소스 검색

feat(tbr-calculation): refatora calculo usando contratos como fonte + fluxo em lote

- Service deriva start_date, TBR, porte e dia de vencimento direto de
  franchisee_contracts (substitui dependencia de franchisee_tbrs)
- FNM (20%) e Manutencao (30%) viram constantes do service
- royalties_bracket_id agora referencia inhabitant_classifications
- Novos metodos: previewBatch, generateBatch (geracao multi-unidade)
- Endpoints novos: POST /tbr-calculation/preview-batch e
  POST /tbr-calculation/generate-batch
- Validacao de duplicata por unit_id + contract_month_reference

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ebagabee 3 주 전
부모
커밋
7d3ba4587c

+ 49 - 2
app/Http/Controllers/TbrCalculationController.php

@@ -2,9 +2,11 @@
 
 namespace App\Http\Controllers;
 
-use App\Services\TbrCalculationService;
+use App\Http\Requests\TbrCalculationBatchRequest;
 use App\Http\Requests\TbrCalculationRequest;
+use App\Http\Resources\FranchiseeAccountReceiveResource;
 use App\Http\Resources\TbrCalculationResource;
+use App\Services\TbrCalculationService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 
@@ -16,10 +18,25 @@ public function __construct(
 
     public function index(Request $request): JsonResponse
     {
-        $items = $this->service->paginate($request->integer('per_page', 15));
+        $items = $this->service->listAll($request->integer('limit', 100));
         return $this->successResponse(payload: TbrCalculationResource::collection($items));
     }
 
+    public function preview(TbrCalculationRequest $request): JsonResponse
+    {
+        $payload = $this->service->preview($request->validated());
+        return $this->successResponse(payload: $payload);
+    }
+
+    public function previewBatch(TbrCalculationBatchRequest $request): JsonResponse
+    {
+        $payload = $this->service->previewBatch(
+            (int) $request->validated('reference_year'),
+            (int) $request->validated('reference_month'),
+        );
+        return $this->successResponse(payload: $payload);
+    }
+
     public function store(TbrCalculationRequest $request): JsonResponse
     {
         $item = $this->service->calculate($request->validated());
@@ -31,4 +48,34 @@ public function show(int $id): JsonResponse
         $item = $this->service->findById($id);
         return $this->successResponse(payload: new TbrCalculationResource($item));
     }
+
+    public function generateReceivable(int $id): JsonResponse
+    {
+        $receive = $this->service->generateReceivable($id);
+        return $this->successResponse(
+            payload: new FranchiseeAccountReceiveResource($receive),
+            message: 'Conta a Receber gerada com sucesso.',
+            code: 201,
+        );
+    }
+
+    public function generateBatch(TbrCalculationBatchRequest $request): JsonResponse
+    {
+        $result = $this->service->generateBatch(
+            (int) $request->validated('reference_year'),
+            (int) $request->validated('reference_month'),
+            $request->validated('unit_ids'),
+        );
+
+        return $this->successResponse(
+            payload: $result,
+            message: sprintf(
+                '%d título(s) gerado(s), %d pulado(s), %d erro(s).',
+                $result['generated_count'],
+                $result['skipped_count'],
+                $result['error_count'],
+            ),
+            code: 201,
+        );
+    }
 }

+ 27 - 0
app/Http/Requests/TbrCalculationBatchRequest.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class TbrCalculationBatchRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'reference_year'  => ['required', 'integer', 'min:2020', 'max:2099'],
+            'reference_month' => ['required', 'integer', 'min:1', 'max:12'],
+            'unit_ids'        => ['nullable', 'array'],
+            'unit_ids.*'      => ['integer', 'exists:units,id'],
+        ];
+    }
+
+    public function messages(): array
+    {
+        return [
+            'reference_year.required'  => 'O ano de referência é obrigatório.',
+            'reference_month.required' => 'O mês de referência é obrigatório.',
+            'reference_month.between'  => 'O mês de referência deve ser entre 1 e 12.',
+        ];
+    }
+}

+ 17 - 10
app/Http/Requests/TbrCalculationRequest.php

