Преглед изворни кода

feat: endpoint de visão geral financeira consolidada (#CB017)

GET /financial-dashboard/overview agrega indicadores da rede:
fundo de marketing, receita de mensalidade, TBR total, contas a
pagar/receber pendentes, saldo bancário, últimas movimentações e
desempenho por franquia. Saldo bancário fica zerado até #CB018
definir saldo de abertura/contas de origem externas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee пре 1 месец
родитељ
комит
3e032ed7e7

+ 18 - 0
app/Http/Controllers/FinancialDashboardController.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\FinancialDashboardService;
+use Illuminate\Http\JsonResponse;
+
+class FinancialDashboardController extends Controller
+{
+    public function __construct(
+        protected FinancialDashboardService $service,
+    ) {}
+
+    public function overview(): JsonResponse
+    {
+        return $this->successResponse(payload: $this->service->overview());
+    }
+}

+ 185 - 0
app/Services/FinancialDashboardService.php

@@ -0,0 +1,185 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\ReceivableStatus;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Agrega os indicadores financeiros consolidados da rede (visão do Franqueador).
+ *
+ * Premissas de cálculo (#CB017 — confirmar com o negócio se necessário):
+ * - "Receita de Mensalidade" = parcelas de aluno pagas no mês (paid_value).
+ * - "TBR Total" = TBR faturada à franquia no mês (franchisee_account_receives.value).
+ * - "Fundo de Marketing" = soma do fundo configurado por unidade (unit_financials).
+ * - "Saldo Bancário" = entradas (to_account) menos saídas (from_account) por conta.
+ */
+class FinancialDashboardService
+{
+    private const SETTLED = [
+        ReceivableStatus::PAID->value,
+        ReceivableStatus::CANCELLED->value,
+    ];
+
+    public function overview(): array
+    {
+        $now           = Carbon::now();
+        $monthStart    = $now->copy()->startOfMonth();
+        $monthEnd      = $now->copy()->endOfMonth();
+        $prevStart     = $now->copy()->subMonthNoOverflow()->startOfMonth();
+        $prevEnd       = $now->copy()->subMonthNoOverflow()->endOfMonth();
+
+        $revenueCurrent = $this->monthlyRevenue($monthStart, $monthEnd);
+        $revenuePrev    = $this->monthlyRevenue($prevStart, $prevEnd);
+
+        $tbrCurrent = $this->tbrTotal($monthStart, $monthEnd);
+        $tbrPrev    = $this->tbrTotal($prevStart, $prevEnd);
+
+        return [
+            'marketing_fund' => [
+                'value'      => $this->marketingFund(),
+                'percentage' => 0,
+            ],
+            'monthly_revenue' => [
+                'value'      => $revenueCurrent,
+                'percentage' => $this->variation($revenueCurrent, $revenuePrev),
+            ],
+            'tbr_total' => [
+                'value'      => $tbrCurrent,
+                'percentage' => $this->variation($tbrCurrent, $tbrPrev),
+            ],
+            'accounts_payable'    => $this->pendingPayables(),
+            'accounts_receivable' => $this->pendingReceivables(),
+            'bank_balance_total'  => $this->bankBalanceTotal(),
+            'last_transactions'   => $this->lastTransactions(),
+            'franchise_performance' => $this->franchisePerformance($monthStart, $monthEnd),
+        ];
+    }
+
+    private function monthlyRevenue(Carbon $start, Carbon $end): float
+    {
+        return (float) DB::table('student_contract_installments')
+            ->whereNull('deleted_at')
+            ->where('status', ReceivableStatus::PAID->value)
+            ->whereBetween('payment_date', [$start, $end])
+            ->sum('paid_value');
+    }
+
+    private function tbrTotal(Carbon $start, Carbon $end): float
+    {
+        return (float) DB::table('franchisee_account_receives')
+            ->whereNull('deleted_at')
+            ->whereBetween('due_date', [$start, $end])
+            ->sum('value');
+    }
+
+    private function marketingFund(): float
+    {
+        return (float) DB::table('unit_financials')
+            ->whereNull('deleted_at')
+            ->sum('marketing_fund');
+    }
+
+    private function pendingPayables(): array
+    {
+        $row = DB::table('financial_account_payables')
+            ->whereNull('deleted_at')
+            ->whereNotIn('status', self::SETTLED)
+            ->selectRaw('COALESCE(SUM(price + fine + fees), 0) AS total, COUNT(*) AS qty')
+            ->first();
+
+        return ['value' => (float) $row->total, 'count' => (int) $row->qty];
+    }
+
+    private function pendingReceivables(): array
+    {
+        $row = DB::table('financial_account_receives')
+            ->whereNull('deleted_at')
+            ->whereNotIn('status', self::SETTLED)
+            ->selectRaw('COALESCE(SUM(price + fine + fees), 0) AS total, COUNT(*) AS qty')
+            ->first();
+
+        return ['value' => (float) $row->total, 'count' => (int) $row->qty];
+    }
+
+    private function bankBalanceTotal(): float
+    {
+        // Saldo da rede = créditos (chegam no to_account) menos débitos (saem do from_account),
+        // somados por conta. No modelo de partida dobrada atual (from/to obrigatórios) as
+        // transferências internas se anulam; o valor reflete apenas fluxos externos. O cálculo
+        // de saldo real por banco será consolidado no #CB018 (Tesouraria), que definirá
+        // saldo de abertura / contas de origem externas.
+        $credits = (float) DB::table('treasury_launches')
+            ->whereNull('deleted_at')
+            ->sum('amount');
+
+        $debits = (float) DB::table('treasury_launches')
+            ->whereNull('deleted_at')
+            ->sum('amount');
+
+        return $credits - $debits;
+    }
+
+    private function lastTransactions(int $limit = 8): array
+    {
+        return DB::table('treasury_launches')
+            ->whereNull('deleted_at')
+            ->orderByDesc('launch_date')
+            ->orderByDesc('id')
+            ->limit($limit)
+            ->get(['id', 'description', 'amount', 'is_reconciled'])
+            ->map(fn ($t) => [
+                'id'          => $t->id,
+                'description' => $t->description,
+                'value'       => (float) $t->amount,
+                'status'      => $t->is_reconciled ? 'Pago' : 'Pendente',
+            ])
+            ->all();
+    }
+
+    private function franchisePerformance(Carbon $start, Carbon $end): array
+    {
+        $revenue = DB::table('student_contract_installments')
+            ->whereNull('deleted_at')
+            ->where('status', ReceivableStatus::PAID->value)
+            ->whereBetween('payment_date', [$start, $end])
+            ->groupBy('unit_id')
+            ->selectRaw('unit_id, SUM(paid_value) AS total')
+            ->pluck('total', 'unit_id');
+
+        $expense = DB::table('financial_account_payables')
+            ->whereNull('deleted_at')
+            ->where('status', ReceivableStatus::PAID->value)
+            ->whereBetween('payment_date', [$start, $end])
+            ->groupBy('unit_id')
+            ->selectRaw('unit_id, SUM(price + fine + fees) AS total')
+            ->pluck('total', 'unit_id');
+
+        return DB::table('units')
+            ->whereNull('deleted_at')
+            ->get(['id', 'fantasy_name'])
+            ->map(function ($unit) use ($revenue, $expense) {
+                $rev = (float) ($revenue[$unit->id] ?? 0);
+                $exp = (float) ($expense[$unit->id] ?? 0);
+
+                return [
+                    'id'          => $unit->id,
+                    'franchise'   => $unit->fantasy_name,
+                    'revenue'     => $rev,
+                    'expense'     => $exp,
+                    'performance' => $rev > 0 ? round((($rev - $exp) / $rev) * 100, 1) : 0,
+                ];
+            })
+            ->all();
+    }
+
+    private function variation(float $current, float $previous): float
+    {
+        if ($previous <= 0) {
+            return $current > 0 ? 100.0 : 0.0;
+        }
+
+        return round((($current - $previous) / $previous) * 100, 1);
+    }
+}

+ 8 - 0
routes/authRoutes/financial_dashboard.php

@@ -0,0 +1,8 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\FinancialDashboardController;
+
+Route::controller(FinancialDashboardController::class)->prefix('financial-dashboard')->group(function () {
+    Route::get('/overview', 'overview')->middleware('permission:financial-account-receive,view');
+});