|
|
@@ -0,0 +1,1429 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Tests\Unit\Financeiro;
|
|
|
+
|
|
|
+use App\Models\City;
|
|
|
+use App\Models\Country;
|
|
|
+use App\Models\FranchiseeAccountReceive;
|
|
|
+use App\Models\FranchiseeContract;
|
|
|
+use App\Models\InhabitantClassification;
|
|
|
+use App\Models\MunicipalitySize;
|
|
|
+use App\Models\State;
|
|
|
+use App\Models\Student;
|
|
|
+use App\Models\StudentContract;
|
|
|
+use App\Models\StudentContractInstallment;
|
|
|
+use App\Models\Tbr;
|
|
|
+use App\Models\TbrCalculation;
|
|
|
+use App\Models\Unit;
|
|
|
+use App\Models\UnitAccountPayable;
|
|
|
+use App\Models\UnitAccountReceivable;
|
|
|
+use App\Models\UnitFinancial;
|
|
|
+use App\Services\TbrCalculationService;
|
|
|
+use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
+use Illuminate\Support\Facades\Bus;
|
|
|
+use Illuminate\Validation\ValidationException;
|
|
|
+use Tests\TestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Regras de cobrança do contrato da unidade (Royalties / FNM / Taxa de Manutenção).
|
|
|
+ *
|
|
|
+ * Documentação de apoio: docs/regras-financeiras-contrato-unidade.md
|
|
|
+ *
|
|
|
+ * Cenário base usado na maioria dos testes:
|
|
|
+ * - TBR = R$ 1.000,00 configurada em `tbrs` para 2026 (fácil de conferir a olho)
|
|
|
+ * - Porte GP: meses 1-3 = 0%, meses 4-12 = 75%, meses 13-60 = 100%
|
|
|
+ * - FNM fixo = 20% da TBR | Manutenção = 30% da TBR
|
|
|
+ * - Royalties sobre faturamento = 8% | FNM sobre faturamento = 2%
|
|
|
+ * - Contrato iniciado em 01/01/2026
|
|
|
+ *
|
|
|
+ * Cada teste tem em cima um bloco com três partes:
|
|
|
+ * Regra - a regra de negócio que está sendo verificada
|
|
|
+ * Cenário - o que foi montado no banco antes de chamar o serviço
|
|
|
+ * Espera - o resultado esperado, com as contas abertas
|
|
|
+ * Assim da para conferir se a regra descrita bate com o que o negócio quer, e não
|
|
|
+ * só se o teste passa. Se a regra mudar, o bloco muda junto com o assert.
|
|
|
+ *
|
|
|
+ * Para adicionar um teste novo:
|
|
|
+ * 1. Escreva primeiro o bloco Regra/Cenário/Espera, com os números na mão.
|
|
|
+ * 2. Monte o cenário com os helpers: makeContract(), makeTbrYear(), makeBrackets(),
|
|
|
+ * makeSize(), makeInstallment(), makeUnit().
|
|
|
+ * 3. Use preview($ano, $mês, $faturamento) para simular sem gravar nada;
|
|
|
+ * calculate() para gravar o cálculo; generateReceivable() para gerar o título.
|
|
|
+ * 4. Prefira assertSame com float (ex.: 750.0) nos retornos do preview — são floats.
|
|
|
+ * Nos modelos gravados os decimais voltam como string ('750.00').
|
|
|
+ * 5. Erros de regra chegam como ValidationException; use expectExceptionMessage
|
|
|
+ * com um trecho curto da mensagem.
|
|
|
+ */
|
|
|
+class TbrCalculationRulesTest extends TestCase
|
|
|
+{
|
|
|
+ use RefreshDatabase;
|
|
|
+
|
|
|
+ private TbrCalculationService $service;
|
|
|
+
|
|
|
+ private Unit $unit;
|
|
|
+
|
|
|
+ private MunicipalitySize $sizeGp;
|
|
|
+
|
|
|
+ /** @var array<int, array{student_id:int, contract_id:int}> aluno/contrato reaproveitado por unidade */
|
|
|
+ private array $studentContracts = [];
|
|
|
+
|
|
|
+ protected function setUp(): void
|
|
|
+ {
|
|
|
+ parent::setUp();
|
|
|
+
|
|
|
+ Bus::fake();
|
|
|
+
|
|
|
+ $this->service = new TbrCalculationService;
|
|
|
+ $this->unit = $this->makeUnit();
|
|
|
+ $this->sizeGp = $this->makeSize('GP', 'De 100 mil a 200 mil habitantes');
|
|
|
+
|
|
|
+ $this->makeBrackets($this->sizeGp->id, [
|
|
|
+ [1, 3, 0.0],
|
|
|
+ [4, 12, 0.75],
|
|
|
+ [13, 60, 1.00],
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->makeTbrYear(2026, 1000.00);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------- helpers
|
|
|
+
|
|
|
+ private function makeUnit(string $name = 'Unidade Teste'): Unit
|
|
|
+ {
|
|
|
+ $country = Country::create(['name' => 'Brasil', 'code' => 'BR']);
|
|
|
+ $state = State::create(['name' => 'Paraná', 'code' => 'PR', 'country_id' => $country->id]);
|
|
|
+ $city = City::create(['name' => 'Maringá', 'country_id' => $country->id, 'state_id' => $state->id]);
|
|
|
+
|
|
|
+ return Unit::create([
|
|
|
+ 'fantasy_name' => $name,
|
|
|
+ 'social_reason' => $name.' LTDA',
|
|
|
+ 'cnpj' => '00000000000191',
|
|
|
+ 'street' => 'Rua Teste',
|
|
|
+ 'neighborhood' => 'Centro',
|
|
|
+ 'postal_code' => '87000000',
|
|
|
+ 'city_id' => $city->id,
|
|
|
+ 'state_id' => $state->id,
|
|
|
+ 'email' => 'unidade@teste.com',
|
|
|
+ 'name_responsible' => 'Responsável Teste',
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function makeSize(string $acronym, string $description): MunicipalitySize
|
|
|
+ {
|
|
|
+ return MunicipalitySize::create(['acronym' => $acronym, 'description' => $description]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @param array<int, array{0:int,1:?int,2:float}> $brackets [start, end, percentual] */
|
|
|
+ private function makeBrackets(int $sizeId, array $brackets, bool $isRenewal = false): void
|
|
|
+ {
|
|
|
+ foreach ($brackets as [$start, $end, $percentage]) {
|
|
|
+ InhabitantClassification::create([
|
|
|
+ 'municipality_size_id' => $sizeId,
|
|
|
+ 'description' => "Faixa {$start}-{$end}",
|
|
|
+ 'start' => $start,
|
|
|
+ 'end' => $end,
|
|
|
+ 'tbr_percentage' => $percentage,
|
|
|
+ 'is_renewal' => $isRenewal,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private function makeTbrYear(int $year, float $value, float $fnm = 0.20, float $maintenance = 0.30): Tbr
|
|
|
+ {
|
|
|
+ return Tbr::create([
|
|
|
+ 'year' => $year,
|
|
|
+ 'tbr_value' => $value,
|
|
|
+ 'royalties_percentage' => 0.08,
|
|
|
+ 'fnm_percentage' => $fnm,
|
|
|
+ 'maintenance_percentage' => $maintenance,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function makeContract(array $overrides = []): FranchiseeContract
|
|
|
+ {
|
|
|
+ return FranchiseeContract::create(array_merge([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'protocol' => 1,
|
|
|
+ 'name' => 'Contrato de Franquia',
|
|
|
+ 'description' => 'Contrato de teste',
|
|
|
+ 'start_date' => '2026-01-01',
|
|
|
+ 'end_date' => '2031-01-01',
|
|
|
+ 'signature_date' => '2026-01-01',
|
|
|
+ 'validity_months' => 60,
|
|
|
+ 'invoice_due_date' => 10,
|
|
|
+ 'municipality_size_id' => $this->sizeGp->id,
|
|
|
+ 'tbr_fixed_value' => 1000.00,
|
|
|
+ ], $overrides));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Aluno + contrato mínimos para pendurar as parcelas que formam o faturamento. */
|
|
|
+ private function studentContractFor(int $unitId): array
|
|
|
+ {
|
|
|
+ if (isset($this->studentContracts[$unitId])) {
|
|
|
+ return $this->studentContracts[$unitId];
|
|
|
+ }
|
|
|
+
|
|
|
+ $student = Student::create([
|
|
|
+ 'name' => 'Aluno Teste',
|
|
|
+ 'unit_id' => $unitId,
|
|
|
+ 'status' => 'active',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $contract = StudentContract::create([
|
|
|
+ 'student_id' => $student->id,
|
|
|
+ 'unit_id' => $unitId,
|
|
|
+ 'status' => 'active',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $this->studentContracts[$unitId] = [
|
|
|
+ 'student_id' => $student->id,
|
|
|
+ 'contract_id' => $contract->id,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function makeInstallment(array $overrides = []): StudentContractInstallment
|
|
|
+ {
|
|
|
+ $unitId = $overrides['unit_id'] ?? $this->unit->id;
|
|
|
+ $owner = $this->studentContractFor($unitId);
|
|
|
+
|
|
|
+ return StudentContractInstallment::create(array_merge([
|
|
|
+ 'student_contract_id' => $owner['contract_id'],
|
|
|
+ 'unit_id' => $unitId,
|
|
|
+ 'student_id' => $owner['student_id'],
|
|
|
+ 'type' => 'monthly',
|
|
|
+ 'history' => 'Mensalidade',
|
|
|
+ 'installment_number' => 1,
|
|
|
+ 'total_installments' => 12,
|
|
|
+ 'value' => 100.00,
|
|
|
+ 'due_date' => '2026-04-10',
|
|
|
+ 'status' => 'pending',
|
|
|
+ ], $overrides));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Atalho: preview do cenário base para uma competência, com faturamento informado. */
|
|
|
+ private function preview(int $year, int $month, float $revenue = 0.0): array
|
|
|
+ {
|
|
|
+ return $this->service->preview([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'reference_year' => $year,
|
|
|
+ 'reference_month' => $month,
|
|
|
+ 'revenue_value' => $revenue,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------- mês de contrato / isenção
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Como o sistema descobre em que "mês de contrato" a competência cai.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato iniciado em 01/01/2026; consulta várias competências.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - 01/2026 -> mês 1
|
|
|
+ * - 03/2026 -> mês 3
|
|
|
+ * - 04/2026 -> mês 4
|
|
|
+ * - 01/2027 -> mês 13
|
|
|
+ *
|
|
|
+ * Nota: Fórmula: meses entre o início do mês de start_date e o início do mês de referência, + 1.
|
|
|
+ */
|
|
|
+ public function test_mes_de_contrato_conta_a_partir_do_start_date(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $this->assertSame(1, $this->preview(2026, 1, 1000)['contract_month_reference']);
|
|
|
+ $this->assertSame(3, $this->preview(2026, 3, 1000)['contract_month_reference']);
|
|
|
+ $this->assertSame(4, $this->preview(2026, 4, 1000)['contract_month_reference']);
|
|
|
+ $this->assertSame(13, $this->preview(2027, 1, 1000)['contract_month_reference']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Competência anterior ao início do contrato não pode gerar mês zero ou negativo.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato inicia 01/01/2026 e pede-se a competência 10/2025.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - mês de contrato = 1
|
|
|
+ *
|
|
|
+ * Nota: O cálculo é travado por max(1, diff + 1).
|
|
|
+ */
|
|
|
+ public function test_competencia_anterior_ao_inicio_do_contrato_e_tratada_como_mes_1(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $this->assertSame(1, $this->preview(2025, 10, 1000)['contract_month_reference']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: nos meses 1, 2 e 3 não se cobra Royalties nem FNM.
|
|
|
+ *
|
|
|
+ * Cenário: Faturamento alto (R$ 40.000), que normalmente geraria R$ 3.200 de ROI e R$ 800 de FNM.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 0,00 nos três meses
|
|
|
+ * - FNM = R$ 0,00 nos três meses
|
|
|
+ * - critério registrado = 'tbr_fixo'
|
|
|
+ *
|
|
|
+ * Nota: A isenção ignora o faturamento: mesmo faturando muito, os dois ficam zerados.
|
|
|
+ */
|
|
|
+ public function test_meses_1_a_3_isentam_royalties_e_fnm(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ foreach ([1, 2, 3] as $month) {
|
|
|
+ $result = $this->preview(2026, $month, 40000);
|
|
|
+
|
|
|
+ $this->assertSame(0.0, $result['royalties_effective_value'], "mês {$month}");
|
|
|
+ $this->assertSame(0.0, $result['fnm_effective_value'], "mês {$month}");
|
|
|
+ $this->assertSame('tbr_fixo', $result['royalties_applied_criteria'], "mês {$month}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A isenção dos meses 1-3 vale só para Royalties e FNM; a Manutenção continua.
|
|
|
+ *
|
|
|
+ * Cenário: Competência 02/2026 (mês 2), TBR de R$ 1.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Manutenção = R$ 300,00 (30% da TBR)
|
|
|
+ * - Total do título = R$ 300,00
|
|
|
+ *
|
|
|
+ * Nota: Ponto que costuma gerar dúvida: o mês isento ainda gera cobrança.
|
|
|
+ */
|
|
|
+ public function test_taxa_de_manutencao_e_cobrada_mesmo_nos_meses_isentos(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 2, 40000);
|
|
|
+
|
|
|
+ $this->assertSame(300.0, $result['maintenance_effective_value']);
|
|
|
+ $this->assertSame(300.0, $result['final_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A isenção termina exatamente no fim do mês 3.
|
|
|
+ *
|
|
|
+ * Cenário: Competência 04/2026 (mês 4), faturamento baixo.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties > 0
|
|
|
+ * - FNM > 0
|
|
|
+ */
|
|
|
+ public function test_isencao_acaba_no_mes_4(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 1000);
|
|
|
+
|
|
|
+ $this->assertGreaterThan(0, $result['royalties_effective_value']);
|
|
|
+ $this->assertGreaterThan(0, $result['fnm_effective_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------- regra do maior valor
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: vence o fixo sobre a TBR quando o faturamento é pequeno.
|
|
|
+ *
|
|
|
+ * Cenário: Mês 4, TBR R$ 1.000,00, faturamento R$ 1.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 750,00 (75% x 1.000) e não R$ 80,00 (8% x 1.000)
|
|
|
+ * - FNM = R$ 200,00 (20% x 1.000) e não R$ 20,00 (2% x 1.000)
|
|
|
+ * - Manutenção = R$ 300,00
|
|
|
+ * - Total = R$ 1.250,00
|
|
|
+ * - critério = 'tbr_fixo'
|
|
|
+ */
|
|
|
+ public function test_usa_valor_fixo_da_tbr_quando_faturamento_e_baixo(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 1000.00);
|
|
|
+
|
|
|
+ // Royalties: 75% x 1000 = 750,00 vs 8% x 1000 = 80,00
|
|
|
+ $this->assertSame(750.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame(0.75, $result['royalties_effective_percentage']);
|
|
|
+ $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
|
|
|
+
|
|
|
+ // FNM: 20% x 1000 = 200,00 vs 2% x 1000 = 20,00
|
|
|
+ $this->assertSame(200.0, $result['fnm_effective_value']);
|
|
|
+ $this->assertSame(0.20, $result['fnm_effective_percentage']);
|
|
|
+
|
|
|
+ $this->assertSame(300.0, $result['maintenance_effective_value']);
|
|
|
+ $this->assertSame(1250.0, $result['final_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: vence o percentual do faturamento quando ele supera o fixo.
|
|
|
+ *
|
|
|
+ * Cenário: Mês 4, TBR R$ 1.000,00, faturamento R$ 40.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 3.200,00 (8% x 40.000) e não R$ 750,00
|
|
|
+ * - FNM = R$ 800,00 (2% x 40.000) e não R$ 200,00
|
|
|
+ * - Manutenção = R$ 300,00
|
|
|
+ * - Total = R$ 4.300,00
|
|
|
+ * - critério = 'percentual_faturamento'
|
|
|
+ */
|
|
|
+ public function test_usa_percentual_do_faturamento_quando_faturamento_e_alto(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 40000.00);
|
|
|
+
|
|
|
+ // Royalties: 8% x 40.000 = 3.200,00 > 750,00
|
|
|
+ $this->assertSame(3200.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame(0.08, $result['royalties_effective_percentage']);
|
|
|
+ $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
|
|
|
+
|
|
|
+ // FNM: 2% x 40.000 = 800,00 > 200,00
|
|
|
+ $this->assertSame(800.0, $result['fnm_effective_value']);
|
|
|
+ $this->assertSame(0.02, $result['fnm_effective_percentage']);
|
|
|
+
|
|
|
+ $this->assertSame(300.0, $result['maintenance_effective_value']);
|
|
|
+ $this->assertSame(4300.0, $result['final_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Critério de desempate — valores iguais mantem o fixo da TBR.
|
|
|
+ *
|
|
|
+ * Cenário: Faturamento R$ 9.375,00, em que 8% = R$ 750,00 = exatamente o valor fixo.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 750,00
|
|
|
+ * - critério = 'tbr_fixo'
|
|
|
+ * - percentual gravado = 0,75 (o da faixa, não 0,08)
|
|
|
+ *
|
|
|
+ * Nota: A comparacao no código e >=, então o empate cai no fixo.
|
|
|
+ */
|
|
|
+ public function test_empate_entre_fixo_e_faturamento_mantem_o_fixo_da_tbr(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ // 8% x 9.375 = 750,00 = valor fixo dos royalties
|
|
|
+ $result = $this->preview(2026, 4, 9375.00);
|
|
|
+
|
|
|
+ $this->assertSame(750.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame('tbr_fixo', $result['royalties_applied_criteria']);
|
|
|
+ $this->assertSame(0.75, $result['royalties_effective_percentage']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Royalties e FNM são comparados de forma independente.
|
|
|
+ *
|
|
|
+ * Cenário: Faturamento R$ 9.500,00: 8% = R$ 760 (passa do fixo 750), mas 2% = R$ 190 (abaixo do fixo 200).
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 760,00 por 'percentual_faturamento'
|
|
|
+ * - FNM = R$ 200,00 pelo fixo (percentual 0,20)
|
|
|
+ */
|
|
|
+ public function test_royalties_e_fnm_podem_usar_criterios_diferentes_no_mesmo_mes(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ // Royalties: 8% x 11.000 = 880 > 750 (faturamento)
|
|
|
+ // FNM: 2% x 11.000 = 220 > 200 (faturamento) — escolhe faturamento nos dois
|
|
|
+ // Já com 9.500: royalties 760 > 750 (faturamento) e FNM 190 < 200 (fixo)
|
|
|
+ $result = $this->preview(2026, 4, 9500.00);
|
|
|
+
|
|
|
+ $this->assertSame(760.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame('percentual_faturamento', $result['royalties_applied_criteria']);
|
|
|
+ $this->assertSame(200.0, $result['fnm_effective_value']);
|
|
|
+ $this->assertSame(0.20, $result['fnm_effective_percentage']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A Taxa de Manutenção é sempre um percentual fixo da TBR.
|
|
|
+ *
|
|
|
+ * Cenário: Mesmo contrato com faturamento de R$ 100,00 e depois de R$ 900.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Manutenção = R$ 300,00 nos dois casos
|
|
|
+ * - percentual = 0,30
|
|
|
+ */
|
|
|
+ public function test_manutencao_nunca_e_comparada_com_o_faturamento(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $baixo = $this->preview(2026, 4, 100.00);
|
|
|
+ $alto = $this->preview(2026, 12, 900000.00);
|
|
|
+
|
|
|
+ $this->assertSame(300.0, $baixo['maintenance_effective_value']);
|
|
|
+ $this->assertSame(300.0, $alto['maintenance_effective_value']);
|
|
|
+ $this->assertSame(0.30, $alto['maintenance_effective_percentage']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------ faixas / porte da cidade
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A faixa de royalties do porte muda conforme o mês de contrato avanca.
|
|
|
+ *
|
|
|
+ * Cenário: Porte GP: 75% nos meses 4-12 e 100% nos meses 13-60.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - mês 12 -> 75% da TBR = R$ 750,00
|
|
|
+ * - mês 13 -> 100% da TBR = R$ 1.000,00
|
|
|
+ */
|
|
|
+ public function test_faixa_de_royalties_muda_no_mes_13(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeTbrYear(2027, 1000.00);
|
|
|
+
|
|
|
+ $mes12 = $this->preview(2026, 12, 1000);
|
|
|
+ $mes13 = $this->preview(2027, 1, 1000);
|
|
|
+
|
|
|
+ $this->assertSame(0.75, $mes12['royalties_bracket_percentage']);
|
|
|
+ $this->assertSame(750.0, $mes12['royalties_bracket_value']);
|
|
|
+
|
|
|
+ $this->assertSame(1.0, $mes13['royalties_bracket_percentage']);
|
|
|
+ $this->assertSame(1000.0, $mes13['royalties_bracket_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O porte do município do contrato define o percentual de royalties.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato em porte MGP (100% no mês 4) no lugar de GP (75%).
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - percentual da faixa = 1,00
|
|
|
+ * - Royalties = R$ 1.000,00 em vez de R$ 750,00
|
|
|
+ */
|
|
|
+ public function test_porte_maior_do_municipio_cobra_percentual_maior(): void
|
|
|
+ {
|
|
|
+ $sizeMgp = $this->makeSize('MGP', 'Acima de 200 mil habitantes');
|
|
|
+ $this->makeBrackets($sizeMgp->id, [[1, 3, 0.0], [4, 12, 1.00], [13, 60, 1.50]]);
|
|
|
+
|
|
|
+ $this->makeContract(['municipality_size_id' => $sizeMgp->id]);
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 1000);
|
|
|
+
|
|
|
+ $this->assertSame(1.0, $result['royalties_bracket_percentage']);
|
|
|
+ $this->assertSame(1000.0, $result['royalties_effective_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A busca de faixa é fixada em is_renewal = false: faixas de renovacao nunca entram.
|
|
|
+ *
|
|
|
+ * Cenário: Unica faixa que cobre o mês 61 e de renovacao.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException 'Nao foi encontrada faixa de royalties'
|
|
|
+ *
|
|
|
+ * Nota: Comportamento atual documentado como divergência (item 5 do doc). Se as faixas de renovacao
|
|
|
+ * passarem a valer, este teste deve ser reescrito.
|
|
|
+ */
|
|
|
+ public function test_faixa_de_renovacao_nao_e_considerada(): void
|
|
|
+ {
|
|
|
+ // Só existe faixa de renovação cobrindo o mês 61 — o cálculo ignora e estoura.
|
|
|
+ $this->makeBrackets($this->sizeGp->id, [[61, 120, 1.00]], true);
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeTbrYear(2031, 1000.00);
|
|
|
+
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('Não foi encontrada faixa de royalties');
|
|
|
+
|
|
|
+ $this->preview(2031, 1, 1000); // mês de contrato 61
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Contrato que passa do último mês com faixa cadastrada não calcula.
|
|
|
+ *
|
|
|
+ * Cenário: Faixas cadastradas até o mês 60; competência cai no mês 61.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException 'Nao foi encontrada faixa de royalties'
|
|
|
+ */
|
|
|
+ public function test_erro_quando_nao_existe_faixa_para_o_mes_de_contrato(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeTbrYear(2031, 1000.00);
|
|
|
+
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('Não foi encontrada faixa de royalties');
|
|
|
+
|
|
|
+ $this->preview(2031, 1, 1000); // mês 61, fora das faixas 1-60
|
|
|
+ }
|
|
|
+
|
|
|
+ // --------------------------------------------------------------- valor da TBR
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A TBR do ano cadastrada pelo admin (tabela tbrs) manda sobre o valor do contrato.
|
|
|
+ *
|
|
|
+ * Cenário: tbrs/2026 = R$ 1.000,00 (setUp) e contrato com tbr_fixed_value = R$ 2.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - TBR usada = R$ 1.000,00
|
|
|
+ * - Royalties = R$ 750,00 (75% x 1.000)
|
|
|
+ * - Manutenção = R$ 300,00
|
|
|
+ */
|
|
|
+ public function test_tbr_do_ano_configurada_pelo_admin_tem_prioridade_sobre_o_contrato(): void
|
|
|
+ {
|
|
|
+ // tbrs/2026 = 1.000,00 (setUp) e o contrato traz 2.000,00: vale a tabela do ano.
|
|
|
+ $this->makeContract(['tbr_fixed_value' => 2000.00]);
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 1000);
|
|
|
+
|
|
|
+ $this->assertSame(1000.0, $result['tbr_value']);
|
|
|
+ $this->assertSame(750.0, $result['royalties_effective_value']); // 75% x 1000
|
|
|
+ $this->assertSame(300.0, $result['maintenance_effective_value']); // 30% x 1000
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A busca da TBR é feita pelo ano da competência, não pelo ano corrente.
|
|
|
+ *
|
|
|
+ * Cenário: tbrs/2026 = R$ 1.000,00 e tbrs/2027 = R$ 1.800,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - competência de 2026 usa R$ 1.000,00
|
|
|
+ * - competência de 2027 usa R$ 1.800,00
|
|
|
+ */
|
|
|
+ public function test_cada_ano_usa_a_sua_propria_configuracao_de_tbr(): void
|
|
|
+ {
|
|
|
+ $this->makeTbrYear(2027, 1800.00);
|
|
|
+ $this->makeContract(['tbr_fixed_value' => 2000.00]);
|
|
|
+
|
|
|
+ $this->assertSame(1000.0, $this->preview(2026, 4, 1000)['tbr_value']);
|
|
|
+ $this->assertSame(1800.0, $this->preview(2027, 1, 1000)['tbr_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O tbr_fixed_value do contrato é a reserva para anos sem cadastro em tbrs.
|
|
|
+ *
|
|
|
+ * Cenário: 2027 sem linha em tbrs; contrato com R$ 2.000,00; competência 01/2027 (mês 13, faixa 100%).
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - TBR usada = R$ 2.000,00
|
|
|
+ * - Royalties = R$ 2.000,00
|
|
|
+ * - Manutenção = R$ 600,00
|
|
|
+ */
|
|
|
+ public function test_usa_a_tbr_do_contrato_quando_o_ano_nao_esta_configurado(): void
|
|
|
+ {
|
|
|
+ // 2027 não tem linha em `tbrs`; cai no valor gravado no contrato.
|
|
|
+ $this->makeContract(['tbr_fixed_value' => 2000.00]);
|
|
|
+
|
|
|
+ $result = $this->preview(2027, 1, 1000); // mês 13
|
|
|
+
|
|
|
+ $this->assertSame(2000.0, $result['tbr_value']);
|
|
|
+ $this->assertSame(2000.0, $result['royalties_effective_value']); // 100% x 2000
|
|
|
+ $this->assertSame(600.0, $result['maintenance_effective_value']); // 30% x 2000
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Linha do ano com tbr_value = 0 conta como 'não configurada'.
|
|
|
+ *
|
|
|
+ * Cenário: tbrs/2027 = R$ 0,00 e contrato com R$ 2.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - TBR usada = R$ 2.000,00
|
|
|
+ */
|
|
|
+ public function test_tbr_do_ano_zerada_cai_no_valor_do_contrato(): void
|
|
|
+ {
|
|
|
+ $this->makeTbrYear(2027, 0.00);
|
|
|
+ $this->makeContract(['tbr_fixed_value' => 2000.00]);
|
|
|
+
|
|
|
+ $this->assertSame(2000.0, $this->preview(2027, 1, 1000)['tbr_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Sem TBR em lugar nenhum o cálculo não pode seguir.
|
|
|
+ *
|
|
|
+ * Cenário: Competência 2029 (sem linha em tbrs) e contrato sem tbr_fixed_value.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException 'TBR não definida'
|
|
|
+ */
|
|
|
+ public function test_erro_quando_nao_ha_tbr_no_ano_nem_no_contrato(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['tbr_fixed_value' => null]);
|
|
|
+
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('TBR não definida');
|
|
|
+
|
|
|
+ $this->preview(2029, 4, 1000); // sem linha em tbrs para 2029
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Os percentuais de FNM e Manutenção saem da linha do ano em tbrs.
|
|
|
+ *
|
|
|
+ * Cenário: tbrs/2027 com FNM 10% e Manutenção 50%, TBR R$ 1.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - FNM fixo = 10% = R$ 100,00
|
|
|
+ * - Manutenção = 50% = R$ 500,00
|
|
|
+ */
|
|
|
+ public function test_percentuais_de_fnm_e_manutencao_vem_da_tabela_do_ano(): void
|
|
|
+ {
|
|
|
+ $this->makeTbrYear(2027, 1000.00, fnm: 0.10, maintenance: 0.50);
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2027, 1, 1000); // mês 13
|
|
|
+
|
|
|
+ $this->assertSame(0.10, $result['fnm_bracket_percentage']);
|
|
|
+ $this->assertSame(100.0, $result['fnm_bracket_value']);
|
|
|
+ $this->assertSame(0.50, $result['maintenance_effective_percentage']);
|
|
|
+ $this->assertSame(500.0, $result['maintenance_effective_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Sem linha do ano, os percentuais caem nas constantes do serviço.
|
|
|
+ *
|
|
|
+ * Cenário: 2027 sem cadastro em tbrs; a TBR vem do contrato.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - FNM fixo = 20% (FNM_BRACKET_PERCENTAGE)
|
|
|
+ * - Manutenção = 30% (MAINTENANCE_RATE)
|
|
|
+ */
|
|
|
+ public function test_sem_tabela_do_ano_usa_os_padroes_do_codigo(): void
|
|
|
+ {
|
|
|
+ // 2027 não tem linha em `tbrs`; a TBR vem do contrato e os percentuais dos defaults.
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2027, 1, 1000); // mês 13
|
|
|
+
|
|
|
+ $this->assertSame(0.20, $result['fnm_bracket_percentage']);
|
|
|
+ $this->assertSame(0.30, $result['maintenance_effective_percentage']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------- flags de cobrança
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Flag charge_roi = false na unidade tira os Royalties da conta.
|
|
|
+ *
|
|
|
+ * Cenário: unit_financials com charge_roi = false, mês 4, faturamento R$ 40.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 0,00 e percentual = 0
|
|
|
+ * - critério = 'nao_cobrado'
|
|
|
+ * - royalties_bracket_id = null (a faixa nem é buscada)
|
|
|
+ * - Total = R$ 1.100,00 (FNM 800 + Manutenção 300)
|
|
|
+ */
|
|
|
+ public function test_charge_roi_desligado_zera_os_royalties(): void
|
|
|
+ {
|
|
|
+ UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]);
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 40000);
|
|
|
+
|
|
|
+ $this->assertSame(0.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame(0.0, $result['royalties_effective_percentage']);
|
|
|
+ $this->assertSame('nao_cobrado', $result['royalties_applied_criteria']);
|
|
|
+ $this->assertNull($result['royalties_bracket_id']);
|
|
|
+ $this->assertSame(1100.0, $result['final_value']); // FNM 800 + manutenção 300
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Com os Royalties desligados, a falta de faixa não derruba o cálculo.
|
|
|
+ *
|
|
|
+ * Cenário: charge_roi = false e competência no mês 61 (sem faixa cadastrada).
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - calcula sem exceção
|
|
|
+ * - Royalties = R$ 0,00
|
|
|
+ * - Total = R$ 500,00 (FNM 200 + Manutenção 300)
|
|
|
+ */
|
|
|
+ public function test_charge_roi_desligado_dispensa_a_existencia_de_faixa(): void
|
|
|
+ {
|
|
|
+ UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => true]);
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeTbrYear(2031, 1000.00);
|
|
|
+
|
|
|
+ $result = $this->preview(2031, 1, 1000); // mês 61, sem faixa cadastrada
|
|
|
+
|
|
|
+ $this->assertSame(0.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame(500.0, $result['final_value']); // FNM 200 + manutenção 300
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Flag charge_fnm = false na unidade tira o FNM da conta.
|
|
|
+ *
|
|
|
+ * Cenário: unit_financials com charge_fnm = false, mês 4, faturamento baixo.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - FNM = R$ 0,00 e percentual = 0
|
|
|
+ * - Total = R$ 1.050,00 (Royalties 750 + Manutenção 300)
|
|
|
+ */
|
|
|
+ public function test_charge_fnm_desligado_zera_o_fnm(): void
|
|
|
+ {
|
|
|
+ UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => true, 'charge_fnm' => false]);
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 1000);
|
|
|
+
|
|
|
+ $this->assertSame(0.0, $result['fnm_effective_value']);
|
|
|
+ $this->assertSame(0.0, $result['fnm_effective_percentage']);
|
|
|
+ $this->assertSame(1050.0, $result['final_value']); // royalties 750 + manutenção 300
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Unidade sem linha em unit_financials é tratada como 'cobra tudo'.
|
|
|
+ *
|
|
|
+ * Cenário: Nenhum registro financeiro criado para a unidade.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Royalties = R$ 750,00
|
|
|
+ * - FNM = R$ 200,00
|
|
|
+ *
|
|
|
+ * Nota: Padrão seguro: a ausencia de configuração não deve isentar ninguem.
|
|
|
+ */
|
|
|
+ public function test_unidade_sem_registro_financeiro_cobra_royalties_e_fnm(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $this->assertSame(0, UnitFinancial::where('unit_id', $this->unit->id)->count());
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 1000);
|
|
|
+
|
|
|
+ $this->assertSame(750.0, $result['royalties_effective_value']);
|
|
|
+ $this->assertSame(200.0, $result['fnm_effective_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: A Taxa de Manutenção não tem flag de liga/desliga.
|
|
|
+ *
|
|
|
+ * Cenário: charge_roi e charge_fnm ambos false, faturamento alto.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - Total = R$ 300,00, só a Manutenção
|
|
|
+ */
|
|
|
+ public function test_manutencao_e_cobrada_mesmo_com_as_duas_flags_desligadas(): void
|
|
|
+ {
|
|
|
+ UnitFinancial::create(['unit_id' => $this->unit->id, 'charge_roi' => false, 'charge_fnm' => false]);
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 4, 40000);
|
|
|
+
|
|
|
+ $this->assertSame(300.0, $result['final_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------- faturamento
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Composição da base de faturamento do mês — três fontes somadas.
|
|
|
+ *
|
|
|
+ * Cenário: Parcelas de aluno R$ 100 + R$ 250, recebível da unidade R$ 50 e conta 'manual_unit' da matriz R$ 25.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - faturamento de 04/2026 = R$ 425,00
|
|
|
+ */
|
|
|
+ public function test_faturamento_soma_parcelas_recebiveis_e_contas_da_matriz(): void
|
|
|
+ {
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-01']);
|
|
|
+ $this->makeInstallment(['value' => 250.00, 'due_date' => '2026-04-30']);
|
|
|
+
|
|
|
+ UnitAccountReceivable::create([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'origin' => 'manual',
|
|
|
+ 'history' => 'Venda de material',
|
|
|
+ 'value' => 50.00,
|
|
|
+ 'due_date' => '2026-04-15',
|
|
|
+ 'status' => 'pending',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ FranchiseeAccountReceive::create([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'origin' => 'manual_unit',
|
|
|
+ 'history' => 'Cobrança avulsa da matriz',
|
|
|
+ 'value' => 25.00,
|
|
|
+ 'due_date' => '2026-04-20',
|
|
|
+ 'status' => 'pending',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->assertSame(425.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Filtro do faturamento — só entra o que vence no mês e não está cancelado.
|
|
|
+ *
|
|
|
+ * Cenário: R$ 100 válida, R$ 999 cancelada, R$ 777 em 31/03 e R$ 555 em 01/05.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - faturamento de 04/2026 = R$ 100,00
|
|
|
+ *
|
|
|
+ * Nota: Confirma também que o intervalo do mês e fechado nas duas pontas.
|
|
|
+ */
|
|
|
+ public function test_faturamento_ignora_parcelas_canceladas_e_de_outros_meses(): void
|
|
|
+ {
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
|
|
|
+ $this->makeInstallment(['value' => 999.00, 'due_date' => '2026-04-10', 'status' => 'cancelled']);
|
|
|
+ $this->makeInstallment(['value' => 777.00, 'due_date' => '2026-03-31']);
|
|
|
+ $this->makeInstallment(['value' => 555.00, 'due_date' => '2026-05-01']);
|
|
|
+
|
|
|
+ $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Parcela removida (soft delete) sai da base de faturamento.
|
|
|
+ *
|
|
|
+ * Cenário: R$ 100 ativa e R$ 400 excluida, ambas vencendo no mês.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - faturamento = R$ 100,00
|
|
|
+ */
|
|
|
+ public function test_faturamento_ignora_parcelas_excluidas(): void
|
|
|
+ {
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
|
|
|
+ $this->makeInstallment(['value' => 400.00, 'due_date' => '2026-04-10'])->delete();
|
|
|
+
|
|
|
+ $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O próprio título de TBR não pode entrar na base que gera o TBR.
|
|
|
+ *
|
|
|
+ * Cenário: Parcela de R$ 100 e um franchisee_account_receive de R$ 5.000 com origin = 'tbr'.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - faturamento = R$ 100,00
|
|
|
+ *
|
|
|
+ * Nota: Evita realimentação: cobrar TBR aumentaria a base do mês seguinte.
|
|
|
+ */
|
|
|
+ public function test_faturamento_ignora_recebiveis_de_tbr(): void
|
|
|
+ {
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
|
|
|
+
|
|
|
+ FranchiseeAccountReceive::create([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'origin' => 'tbr',
|
|
|
+ 'history' => 'Royalties / FNM / Manutenção — 04/2026',
|
|
|
+ 'value' => 5000.00,
|
|
|
+ 'due_date' => '2026-04-10',
|
|
|
+ 'status' => 'pending',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O faturamento é apurado por unidade.
|
|
|
+ *
|
|
|
+ * Cenário: R$ 100 na unidade do teste e R$ 900 em outra unidade, mesma data.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - faturamento da unidade = R$ 100,00
|
|
|
+ */
|
|
|
+ public function test_faturamento_ignora_movimento_de_outra_unidade(): void
|
|
|
+ {
|
|
|
+ $outra = $this->makeUnit('Outra Unidade');
|
|
|
+
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
|
|
|
+ $this->makeInstallment(['value' => 900.00, 'due_date' => '2026-04-10', 'unit_id' => $outra->id]);
|
|
|
+
|
|
|
+ $this->assertSame(100.0, $this->service->resolveRevenue($this->unit->id, 2026, 4));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: resolveRevenueBetween com lista de unidades vazia representa a rede inteira.
|
|
|
+ *
|
|
|
+ * Cenário: R$ 100 numa unidade e R$ 900 em outra, ambas em 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - total da rede = R$ 1.000,00
|
|
|
+ *
|
|
|
+ * Nota: Mesma composicao usada pelos dashboards.
|
|
|
+ */
|
|
|
+ public function test_faturamento_da_rede_soma_todas_as_unidades(): void
|
|
|
+ {
|
|
|
+ $outra = $this->makeUnit('Outra Unidade');
|
|
|
+
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
|
|
|
+ $this->makeInstallment(['value' => 900.00, 'due_date' => '2026-04-10', 'unit_id' => $outra->id]);
|
|
|
+
|
|
|
+ $total = $this->service->resolveRevenueBetween(
|
|
|
+ [],
|
|
|
+ \Carbon\Carbon::parse('2026-04-01'),
|
|
|
+ \Carbon\Carbon::parse('2026-04-30'),
|
|
|
+ );
|
|
|
+
|
|
|
+ $this->assertSame(1000.0, $total);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: revenue_value maior que zero sobrepoe a apuração automática.
|
|
|
+ *
|
|
|
+ * Cenário: Existe R$ 100 de parcela no mês; um preview manda revenue_value = R$ 40.000.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - com override: revenue_value = R$ 40.000,00
|
|
|
+ * - sem override: revenue_value = R$ 100,00
|
|
|
+ *
|
|
|
+ * Nota: Zero (ou ausente) significa 'calcula automático', não 'faturamento zero'.
|
|
|
+ */
|
|
|
+ public function test_faturamento_informado_manualmente_substitui_o_calculo_automatico(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeInstallment(['value' => 100.00, 'due_date' => '2026-04-10']);
|
|
|
+
|
|
|
+ $comOverride = $this->preview(2026, 4, 40000.00);
|
|
|
+ $automatico = $this->preview(2026, 4, 0.0);
|
|
|
+
|
|
|
+ $this->assertSame(40000.0, $comOverride['revenue_value']);
|
|
|
+ $this->assertSame(100.0, $automatico['revenue_value']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------------------------------------------------------- contrato obrigatório
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Sem contrato não ha regra de cobrança.
|
|
|
+ *
|
|
|
+ * Cenário: Unidade criada sem nenhum franchisee_contract.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException 'Unidade não possui contrato cadastrado.'
|
|
|
+ */
|
|
|
+ public function test_erro_quando_a_unidade_nao_tem_contrato(): void
|
|
|
+ {
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('Unidade não possui contrato cadastrado.');
|
|
|
+
|
|
|
+ $this->preview(2026, 4, 1000);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O porte do município é obrigatório para achar a faixa de royalties.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato com municipality_size_id nulo.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException citando a faixa de habitantes
|
|
|
+ */
|
|
|
+ public function test_erro_quando_o_contrato_nao_tem_porte_do_municipio(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['municipality_size_id' => null]);
|
|
|
+
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('faixa de habitantes');
|
|
|
+
|
|
|
+ $this->preview(2026, 4, 1000);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Com vários contratos na unidade, vale o de start_date mais recente.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato de 01/01/2026 e outro de 01/03/2026; competência 06/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - contract_id = o contrato de março
|
|
|
+ * - mês de contrato = 4, contado a partir de 03/2026
|
|
|
+ */
|
|
|
+ public function test_usa_sempre_o_contrato_mais_recente_da_unidade(): void
|
|
|
+ {
|
|
|
+ $antigo = $this->makeContract(['start_date' => '2026-01-01']);
|
|
|
+ $novo = $this->makeContract(['protocol' => 2, 'start_date' => '2026-03-01']);
|
|
|
+
|
|
|
+ $result = $this->preview(2026, 6, 1000);
|
|
|
+
|
|
|
+ $this->assertSame($novo->id, $result['contract_id']);
|
|
|
+ $this->assertNotSame($antigo->id, $result['contract_id']);
|
|
|
+ $this->assertSame(4, $result['contract_month_reference']); // conta a partir de 03/2026
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------ persistência
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: calculate() persiste em tbr_calculations exatamente o que o preview mostrou.
|
|
|
+ *
|
|
|
+ * Cenário: Mês 4, faturamento R$ 40.000,00.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - linha gravada com a competência e o mês de contrato 4
|
|
|
+ * - Royalties R$ 3.200,00 / FNM R$ 800,00 / Manutenção R$ 300,00 / total R$ 4.300,00
|
|
|
+ * - critério 'percentual_faturamento'
|
|
|
+ * - receivable_generated = false
|
|
|
+ */
|
|
|
+ public function test_calculate_grava_o_calculo_com_os_valores_do_preview(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'reference_year' => 2026,
|
|
|
+ 'reference_month' => 4,
|
|
|
+ 'revenue_value' => 40000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->assertDatabaseHas('tbr_calculations', [
|
|
|
+ 'id' => $calculation->id,
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'reference_year' => 2026,
|
|
|
+ 'reference_month' => 4,
|
|
|
+ 'contract_month_reference' => 4,
|
|
|
+ 'receivable_generated' => false,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->assertSame('3200.00', (string) $calculation->royalties_effective_value);
|
|
|
+ $this->assertSame('800.00', (string) $calculation->fnm_effective_value);
|
|
|
+ $this->assertSame('300.00', (string) $calculation->maintenance_effective_value);
|
|
|
+ $this->assertSame('4300.00', (string) $calculation->final_value);
|
|
|
+ $this->assertSame('percentual_faturamento', $calculation->royalties_applied_criteria);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: generateReceivable() gera o título da matriz e o espelho na unidade.
|
|
|
+ *
|
|
|
+ * Cenário: Cálculo do mês 4 com faturamento baixo (total R$ 1.250,00).
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - franchisee_account_receives de R$ 1.250,00, status pending, order = mês 4
|
|
|
+ * - histórico com a competência 04/2026
|
|
|
+ * - 3 detalhes (Royalties, FNM, Manutenção) somando o total
|
|
|
+ * - cálculo marcado como receivable_generated
|
|
|
+ * - unit_account_payables espelho com origin 'tbr'
|
|
|
+ * - SyncFranchiseeChargeJob despachado
|
|
|
+ */
|
|
|
+ public function test_gerar_titulo_cria_recebivel_detalhes_e_conta_a_pagar(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'reference_year' => 2026,
|
|
|
+ 'reference_month' => 4,
|
|
|
+ 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $receive = $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $this->assertSame('1250.00', (string) $receive->value);
|
|
|
+ $this->assertSame('pending', $receive->status->value);
|
|
|
+ $this->assertSame(4, $receive->order);
|
|
|
+ $this->assertStringContainsString('04/2026', $receive->history);
|
|
|
+
|
|
|
+ $this->assertCount(3, $receive->details);
|
|
|
+ $this->assertEqualsWithDelta(
|
|
|
+ 1250.00,
|
|
|
+ $receive->details->sum(fn ($detail) => (float) $detail->value),
|
|
|
+ 0.01,
|
|
|
+ );
|
|
|
+
|
|
|
+ $this->assertTrue($calculation->fresh()->receivable_generated);
|
|
|
+
|
|
|
+ $this->assertDatabaseHas('unit_account_payables', [
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'franchisee_account_receive_id' => $receive->id,
|
|
|
+ 'origin' => UnitAccountPayable::ORIGIN_TBR,
|
|
|
+ 'value' => '1250.00',
|
|
|
+ 'status' => 'pending',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ Bus::assertDispatched(\App\Jobs\SyncFranchiseeChargeJob::class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Um cálculo só pode virar título uma vez.
|
|
|
+ *
|
|
|
+ * Cenário: generateReceivable() chamado duas vezes com o mesmo id.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException 'Já existe um título gerado para este cálculo.'
|
|
|
+ */
|
|
|
+ public function test_gerar_titulo_duas_vezes_para_o_mesmo_calculo_e_bloqueado(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id,
|
|
|
+ 'reference_year' => 2026,
|
|
|
+ 'reference_month' => 4,
|
|
|
+ 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('Já existe um título gerado para este cálculo.');
|
|
|
+
|
|
|
+ $this->service->generateReceivable($calculation->id);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Uma unidade só pode ter um título por mês de contrato.
|
|
|
+ *
|
|
|
+ * Cenário: Dois calculos distintos para a mesma competência; o primeiro já virou título.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - ValidationException citando o mês de contrato 4
|
|
|
+ *
|
|
|
+ * Nota: A checagem hoje olha só o mês de contrato, sem o ano/mês de competência (item 4 do doc).
|
|
|
+ */
|
|
|
+ public function test_gerar_titulo_para_o_mesmo_mes_de_contrato_e_bloqueado(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $primeiro = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+ $this->service->generateReceivable($primeiro->id);
|
|
|
+
|
|
|
+ $segundo = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->expectException(ValidationException::class);
|
|
|
+ $this->expectExceptionMessage('Já existe um título gerado para esta unidade no mês de contrato 4.');
|
|
|
+
|
|
|
+ $this->service->generateReceivable($segundo->id);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O preview sinaliza a duplicidade antes de tentar gerar.
|
|
|
+ *
|
|
|
+ * Cenário: Preview antes e depois de gerar o título do mês 4.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - antes: receivable_already_generated = false
|
|
|
+ * - depois: true
|
|
|
+ */
|
|
|
+ public function test_preview_avisa_quando_ja_existe_titulo_no_mes_de_contrato(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $this->assertFalse($this->preview(2026, 4, 1000)['receivable_already_generated']);
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+ $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $this->assertTrue($this->preview(2026, 4, 1000)['receivable_already_generated']);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------- vencimento
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Vencimento = mês da competência + 1, no dia invoice_due_date do contrato.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato com invoice_due_date = 15, competência 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - vencimento = 15/05/2026
|
|
|
+ */
|
|
|
+ public function test_vencimento_usa_o_dia_do_contrato_no_mes_seguinte(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['invoice_due_date' => 15]);
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $receive = $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $this->assertSame('2026-05-15', $receive->due_date->toDateString());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Sem invoice_due_date, o vencimento usa o dia 10.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato com invoice_due_date nulo, competência 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - vencimento = 10/05/2026
|
|
|
+ */
|
|
|
+ public function test_vencimento_cai_no_dia_10_quando_o_contrato_nao_informa(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['invoice_due_date' => null]);
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $receive = $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $this->assertSame('2026-05-10', $receive->due_date->toDateString());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O dia de vencimento é travado entre 1 e 28 (evita mês sem dia 29-31).
|
|
|
+ *
|
|
|
+ * Cenário: Contrato com invoice_due_date = 31, competência 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - vencimento = 28/05/2026
|
|
|
+ */
|
|
|
+ public function test_vencimento_e_limitado_ao_dia_28(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['invoice_due_date' => 31]);
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $receive = $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $this->assertSame('2026-05-28', $receive->due_date->toDateString());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------- em lote
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: generateBatch() percorre os contratos ativos na competência e gera os títulos.
|
|
|
+ *
|
|
|
+ * Cenário: Uma unidade com contrato ativo e R$ 1.000 de faturamento apurado no mês.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - generated_count = 1 e nenhum erro
|
|
|
+ * - total gerado = R$ 1.250,00
|
|
|
+ * - 1 recebível criado
|
|
|
+ */
|
|
|
+ public function test_lote_gera_titulo_para_cada_contrato_ativo(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeInstallment(['value' => 1000.00, 'due_date' => '2026-04-10']);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2026, 4);
|
|
|
+
|
|
|
+ $this->assertSame(1, $resultado['generated_count']);
|
|
|
+ $this->assertSame(0, $resultado['error_count']);
|
|
|
+ $this->assertSame(1250.0, $resultado['generated'][0]['total']);
|
|
|
+ $this->assertSame(1, FranchiseeAccountReceive::count());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: O lote não duplica título: unidade já cobrada entra em 'skipped'.
|
|
|
+ *
|
|
|
+ * Cenário: Titulo do mês 4 gerado antes de rodar o lote da mesma competência.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - generated_count = 0 e skipped_count = 1
|
|
|
+ * - continua com apenas 1 recebível
|
|
|
+ */
|
|
|
+ public function test_lote_pula_unidade_que_ja_tem_titulo_no_mes_de_contrato(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $calculation = $this->service->calculate([
|
|
|
+ 'unit_id' => $this->unit->id, 'reference_year' => 2026, 'reference_month' => 4, 'revenue_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+ $this->service->generateReceivable($calculation->id);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2026, 4);
|
|
|
+
|
|
|
+ $this->assertSame(0, $resultado['generated_count']);
|
|
|
+ $this->assertSame(1, $resultado['skipped_count']);
|
|
|
+ $this->assertSame(1, FranchiseeAccountReceive::count());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Contrato com end_date anterior ao mês não e cobrado.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato encerrado em 28/02/2026; lote de 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - nada gerado e nenhum cálculo gravado
|
|
|
+ */
|
|
|
+ public function test_lote_ignora_contrato_encerrado_antes_da_competencia(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['end_date' => '2026-02-28']);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2026, 4);
|
|
|
+
|
|
|
+ $this->assertSame(0, $resultado['generated_count']);
|
|
|
+ $this->assertSame(0, TbrCalculation::count());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Contrato com start_date posterior a competência não e cobrado.
|
|
|
+ *
|
|
|
+ * Cenário: Contrato comeca em 01/06/2026; lote de 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - nada gerado
|
|
|
+ */
|
|
|
+ public function test_lote_ignora_contrato_que_ainda_nao_comecou(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['start_date' => '2026-06-01']);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2026, 4);
|
|
|
+
|
|
|
+ $this->assertSame(0, $resultado['generated_count']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Contrato sem porte fica fora da selecao do lote (nem gera erro).
|
|
|
+ *
|
|
|
+ * Cenário: Contrato com municipality_size_id nulo; lote de 04/2026.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - generated_count = 0 e error_count = 0
|
|
|
+ *
|
|
|
+ * Nota: Diferente do cálculo individual, que devolve ValidationException nesse caso.
|
|
|
+ */
|
|
|
+ public function test_lote_ignora_contrato_sem_porte_definido(): void
|
|
|
+ {
|
|
|
+ $this->makeContract(['municipality_size_id' => null]);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2026, 4);
|
|
|
+
|
|
|
+ $this->assertSame(0, $resultado['generated_count']);
|
|
|
+ $this->assertSame(0, $resultado['error_count']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: generateBatch() aceita uma lista de unidades e ignora as demais.
|
|
|
+ *
|
|
|
+ * Cenário: duas unidades com contrato ativo; o lote roda filtrando só a segunda.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - generated_count = 1
|
|
|
+ * - o título gerado pertence a unidade filtrada
|
|
|
+ */
|
|
|
+ public function test_lote_pode_ser_filtrado_por_unidade(): void
|
|
|
+ {
|
|
|
+ $this->makeContract();
|
|
|
+
|
|
|
+ $outra = $this->makeUnit('Outra Unidade');
|
|
|
+ FranchiseeContract::create([
|
|
|
+ 'unit_id' => $outra->id,
|
|
|
+ 'protocol' => 1,
|
|
|
+ 'name' => 'Contrato Outra',
|
|
|
+ 'description' => 'Teste',
|
|
|
+ 'start_date' => '2026-01-01',
|
|
|
+ 'end_date' => '2031-01-01',
|
|
|
+ 'signature_date' => '2026-01-01',
|
|
|
+ 'validity_months' => 60,
|
|
|
+ 'invoice_due_date' => 10,
|
|
|
+ 'municipality_size_id' => $this->sizeGp->id,
|
|
|
+ 'tbr_fixed_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2026, 4, [$outra->id]);
|
|
|
+
|
|
|
+ $this->assertSame(1, $resultado['generated_count']);
|
|
|
+ $this->assertSame($outra->id, $resultado['generated'][0]['unit_id']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Regra: Falha numa unidade não derruba o lote inteiro.
|
|
|
+ *
|
|
|
+ * Cenário: Uma unidade cai no mês 61 sem faixa; outra tem faixa válida até o mês 120.
|
|
|
+ *
|
|
|
+ * Espera:
|
|
|
+ * - generated_count = 1 e error_count = 1
|
|
|
+ * - o erro aponta a unidade problemática
|
|
|
+ */
|
|
|
+ public function test_lote_reporta_erro_sem_interromper_as_demais_unidades(): void
|
|
|
+ {
|
|
|
+ // Unidade base: mês 61 sem faixa cadastrada → erro
|
|
|
+ $this->makeContract();
|
|
|
+ $this->makeTbrYear(2031, 1000.00);
|
|
|
+
|
|
|
+ $outra = $this->makeUnit('Unidade OK');
|
|
|
+ $sizeOk = $this->makeSize('PP', 'Até 50 mil habitantes');
|
|
|
+ $this->makeBrackets($sizeOk->id, [[1, 120, 0.40]]);
|
|
|
+ FranchiseeContract::create([
|
|
|
+ 'unit_id' => $outra->id,
|
|
|
+ 'protocol' => 1,
|
|
|
+ 'name' => 'Contrato OK',
|
|
|
+ 'description' => 'Teste',
|
|
|
+ 'start_date' => '2026-01-01',
|
|
|
+ 'signature_date' => '2026-01-01',
|
|
|
+ 'validity_months' => 120,
|
|
|
+ 'invoice_due_date' => 10,
|
|
|
+ 'municipality_size_id' => $sizeOk->id,
|
|
|
+ 'tbr_fixed_value' => 1000.00,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $resultado = $this->service->generateBatch(2031, 1);
|
|
|
+
|
|
|
+ $this->assertSame(1, $resultado['generated_count']);
|
|
|
+ $this->assertSame(1, $resultado['error_count']);
|
|
|
+ $this->assertSame($this->unit->id, $resultado['errors'][0]['unit_id']);
|
|
|
+ }
|
|
|
+}
|