@@ -9,22 +9,29 @@ class TbrCalculationRequest extends FormRequest
     public function rules(): array
     {
         return [
-            'unit_id'                  => ['required', 'integer', 'exists:units,id'],
-            'revenue_value'            => ['required', 'numeric', 'min:0'],
-            'contract_month_reference' => ['required', 'integer', 'min:1', 'max:60'],
+            'unit_id'         => ['required', 'integer', 'exists:units,id'],
+            'reference_year'  => ['required', 'integer', 'min:2020', 'max:2099'],
+            'reference_month' => ['required', 'integer', 'min:1', 'max:12'],
+            'revenue_value'   => ['nullable', 'numeric', 'min:0'],
         ];
     }
 
     public function messages(): array
     {
         return [
-            'unit_id.required'                  => 'A unidade é obrigatória.',
-            'unit_id.exists'                    => 'Unidade não encontrada.',
-            'revenue_value.required'            => 'O valor do faturamento é obrigatório.',
-            'revenue_value.min'                 => 'O faturamento não pode ser negativo.',
-            'contract_month_reference.required' => 'O mês de referência do contrato é obrigatório.',
-            'contract_month_reference.min'      => 'O mês de referência deve ser entre 1 e 60.',
-            'contract_month_reference.max'      => 'O mês de referência deve ser entre 1 e 60.',
+            'unit_id.required'         => 'A unidade é obrigatória.',
+            'unit_id.exists'           => 'Unidade não encontrada.',
+            'reference_year.required'  => 'O ano de referência é obrigatório.',
+            'reference_month.required' => 'O mês de referência é obrigatório.',
+            'reference_month.between'  => 'O mês de referência deve ser entre 1 e 12.',
+            'revenue_value.min'        => 'O faturamento não pode ser negativo.',
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if ($this->revenue_value === null || $this->revenue_value === '') {
+            $this->merge(['revenue_value' => 0]);
+        }
+    }
 }

+ 4 - 2
app/Http/Resources/TbrCalculationResource.php

@@ -26,10 +26,8 @@ public function toArray(Request $request): array
             'royalties_bracket_id'             => $this->royalties_bracket_id,
             'royalties_bracket_percentage'     => $this->royalties_bracket_percentage,
             'royalties_bracket_value'          => $this->royalties_bracket_value,
-            'fnm_bracket_id'                   => $this->fnm_bracket_id,
             'fnm_bracket_percentage'           => $this->fnm_bracket_percentage,
             'fnm_bracket_value'                => $this->fnm_bracket_value,
-            'maintenance_bracket_id'           => $this->maintenance_bracket_id,
             'maintenance_bracket_percentage'   => $this->maintenance_bracket_percentage,
             'maintenance_bracket_value'        => $this->maintenance_bracket_value,
             'royalties_effective_percentage'   => $this->royalties_effective_percentage,
@@ -44,6 +42,10 @@ public function toArray(Request $request): array
             'user_id'                          => $this->user_id,
             'royalties_applied_criteria'       => $this->royalties_applied_criteria,
             'receivable_generated'             => $this->receivable_generated,
+            'unit'                             => $this->whenLoaded('unit', fn () => [
+                'id'           => $this->unit->id,
+                'fantasy_name' => $this->unit->fantasy_name,
+            ]),
             'created_at'                       => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
             'updated_at'                       => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
         ];

+ 2 - 18
app/Models/TbrCalculation.php

@@ -12,10 +12,8 @@
  * @property numeric $revenue_value
  * @property int $contract_month_reference
  * @property numeric $tbr_value
- * @property int $fnm_bracket_id
  * @property numeric $fnm_bracket_percentage
  * @property numeric $fnm_bracket_value
- * @property int $maintenance_bracket_id
  * @property numeric $maintenance_bracket_percentage
  * @property numeric $maintenance_bracket_value
  * @property int $royalties_bracket_id
