f=CatalogueAccountingFixture::create(); $this->login(); $client=$this->a('clients',['name'=>'Cliente sintético'])->json('id'); $this->service=$this->a('services',['name'=>'Serviço pontual sintético'])->json('id'); $this->draft=$this->a('service_drafts',['client_id'=>$client,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'100']]])->json('id'); } private function login(string $role='manager'): void { $this->postJson('/api/login',['email'=>$this->f[$role]->email,'password'=>$this->f['password']])->assertOk(); $this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token'); } private function a(string $area,array $data) {return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/a/'.$area,$data)->assertCreated();} private function command(string $path,array $data=[],?string $key=null) {return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/catalogue-accounting'.$path,$data);} private function propose(string $kind,array $data,string $from='2026-01-01',array $extra=[]): int { return $this->command('/proposals',['kind'=>$kind,'data'=>$data,'valid_from'=>$from,'reason'=>'Fundamento sintético']+$extra)->assertOk()->json('id'); } private function approve(int $id): void { $this->command('/proposals/'.$id.'/submit')->assertOk();$this->login('reader');$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Validação sintética das condições'])->assertOk();$this->login(); } private function graph(): array { $plan=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]);$this->approve($plan); $account=$this->propose('account',['plan_id'=>$plan,'code'=>'7.2.900','name'=>'Conta empresarial exclusivamente sintética','origin'=>'enterprise','movable'=>true,'evidence'=>'Desdobramento de teste, não conta oficial inventada.']);$this->approve($account); $category=$this->propose('category',['name'=>'Serviços normais sintéticos','account_id'=>$account,'conditions'=>'Natureza normal validada apenas para o caso sintético.']);$this->approve($category); $link=$this->propose('service_link',['service_id'=>$this->service,'category_id'=>$category]);$this->approve($link); return compact('plan','account','category','link'); } private function resolve(string $date='2026-10-26') {return $this->getJson('/api/catalogue-accounting/services/'.$this->service.'/resolve?reference_date='.$date);} public function test_workflow_separates_preparation_validation_and_immutable_versions(): void { $id=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]); $this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Sem direito'])->assertForbidden(); $this->command('/proposals/'.$id.'/submit')->assertOk(); $this->login('reader'); $this->command('/proposals',['kind'=>'plan','data'=>['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'valid_from'=>'2026-01-01','reason'=>'Não permitido'])->assertForbidden(); $this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Tentativa com edição','data'=>[]])->assertUnprocessable(); $key=(string)Str::uuid(); $decision=['decision'=>'refused','reason'=>'Evidência insuficiente']; $this->command('/proposals/'.$id.'/decision',$decision,$key)->assertOk()->assertJsonPath('replayed',false); $this->command('/proposals/'.$id.'/decision',$decision,$key)->assertOk()->assertJsonPath('replayed',true); $this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Conflito'])->assertStatus(409); $this->login(); $v2=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'2026-01-01',['previous_id'=>$id]); $this->assertDatabaseMissing('ca_decisions',['proposal_id'=>$v2]); try{DB::transaction(fn()=>DB::table('ca_proposals')->where('id',$id)->update(['reason'=>'Edição indevida']));$this->fail('Mutable proposal');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_',$e->getMessage());} } public function test_pending_refused_and_future_exceptions_do_not_replace_valid_category(): void { $g=$this->graph(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base'); $data=['service_id'=>$this->service,'account_id'=>$g['account'],'conditions'=>'Excepção de ensaio']; $draft=$this->propose('service_override',$data); $this->resolve()->assertOk()->assertJsonPath('origin','category_base'); $this->command('/proposals/'.$draft.'/submit')->assertOk(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base'); $this->login('reader');$this->command('/proposals/'.$draft.'/decision',['decision'=>'refused','reason'=>'Recusa sintética'])->assertOk();$this->login(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base'); $future=$this->propose('service_override',$data,'2026-11-01',['previous_id'=>$draft]);$this->approve($future); $this->resolve('2026-10-31')->assertOk()->assertJsonPath('origin','category_base'); $this->resolve('2026-11-01')->assertOk()->assertJsonPath('origin','explicit_service_exception'); } public function test_explicit_replacement_preserves_dates_history_and_rejects_overlap(): void { $g=$this->graph(); $capture=['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>$this->resolve()->json('revision')]; $id=$this->command('/resolutions',$capture)->assertOk()->json('id'); $snapshot=DB::table('ca_resolutions')->where('id',$id)->value('snapshot'); $data=['name'=>'Nova regra sintética','account_id'=>$g['account'],'conditions'=>'Condições revistas']; $overlap=$this->propose('category',$data,'2026-11-01',['previous_id'=>$g['category']]); $this->command('/proposals/'.$overlap.'/submit')->assertOk();$this->login('reader');$this->command('/proposals/'.$overlap.'/decision',['decision'=>'approved','reason'=>'Sobreposição indevida'])->assertStatus(409);$this->login(); $revision=$this->propose('category',$data,'2026-11-01',['previous_id'=>$overlap,'replaces_id'=>$g['category']]);$this->approve($revision); $this->resolve('2026-10-31')->assertOk()->assertJsonPath('references.category.id',$g['category']); $this->resolve('2026-11-01')->assertOk()->assertJsonPath('references.category.id',$revision); $this->assertSame($snapshot,DB::table('ca_resolutions')->where('id',$id)->value('snapshot')); try{DB::transaction(fn()=>DB::table('ca_resolutions')->update(['unit_id'=>$this->f['hidden']]));$this->fail('Mass transfer accepted');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_',$e->getMessage());} } public function test_category_switch_cannot_bypass_validation_or_catalogue_protection(): void { $g=$this->graph(); $other=$this->propose('category',['name'=>'Outra categoria','account_id'=>$g['account'],'conditions'=>'Caso sintético']);$this->approve($other); $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/services/'.$this->service,['version'=>1,'name'=>'Tentativa','category_id'=>$other])->assertUnprocessable(); $link=$this->propose('service_link',['service_id'=>$this->service,'category_id'=>$other],'2026-11-01',['previous_id'=>$g['link'],'replaces_id'=>$g['link']]); $this->resolve('2026-12-01')->assertOk()->assertJsonPath('references.category.id',$g['category']); $this->approve($link); $this->resolve('2026-10-31')->assertOk()->assertJsonPath('references.category.id',$g['category']);$this->resolve('2026-11-01')->assertOk()->assertJsonPath('references.category.id',$other); } public function test_cross_company_operations_units_revocation_and_missing_date_are_denied(): void { $this->graph(); $this->getJson('/api/catalogue-accounting/services/'.$this->service.'/resolve')->assertOk()->assertJsonPath('state','pending'); $foreign=DB::table('a_services')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'name'=>'Serviço de outra empresa','data'=>'{}','version'=>1,'created_at'=>now(),'updated_at'=>now()]); $this->getJson('/api/catalogue-accounting/services/'.$foreign.'/resolve?reference_date=2026-10-26')->assertForbidden(); $this->command('/proposals',['kind'=>'service_link','data'=>['service_id'=>$foreign,'category_id'=>1],'valid_from'=>'2026-01-01','reason'=>'Cruzamento'])->assertStatus(403); $this->login('reader'); $this->getJson('/api/catalogue-accounting')->assertOk(); DB::table('permission_grants')->where('user_id',$this->f['reader']->id)->where('permission','catalogue_accounting.view')->delete(); $this->getJson('/api/catalogue-accounting')->assertForbidden(); $this->login(); IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'unit.view',$this->f['hidden']); $new=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertOk()->json('context.token'); $this->token=$new; $this->command('/resolutions',['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>str_repeat('0',64)])->assertForbidden(); $this->getJson('/api/catalogue-accounting/history/service_drafts/'.$this->draft)->assertForbidden(); } public function test_stale_submission_identity_replay_and_no_fiscal_payloads(): void { $data=['kind'=>'plan','data'=>['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'valid_from'=>'2026-01-01','reason'=>'Proposta'];$key=(string)Str::uuid(); $id=$this->command('/proposals',$data,$key)->assertOk()->json('id');$this->command('/proposals',$data,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->command('/proposals',array_replace($data,['reason'=>'Diferente']),$key)->assertStatus(409); $next=$this->propose('plan',$data['data'],'2026-01-01',['previous_id'=>$id]);$this->command('/proposals/'.$id.'/submit')->assertStatus(409); $this->command('/proposals',['kind'=>'plan','data'=>$data['data']+['vat_rate'=>'16'],'valid_from'=>'2026-01-01','reason'=>'Taxa indevida','previous_id'=>$next])->assertUnprocessable(); $this->assertDatabaseCount('service_calculation_results',0); $this->assertDatabaseCount('b_internal_confirmations',0); } public function test_aggregate_missing_reference_and_incompatible_approved_override_are_not_silently_applied(): void { $g=$this->graph(); $aggregate=$this->propose('account',['plan_id'=>$g['plan'],'code'=>'7.2','name'=>'Prestação de serviços','origin'=>'reference','movable'=>false,'evidence'=>'Referência agregadora do exemplar, sem conta de movimento.']);$this->approve($aggregate); $cat=$this->propose('category',['name'=>'Incompleta','account_id'=>$aggregate,'conditions'=>'Exige conta de movimento']);$this->command('/proposals/'.$cat.'/submit')->assertUnprocessable(); $override=$this->propose('service_override',['service_id'=>$this->service,'account_id'=>$g['account'],'conditions'=>'Caso específico']);$this->approve($override); // An explicitly approved new account version expires the old reference. The override is now incompatible; // it must report the conflict rather than falling back silently to the category. $account=$this->propose('account',['plan_id'=>$g['plan'],'code'=>'7.2.900','name'=>'Conta sintética revista','origin'=>'enterprise','movable'=>true,'evidence'=>'Nova versão sintética'],'2026-11-01',['previous_id'=>$g['account'],'replaces_id'=>$g['account']]);$this->approve($account); $this->resolve('2026-11-01')->assertOk()->assertJsonPath('state','pending')->assertJsonPath('conflict',true); $this->command('/proposals',['kind'=>'category','data'=>['name'=>'Ausente','account_id'=>999999,'conditions'=>'Sem referência'],'valid_from'=>'2026-01-01','reason'=>'Ensaio'])->assertForbidden(); } public function test_b1_snapshot_is_untouched_and_unauthorised_unit_cannot_insert_resolution_directly(): void { $this->graph(); $billing=$this->a('billing_drafts',['source_id'=>$this->draft])->json('id'); $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$billing.'/confirm-internal',['version'=>1])->assertOk(); $before=DB::table('b_internal_confirmations')->get()->toJson(); $this->command('/resolutions',['area'=>'billing_drafts','record_id'=>$billing,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>str_repeat('0',64)])->assertStatus(409); $this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson()); try{DB::transaction(fn()=>DB::table('ca_resolutions')->insert(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden'],'area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','snapshot'=>'{}','actor_id'=>$this->f['manager']->id,'created_at'=>now()]));$this->fail('Cross-unit insert accepted');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_CA_OPERATION_SCOPE',$e->getMessage());} } public function test_capture_refuses_a_different_classification_than_the_one_consulted(): void { $g=$this->graph();$old=$this->resolve('2026-11-01')->assertOk()->json('revision'); $id=$this->propose('category',['name'=>'Regra revista','account_id'=>$g['account'],'conditions'=>'Condições revistas'],'2026-11-01',['previous_id'=>$g['category'],'replaces_id'=>$g['category']]);$this->approve($id); $capture=['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-11-01','resolution_revision'=>$old]; $this->command('/resolutions',$capture)->assertStatus(409);$this->assertDatabaseCount('ca_resolutions',0); $capture['resolution_revision']=$this->resolve('2026-11-01')->assertOk()->json('revision');$key=(string)Str::uuid(); $this->command('/resolutions',$capture,$key)->assertOk()->assertJsonPath('replayed',false);$this->command('/resolutions',$capture,$key)->assertOk()->assertJsonPath('replayed',true);$this->assertDatabaseCount('ca_resolutions',1); } }