@@ -35,9 +33,7 @@
  * @property bool $receivable_generated
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at
- * @property-read \App\Models\FranchiseeFnmBracket $fnmBracket
- * @property-read \App\Models\FranchiseeMaintenanceBracket $maintenanceBracket
- * @property-read \App\Models\FranchiseeRoyaltiesBracket $royaltiesBracket
+ * @property-read \App\Models\InhabitantClassification $royaltiesBracket
  * @property-read \App\Models\Unit $unit
  * @property-read \App\Models\User $user
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation newModelQuery()
@@ -47,13 +43,11 @@
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereContractMonthReference($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereCreatedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereFinalValue($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereFnmBracketId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereFnmBracketPercentage($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereFnmBracketValue($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereFnmEffectivePercentage($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereFnmEffectiveValue($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereMaintenanceBracketId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereMaintenanceBracketPercentage($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereMaintenanceBracketValue($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|TbrCalculation whereMaintenanceEffectivePercentage($value)
@@ -109,19 +103,9 @@ public function unit(): BelongsTo
         return $this->belongsTo(Unit::class, 'unit_id');
     }
 
-    public function fnmBracket(): BelongsTo
-    {
-        return $this->belongsTo(FranchiseeFnmBracket::class, 'fnm_bracket_id');
-    }
-
-    public function maintenanceBracket(): BelongsTo
-    {
-        return $this->belongsTo(FranchiseeMaintenanceBracket::class, 'maintenance_bracket_id');
-    }
-
     public function royaltiesBracket(): BelongsTo
     {
-        return $this->belongsTo(FranchiseeRoyaltiesBracket::class, 'royalties_bracket_id');
+        return $this->belongsTo(InhabitantClassification::class, 'royalties_bracket_id');
     }
 
     public function user(): BelongsTo

+ 379 - 81
app/Services/TbrCalculationService.php

@@ -2,21 +2,24 @@
 
 namespace App\Services;
 
-use App\Models\FranchiseeFnmBracket;
-use App\Models\FranchiseeMaintenanceBracket;
-use App\Models\FranchiseeRoyaltiesBracket;
-use App\Models\FranchiseeTbr;
-use App\Models\FranchiseeUnit;
+use App\Models\FranchiseeAccountReceive;
+use App\Models\FranchiseeAccountReceiveDetail;
+use App\Models\FranchiseeContract;
+use App\Models\InhabitantClassification;
+use App\Models\Tbr;
 use App\Models\TbrCalculation;
-use App\Models\Unit;
-use Illuminate\Support\Facades\DB;
+use Carbon\Carbon;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
 
 class TbrCalculationService
 {
     private const ROYALTIES_REVENUE_RATE = 0.08;
     private const FNM_REVENUE_RATE       = 0.02;
+    private const FNM_BRACKET_PERCENTAGE = 0.20;
+    private const MAINTENANCE_RATE       = 0.30;
     private const EXEMPT_THRESHOLD_MONTH = 3;
 
     public function paginate(int $perPage = 15): LengthAwarePaginator
@@ -26,92 +29,343 @@ public function paginate(int $perPage = 15): LengthAwarePaginator
             ->paginate($perPage);
     }
 
+    public function listAll(int $limit = 100): \Illuminate\Database\Eloquent\Collection
+    {
+        return TbrCalculation::with(['unit', 'user'])
+            ->orderBy('created_at', 'desc')
+            ->limit($limit)
+            ->get();
+    }
+
     public function findById(int $id): ?TbrCalculation
     {
         return TbrCalculation::with([
             'unit',
             'user',
             'royaltiesBracket',
-            'fnmBracket',
-            'maintenanceBracket',
         ])->find($id);
     }
 
+    public function preview(array $data): array
+    {
+        $contract = $this->resolveContract($data['unit_id']);
+        return $this->buildPreview($contract, (int) $data['reference_year'], (int) $data['reference_month'], (float) ($data['revenue_value'] ?? 0));
+    }
+
+    public function previewBatch(int $referenceYear, int $referenceMonth): array
+    {
+        $contracts = $this->loadActiveContracts($referenceYear, $referenceMonth);
+
+        return $contracts->map(function (FranchiseeContract $contract) use ($referenceYear, $referenceMonth) {
+            try {
+                return $this->buildPreview($contract, $referenceYear, $referenceMonth, 0);
+            } catch (ValidationException $e) {
+                return [
+                    'unit_id'   => $contract->unit_id,
+                    'unit_name' => $contract->unit?->fantasy_name,
+                    'error'     => collect($e->errors())->flatten()->first(),
+                ];
+            }
+        })->values()->toArray();
+    }
+
     public function calculate(array $data): TbrCalculation
     {
         return DB::transaction(function () use ($data) {
-            $unit = Unit::findOrFail($data['unit_id']);
-
-            $franchiseeUnit = FranchiseeUnit::where('unit_id', $unit->id)->firstOrFail();
-
-            $franchiseeTbr = FranchiseeTbr::where('franchisee_id', $franchiseeUnit->franchisee_id)
-                ->where('year', now()->year)
-                ->firstOrFail();
-
-            $tbrValue      = (float) $franchiseeTbr->tbr_value;
-            $contractMonth = (int) $data['contract_month_reference'];
-            $revenueValue  = (float) $data['revenue_value'];
-
-            $royaltiesBracket  = $this->findBracket(FranchiseeRoyaltiesBracket::class, $franchiseeUnit->franchisee_id, $contractMonth);
-            $fnmBracket        = $this->findBracket(FranchiseeFnmBracket::class, $franchiseeUnit->franchisee_id, $contractMonth);
-            $maintenanceBracket = $this->findBracket(FranchiseeMaintenanceBracket::class, $franchiseeUnit->franchisee_id, $contractMonth);
-
-            $royaltiesBracketValue  = round((float) $royaltiesBracket->percentage * $tbrValue, 2);
-            $fnmBracketValue        = round((float) $fnmBracket->percentage * $tbrValue, 2);
-            $maintenanceBracketValue = round((float) $maintenanceBracket->percentage * $tbrValue, 2);
-
-            [$royaltiesEffectiveValue, $royaltiesEffectivePercentage, $royaltiesAppliedCriteria,
-             $fnmEffectiveValue, $fnmEffectivePercentage] = $this->resolveEffectiveValues(
-                $contractMonth,
-                $revenueValue,
-                $royaltiesBracket,
-                $royaltiesBracketValue,
-                $fnmBracket,
-                $fnmBracketValue,
-            );
-
-            $maintenanceEffectiveValue      = $maintenanceBracketValue;
-            $maintenanceEffectivePercentage = (float) $maintenanceBracket->percentage;
-
-            $bracketSubtotal = round($royaltiesBracketValue + $fnmBracketValue + $maintenanceBracketValue, 2);
-            $subtotal        = round($royaltiesEffectiveValue + $fnmEffectiveValue + $maintenanceEffectiveValue, 2);
-
-            return TbrCalculation::create([
-                'unit_id'                          => $unit->id,
-                'revenue_value'                    => $revenueValue,
-                'contract_month_reference'         => $contractMonth,
-                'tbr_value'                        => $tbrValue,
-                'royalties_bracket_id'             => $royaltiesBracket->id,
-                'royalties_bracket_percentage'     => $royaltiesBracket->percentage,
-                'royalties_bracket_value'          => $royaltiesBracketValue,
-                'fnm_bracket_id'                   => $fnmBracket->id,
-                'fnm_bracket_percentage'           => $fnmBracket->percentage,
-                'fnm_bracket_value'                => $fnmBracketValue,
-                'maintenance_bracket_id'           => $maintenanceBracket->id,
-                'maintenance_bracket_percentage'   => $maintenanceBracket->percentage,
-                'maintenance_bracket_value'        => $maintenanceBracketValue,
-                'royalties_effective_percentage'   => $royaltiesEffectivePercentage,
-                'royalties_effective_value'        => $royaltiesEffectiveValue,
-                'fnm_effective_percentage'         => $fnmEffectivePercentage,
-                'fnm_effective_value'              => $fnmEffectiveValue,
-                'maintenance_effective_percentage' => $maintenanceEffectivePercentage,
-                'maintenance_effective_value'      => $maintenanceEffectiveValue,
-                'bracket_subtotal'                 => $bracketSubtotal,
-                'subtotal'                         => $subtotal,
-                'final_value'                      => $subtotal,
-                'user_id'                          => Auth::id(),
-                'royalties_applied_criteria'       => $royaltiesAppliedCriteria,
-                'receivable_generated'             => false,
-            ]);
+            $contract = $this->resolveContract($data['unit_id']);
+            $payload  = $this->buildPreview($contract, (int) $data['reference_year'], (int) $data['reference_month'], (float) ($data['revenue_value'] ?? 0));
+
+            return $this->persistCalculation($payload);
+        });
+    }
+
+    public function generateReceivable(int $calculationId): FranchiseeAccountReceive
+    {
+        return DB::transaction(function () use ($calculationId) {
+            $calculation = TbrCalculation::lockForUpdate()->findOrFail($calculationId);
+
+            if ($calculation->receivable_generated) {
+                throw ValidationException::withMessages([
+                    'tbr_calculation_id' => 'Já existe um título gerado para este cálculo.',
+                ]);
+            }
+
+            $duplicate = TbrCalculation::where('unit_id', $calculation->unit_id)
+                ->where('contract_month_reference', $calculation->contract_month_reference)
+                ->where('receivable_generated', true)
+                ->where('id', '!=', $calculation->id)
+                ->exists();
+
+            if ($duplicate) {
+                throw ValidationException::withMessages([
+                    'tbr_calculation_id' => 'Já existe um título gerado para esta unidade no mês de contrato '
+                        . $calculation->contract_month_reference . '.',
+                ]);
+            }
+
+            $contract = FranchiseeContract::where('unit_id', $calculation->unit_id)
+                ->orderByDesc('start_date')
+                ->first();
+
+            return $this->buildReceivable($calculation, $contract);
         });
     }
 
+    public function generateBatch(int $referenceYear, int $referenceMonth, ?array $unitIds = null): array
+    {
+        $contracts = $this->loadActiveContracts($referenceYear, $referenceMonth);
+
+        if ($unitIds !== null) {
+            $contracts = $contracts->filter(fn ($c) => in_array($c->unit_id, $unitIds, true))->values();
+        }
+
+        $generated = [];
+        $skipped   = [];
+        $errors    = [];
+
+        foreach ($contracts as $contract) {
+            try {
+                DB::transaction(function () use ($contract, $referenceYear, $referenceMonth, &$generated, &$skipped) {
+                    $payload = $this->buildPreview($contract, $referenceYear, $referenceMonth, 0);
+
+                    if ($payload['receivable_already_generated']) {
+                        $skipped[] = [
+                            'unit_id'   => $contract->unit_id,
+                            'unit_name' => $payload['unit_name'],
+                            'reason'    => 'Já gerado para este mês de contrato.',
+                        ];
+                        return;
+                    }
+
+                    $calculation = $this->persistCalculation($payload);
+                    $receive     = $this->buildReceivable($calculation, $contract);
+
+                    $generated[] = [
+                        'unit_id'             => $contract->unit_id,
+                        'unit_name'           => $payload['unit_name'],
+                        'tbr_calculation_id'  => $calculation->id,
+                        'receivable_id'       => $receive->id,
+                        'total'               => $payload['final_value'],
+                    ];
+                });
+            } catch (ValidationException $e) {
+                $errors[] = [
+                    'unit_id'   => $contract->unit_id,
+                    'unit_name' => $contract->unit?->fantasy_name,
+                    'reason'    => collect($e->errors())->flatten()->first(),
+                ];
+            } catch (\Throwable $e) {
+                $errors[] = [
+                    'unit_id'   => $contract->unit_id,
+                    'unit_name' => $contract->unit?->fantasy_name,
+                    'reason'    => $e->getMessage(),
+                ];
+            }
+        }
+
+        return [
+            'generated_count' => count($generated),
+            'skipped_count'   => count($skipped),
+            'error_count'     => count($errors),
+            'generated'       => $generated,
+            'skipped'         => $skipped,
+            'errors'          => $errors,
+        ];
+    }
+
+    private function loadActiveContracts(int $referenceYear, int $referenceMonth): \Illuminate\Support\Collection
+    {
+        $referenceLastDay  = Carbon::createFromDate($referenceYear, $referenceMonth, 1)->endOfMonth()->toDateString();
+        $referenceFirstDay = Carbon::createFromDate($referenceYear, $referenceMonth, 1)->startOfMonth()->toDateString();
+
+        return FranchiseeContract::with(['unit', 'municipalitySize'])
+            ->whereNotNull('start_date')
+            ->whereNotNull('municipality_size_id')
+            ->where('start_date', '<=', $referenceLastDay)
+            ->where(function ($q) use ($referenceFirstDay) {
+                $q->whereNull('end_date')->orWhere('end_date', '>=', $referenceFirstDay);
+            })
+            ->orderByDesc('start_date')
+            ->orderByDesc('id')
+            ->get()
+            ->unique('unit_id')
+            ->values();
+    }
+
+    private function resolveContract(int $unitId): FranchiseeContract
+    {
+        $contract = FranchiseeContract::with(['unit', 'municipalitySize'])
+            ->where('unit_id', $unitId)
+            ->whereNotNull('start_date')
+            ->orderByDesc('start_date')
+            ->orderByDesc('id')
+            ->first();
+
+        if (!$contract) {
+            throw ValidationException::withMessages([
+                'unit_id' => 'Unidade não possui contrato cadastrado.',
+            ]);
+        }
+
+        if (!$contract->municipality_size_id) {
+            throw ValidationException::withMessages([
+                'unit_id' => 'O contrato da unidade não tem a faixa de habitantes definida. Edite o contrato para informá-la.',
+            ]);
+        }
+
+        return $contract;
+    }
+
+    private function buildPreview(FranchiseeContract $contract, int $referenceYear, int $referenceMonth, float $revenueValue): array
+    {
+        $tbrValue = (float) ($contract->tbr_fixed_value ?? 0);
+
+        if ($tbrValue <= 0) {
+            $tbrValue = (float) (Tbr::where('year', $referenceYear)->orderByDesc('id')->value('tbr_value') ?? 0);
+        }
+
+        if ($tbrValue <= 0) {
+            throw ValidationException::withMessages([
+                'unit_id' => 'TBR não definida para o contrato nem para o ano de referência.',
+            ]);
+        }
+
+        $contractMonth = $this->resolveContractMonth($contract->start_date, $referenceYear, $referenceMonth);
+        $municipalitySizeId = (int) $contract->municipality_size_id;
+
+        $royaltiesBracket  = $this->findRoyaltiesBracket($municipalitySizeId, $contractMonth);
+        $fnmPercentage     = $this->resolveFnmPercentage($contractMonth);
+        $maintenancePercentage = self::MAINTENANCE_RATE;
+
+        $royaltiesBracketValue   = round((float) $royaltiesBracket->tbr_percentage * $tbrValue, 2);
+        $fnmBracketValue         = round($fnmPercentage * $tbrValue, 2);
+        $maintenanceBracketValue = round($maintenancePercentage * $tbrValue, 2);
+
+        [$royaltiesEffectiveValue, $royaltiesEffectivePercentage, $royaltiesAppliedCriteria,
+         $fnmEffectiveValue, $fnmEffectivePercentage] = $this->resolveEffectiveValues(
+            $contractMonth,
+            $revenueValue,
+            (float) $royaltiesBracket->tbr_percentage,
+            $royaltiesBracketValue,
+            $fnmPercentage,
+            $fnmBracketValue,
+        );
+
+        $maintenanceEffectiveValue      = $maintenanceBracketValue;
+        $maintenanceEffectivePercentage = $maintenancePercentage;
+
+        $bracketSubtotal = round($royaltiesBracketValue + $fnmBracketValue + $maintenanceBracketValue, 2);
+        $subtotal        = round($royaltiesEffectiveValue + $fnmEffectiveValue + $maintenanceEffectiveValue, 2);
+
+        return [
+            'unit_id'                          => $contract->unit_id,
+            'unit_name'                        => $contract->unit?->fantasy_name,
+            'contract_id'                      => $contract->id,
+            'reference_year'                   => $referenceYear,
+            'reference_month'                  => $referenceMonth,
+            'contract_month_reference'         => $contractMonth,
+            'revenue_value'                    => $revenueValue,
+            'tbr_value'                        => $tbrValue,
+            'municipality_size_id'             => $municipalitySizeId,
+            'municipality_size_name'           => $contract->municipalitySize?->description,
+            'royalties_bracket_id'             => $royaltiesBracket->id,
+            'royalties_bracket_percentage'     => (float) $royaltiesBracket->tbr_percentage,
+            'royalties_bracket_value'          => $royaltiesBracketValue,
+            'fnm_bracket_percentage'           => $fnmPercentage,
+            'fnm_bracket_value'                => $fnmBracketValue,
+            'maintenance_bracket_percentage'   => $maintenancePercentage,
+            'maintenance_bracket_value'        => $maintenanceBracketValue,
+            'royalties_effective_percentage'   => $royaltiesEffectivePercentage,
+            'royalties_effective_value'        => $royaltiesEffectiveValue,
+            'fnm_effective_percentage'         => $fnmEffectivePercentage,
+            'fnm_effective_value'              => $fnmEffectiveValue,
+            'maintenance_effective_percentage' => $maintenanceEffectivePercentage,
+            'maintenance_effective_value'      => $maintenanceEffectiveValue,
+            'bracket_subtotal'                 => $bracketSubtotal,
+            'subtotal'                         => $subtotal,
+            'final_value'                      => $subtotal,
+            'royalties_applied_criteria'       => $royaltiesAppliedCriteria,
+            'receivable_already_generated'     => $this->existingReceivable($contract->unit_id, $contractMonth),
+        ];
+    }
+
+    private function persistCalculation(array $payload): TbrCalculation
+    {
+        return TbrCalculation::create([
+            'unit_id'                          => $payload['unit_id'],
+            'revenue_value'                    => $payload['revenue_value'],
+            'contract_month_reference'         => $payload['contract_month_reference'],
+            'tbr_value'                        => $payload['tbr_value'],
+            'royalties_bracket_id'             => $payload['royalties_bracket_id'],
+            'royalties_bracket_percentage'     => $payload['royalties_bracket_percentage'],
+            'royalties_bracket_value'          => $payload['royalties_bracket_value'],
+            'fnm_bracket_percentage'           => $payload['fnm_bracket_percentage'],
+            'fnm_bracket_value'                => $payload['fnm_bracket_value'],
+            'maintenance_bracket_percentage'   => $payload['maintenance_bracket_percentage'],
+            'maintenance_bracket_value'        => $payload['maintenance_bracket_value'],
+            'royalties_effective_percentage'   => $payload['royalties_effective_percentage'],
+            'royalties_effective_value'        => $payload['royalties_effective_value'],
+            'fnm_effective_percentage'         => $payload['fnm_effective_percentage'],
+            'fnm_effective_value'              => $payload['fnm_effective_value'],
+            'maintenance_effective_percentage' => $payload['maintenance_effective_percentage'],
+            'maintenance_effective_value'      => $payload['maintenance_effective_value'],
+            'bracket_subtotal'                 => $payload['bracket_subtotal'],
+            'subtotal'                         => $payload['subtotal'],
+            'final_value'                      => $payload['final_value'],
+            'user_id'                          => Auth::id(),
+            'royalties_applied_criteria'       => $payload['royalties_applied_criteria'],
+            'receivable_generated'             => false,
+        ]);
+    }
+
+    private function buildReceivable(TbrCalculation $calculation, ?FranchiseeContract $contract): FranchiseeAccountReceive
+    {
+        $referenceDate  = Carbon::parse($calculation->created_at);
+        $referenceLabel = $referenceDate->format('m/Y');
+        $dueDate        = $this->resolveDueDate($contract, $referenceDate);
+
+        $receive = FranchiseeAccountReceive::create([
+            'unit_id'            => $calculation->unit_id,
+            'tbr_calculation_id' => $calculation->id,
+            'order'              => $calculation->contract_month_reference,
+            'history'            => 'Royalties / FNM / Manutenção — ' . $referenceLabel,
+            'value'              => $calculation->final_value,
+            'paid_value'         => 0,
+            'due_date'           => $dueDate,
+            'discount'           => 0,
+            'fees'               => 0,
+            'obs'                => null,
+            'asaas_id'           => null,
+            'status'             => 'pending',
+        ]);
+
+        FranchiseeAccountReceiveDetail::create([
+            'franchisee_account_receive_id' => $receive->id,
+            'value'                         => $calculation->royalties_effective_value,
+            'history'                       => 'Royalties ' . $referenceLabel,
+        ]);
+        FranchiseeAccountReceiveDetail::create([
+            'franchisee_account_receive_id' => $receive->id,
+            'value'                         => $calculation->fnm_effective_value,
+            'history'                       => 'FNM ' . $referenceLabel,
+        ]);
+        FranchiseeAccountReceiveDetail::create([
+            'franchisee_account_receive_id' => $receive->id,
+            'value'                         => $calculation->maintenance_effective_value,
+            'history'                       => 'Taxa Manutenção ' . $referenceLabel,
+        ]);
+
+        $calculation->update(['receivable_generated' => true]);
+
+        return $receive->load('details');
+    }
+
     private function resolveEffectiveValues(
         int $contractMonth,
         float $revenueValue,
-        FranchiseeRoyaltiesBracket $royaltiesBracket,
+        float $royaltiesBracketPercentage,
         float $royaltiesBracketValue,
-        FranchiseeFnmBracket $fnmBracket,
+        float $fnmBracketPercentage,
         float $fnmBracketValue,
     ): array {
         if ($contractMonth <= self::EXEMPT_THRESHOLD_MONTH) {
@@ -123,7 +377,7 @@ private function resolveEffectiveValues(
 
         if ($royaltiesBracketValue >= $royaltiesFromRevenue) {
             $royaltiesEffectiveValue      = $royaltiesBracketValue;
-            $royaltiesEffectivePercentage = (float) $royaltiesBracket->percentage;
+            $royaltiesEffectivePercentage = $royaltiesBracketPercentage;
             $royaltiesAppliedCriteria     = 'tbr_fixo';
         } else {
             $royaltiesEffectiveValue      = $royaltiesFromRevenue;
@@ -133,7 +387,7 @@ private function resolveEffectiveValues(
 
         if ($fnmBracketValue >= $fnmFromRevenue) {
             $fnmEffectiveValue      = $fnmBracketValue;
-            $fnmEffectivePercentage = (float) $fnmBracket->percentage;
+            $fnmEffectivePercentage = $fnmBracketPercentage;
         } else {
             $fnmEffectiveValue      = $fnmFromRevenue;
             $fnmEffectivePercentage = self::FNM_REVENUE_RATE;
@@ -142,13 +396,57 @@ private function resolveEffectiveValues(
         return [$royaltiesEffectiveValue, $royaltiesEffectivePercentage, $royaltiesAppliedCriteria, $fnmEffectiveValue, $fnmEffectivePercentage];
     }
 
-    private function findBracket(string $modelClass, int $franchiseeId, int $contractMonth): mixed
+    private function resolveContractMonth(?Carbon $startDate, int $year, int $month): int
+    {
+        if (!$startDate) {
+            return 1;
+        }
+
+        $start     = $startDate->copy()->startOfMonth();
+        $reference = Carbon::createFromDate($year, $month, 1)->startOfMonth();
+        $diff      = (int) $start->diffInMonths($reference);
+
+        return max(1, $diff + 1);
+    }
+
+    private function findRoyaltiesBracket(int $municipalitySizeId, int $contractMonth): InhabitantClassification
     {
-        return $modelClass::where('franchisee_id', $franchiseeId)
-            ->where('start_month', '<=', $contractMonth)
+        $bracket = InhabitantClassification::where('municipality_size_id', $municipalitySizeId)
+            ->where('is_renewal', false)
+            ->where('start', '<=', $contractMonth)
             ->where(function ($q) use ($contractMonth) {
-                $q->whereNull('end_month')->orWhere('end_month', '>=', $contractMonth);
+                $q->whereNull('end')->orWhere('end', '>=', $contractMonth);
             })
-            ->firstOrFail();
+            ->orderBy('start')
+            ->first();
+
+        if (!$bracket) {
+            throw ValidationException::withMessages([
+                'municipality_size_id' => 'Não foi encontrada faixa de royalties para o porte e mês de contrato informados.',
+            ]);
+        }
+
+        return $bracket;
+    }
+
+    private function resolveFnmPercentage(int $contractMonth): float
+    {
+        return $contractMonth <= self::EXEMPT_THRESHOLD_MONTH ? 0.0 : self::FNM_BRACKET_PERCENTAGE;
+    }
+
+    private function resolveDueDate(?FranchiseeContract $contract, Carbon $referenceDate): Carbon
+    {
+        $dueDay = (int) ($contract?->invoice_due_date ?? 10);
+        $dueDay = max(1, min(28, $dueDay));
+
+        return $referenceDate->copy()->addMonthNoOverflow()->day($dueDay);
+    }
+
+    private function existingReceivable(int $unitId, int $contractMonth): bool
+    {
+        return TbrCalculation::where('unit_id', $unitId)
+            ->where('contract_month_reference', $contractMonth)
+            ->where('receivable_generated', true)
+            ->exists();
     }
 }

+ 2 - 3
database/migrations/2026_04_23_165638_create_tbr_calculations_table.php

@@ -15,15 +15,14 @@ public function up(): void
             $table->unsignedInteger('contract_month_reference');
             $table->decimal('tbr_value', 10, 2);
 
-            $table->foreignId('fnm_bracket_id')->constrained('franchisee_fnm_brackets');
             $table->decimal('fnm_bracket_percentage', 5, 4);
             $table->decimal('fnm_bracket_value', 10, 2);
 
-            $table->foreignId('maintenance_bracket_id')->constrained('franchisee_maintenance_brackets');
             $table->decimal('maintenance_bracket_percentage', 5, 4);
             $table->decimal('maintenance_bracket_value', 10, 2);
 
-            $table->foreignId('royalties_bracket_id')->constrained('franchisee_royalties_brackets');
+            $table->foreignId('royalties_bracket_id')
+                ->constrained('inhabitant_classifications');
             $table->decimal('royalties_bracket_percentage', 5, 4);
             $table->decimal('royalties_bracket_value', 10, 2);
 

+ 8 - 1
routes/authRoutes/tbr_calculation.php

@@ -1,12 +1,19 @@
 <?php
 
-use Illuminate\Support\Facades\Route;
 use App\Http\Controllers\TbrCalculationController;
+use Illuminate\Support\Facades\Route;
 
 Route::controller(TbrCalculationController::class)->prefix('tbr-calculation')->group(function () {
     Route::get('/', 'index')->middleware('permission:tbr-calculation,view');
 
+    Route::post('/preview', 'preview')->middleware('permission:tbr-calculation,view');
+    Route::post('/preview-batch', 'previewBatch')->middleware('permission:tbr-calculation,view');
+
     Route::post('/', 'store')->middleware('permission:tbr-calculation,add');
+    Route::post('/generate-batch', 'generateBatch')->middleware('permission:tbr-calculation,add');
 
     Route::get('/{id}', 'show')->middleware('permission:tbr-calculation,view');
+
+    Route::post('/{id}/generate-receivable', 'generateReceivable')
+        ->middleware('permission:tbr-calculation,add');
 });