Files
SGF/app/Modules/Services/ServiceFiscal.php
T
2026-11-11 09:00:17 +02:00

182 lines
17 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/** Fiscal decisions are separate from arithmetic, catalogue accounting and emission. */
final class ServiceFiscal
{
public function __construct(private CompanyContext $context, private Permissions $permissions, private ServiceWorkflow $workflow, private DocumentPreparation $preparation) {}
private function scope(int $actor, string $action = 'view'): array
{
$s=$this->context->identifiers(); $unit=$this->context->unitId();
abort_unless($actor===$this->context->userId() && $unit!==null,403,'Seleccione uma unidade autorizada.');
$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'unit.view',$unit);
foreach (array_unique(['view',$action]) as $a) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.'.$a,$a==='calculate' ? $unit : null);
return $s+['unit_id'=>$unit];
}
private function command(int $actor,string $action,callable $fn): array
{
$s=$this->scope($actor,$action);
return DB::transaction(function() use($actor,$action,$fn,$s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); $this->scope($actor,$action);
return $fn($s);
});
}
private function row(int $id,array $s): object
{
$r=DB::table('sf_proposals')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Proposta fora do âmbito.'); return $r;
}
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
private function decision(int $id): ?object { return DB::table('sf_decisions')->where('proposal_id',$id)->first(); }
private function approved(object $r): bool { return $this->decision($r->id)?->decision==='approved'; }
private function activated(object $r): bool { return (bool)(DB::table('sf_activation_events')->where('proposal_id',$r->id)->orderByDesc('id')->first()?->active ?? false); }
private function latest(object $r,array $s,?string $date=null): bool
{
// Pending/refused proposals do not supersede a currently approved version.
$q=DB::table('sf_proposals')->where($s)->where('kind',$r->kind)->where('identifier',$r->identifier)->where('version','>',$r->version)
->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'));
if($r->kind==='profile' && $date!==null) $q->whereRaw("data->>'valid_from' <= ?",[$date]);
return !$q->exists();
}
private function exact(array $data,array $rules): array
{
abort_if(array_diff(array_keys($data),array_keys($rules))!==[],422,'Campo protegido ou fora do âmbito fiscal.');
return Validator::make($data,$rules)->validate();
}
public function listing(int $actor): array
{
$s=$this->scope($actor);
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[],'coverage'=>FiscalCoverage::describe()];
$rows=DB::table('sf_proposals')->where($s)->orderByDesc('id')->get()->map(function($r) { return (array)$r+['definition'=>$this->data($r),'decision'=>$this->decision($r->id),'active'=>$r->kind==='profile' && $this->activated($r)]; })->all();
return ['coverage'=>FiscalCoverage::describe(),'proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
}
public function propose(int $actor,array $input): array
{
return $this->command($actor,'propose',function($s) use($actor,$input) {
$in=$this->exact($input,['kind'=>'required|in:profile,temporal','identifier'=>'required|string|max:100|regex:/^[A-Za-z0-9_-]+$/D','previous_id'=>'nullable|integer|min:1','data'=>'required|array']);
$previous=isset($in['previous_id']) ? $this->row((int)$in['previous_id'],$s) : null;
$last=DB::table('sf_proposals')->where($s)->where('kind',$in['kind'])->where('identifier',$in['identifier'])->orderByDesc('version')->first();
abort_unless(($last?->id ?? null)===($previous?->id ?? null),409,'Indique a versão anterior exacta.');
$version=(int)($last?->version ?? 0)+1;
if ($in['kind']==='profile') {
$d=$this->exact($in['data'],['service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:added','rate_percent'=>'required|in:16','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','treatment'=>'required|in:domestic_normal','conditions'=>'required|string|max:4000','evidence'=>'required|string|max:4000']);
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'],409,'Serviço/versão incompatível.');
if ($previous) abort_unless((int)$this->data($previous)['service_id']===(int)$d['service_id'],422,'Nova versão não pode trocar a identidade do serviço.');
abort_if(DB::table('sf_proposals')->where($s)->where('kind','profile')->whereRaw("data->>'service_id' = ?",[(string)$d['service_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade existente deste serviço e uma nova versão.');
$profile=DB::table('service_calculation_profiles')->insertGetId($s+['identifier'=>$in['identifier'],'version'=>$version,'definition'=>json_encode($d+['kind'=>'real','policy'=>ServiceCalculation::POLICY,'sources'=>ServiceCalculation::SOURCES+['fiscal_2026'=>'Lei10/2025:6,32;vigencia2026-01-01']],JSON_THROW_ON_ERROR),'created_at'=>now()]);
$draft=null;
} else {
$d=$this->exact($in['data'],['profile_proposal_id'=>'required|integer|min:1','draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0','reference_date'=>'required|date_format:Y-m-d','reference_basis'=>'required|string|max:2000','period_start'=>'required|date_format:Y-m-d','period_end'=>'nullable|date_format:Y-m-d|after_or_equal:period_start','next_billing'=>'nullable|date_format:Y-m-d','event'=>'required|in:pending,invoice_in_time,invoice_deadline','event_date'=>'nullable|required_unless:event,pending|date_format:Y-m-d','evidence'=>'required|string|max:4000']);
abort_if($d['event']==='pending' && !empty($d['event_date']),422,'Pendência temporal não tem data de exigibilidade.');
abort_if($d['event']!=='pending' && $d['reference_date']!==$d['event_date'],422,'A referência de vigência deve corresponder à exigibilidade fundamentada quando determinada.');
$p=$this->row((int)$d['profile_proposal_id'],$s); abort_unless($p->kind==='profile' && $this->approved($p),422,'Perfil exacto aprovado necessário.');
[$b,$prepared]=$this->draft((int)$d['draft_id'],$actor);
$this->versions($b,$prepared,$d);
abort_unless(($prepared['data']['period_start'] ?? null)===$d['period_start'] && ($prepared['data']['period_end'] ?? null)===($d['period_end'] ?? null),409,'Período deve corresponder à preparação revista.');
if ($previous) abort_unless((int)$previous->draft_id===(int)$d['draft_id'],422,'Decisão temporal pertence ao mesmo rascunho.');
abort_if(DB::table('sf_proposals')->where($s)->where('kind','temporal')->where('draft_id',$d['draft_id'])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade temporal existente e uma nova versão.');
$profile=$p->profile_id; $draft=$d['draft_id'];
}
$id=DB::table('sf_proposals')->insertGetId($s+['kind'=>$in['kind'],'identifier'=>$in['identifier'],'version'=>$version,'previous_id'=>$previous?->id,'profile_id'=>$profile,'draft_id'=>$draft,'data'=>json_encode($d,JSON_THROW_ON_ERROR),'actor_id'=>$actor,'created_at'=>now()]);
Audit::record('service_fiscal.proposed',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$version,'kind'=>$in['kind']]);
return ['id'=>$id,'version'=>$version,'active'=>false];
});
}
public function decide(int $id,int $actor,array $input): array
{
return $this->command($actor,'approve',function($s) use($id,$actor,$input) {
$d=$this->exact($input,['version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|max:4000']);
$r=$this->row($id,$s); abort_unless((int)$r->version===(int)$d['version'] && (int)$r->actor_id!==$actor,403,'Versão exacta e validador distinto obrigatórios.');
abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.');
if ($r->kind==='temporal' && $d['decision']==='approved') {
$data=$this->data($r); [$b,$p]=$this->draft((int)$r->draft_id,$actor); $this->versions($b,$p,$data);
$profile=$this->row((int)$data['profile_proposal_id'],$s); $this->compatible($profile,$s,$data['reference_date'],$b,$p,false);
}
DB::table('sf_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]);
Audit::record('service_fiscal.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$r->version,'decision'=>$d['decision']]);
return ['id'=>$id,'decision'=>$d['decision'],'active'=>false];
});
}
/** No HTTP activation endpoint. Only isolated proofs may exercise active/revoked states in this increment. */
public function proofActivation(int $id,int $actor,int $version,bool $active): void
{
abort_unless(ServiceCalculation::isolated(),403,'Activação real não autorizada neste incremento.'); TestingDatabaseGuard::assertSafe();
$this->command($actor,'approve',function($s) use($id,$actor,$version,$active) {
$p=$this->row($id,$s); abort_unless($p->kind==='profile' && (int)$p->version===$version && $this->approved($p) && $this->latest($p,$s),409);
DB::table('sf_activation_events')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'active'=>$active,'reason'=>'Prova sintética isolada; não é activação real.','created_at'=>now()]);
Audit::record('service_fiscal.proof_activation',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$version,'active'=>$active]);
return [];
});
}
private function draft(int $id,int $actor): array
{
$b=$this->workflow->show('billing_drafts',$id,$actor); abort_if($b['internal_confirmation'],409,'B1 preservada.');
return [$b,$this->preparation->show('billing_drafts',$id,$actor)];
}
private function versions(array $b,array $p,array $d): void
{
abort_unless($b['version']===(int)$d['draft_version'] && $p['version']===(int)$d['preparation_version'] && ($p['data']['draft_version'] ?? null)===$b['version'],409,'Rascunho/preparação desactualizados.');
$current=\App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id',$b['source_id'])->value('version');
abort_unless((int)$current===(int)$b['data']['source_version'],409,'Origem desactualizada.');
}
private function compatible(object $p,array $s,string $date,array $b,array $prepared,bool $active=true): array
{
$d=$this->data($p); $line=$b['data']['lines'][0] ?? [];
abort_unless($p->kind==='profile' && $this->approved($p) && (!$active || $this->activated($p)) && $this->latest($p,$s,$date),409,'Perfil sem aprovação/activação exacta ou substituído.');
abort_unless($date!=='' && $d['valid_from']<=$date && (empty($d['valid_until']) || $date<=$d['valid_until']),422,'Vigência não demonstrada.');
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'] && count($b['data']['lines'])===1 && (int)($line['service_id'] ?? 0)===(int)$d['service_id'] && $b['data']['currency']===$d['currency'] && ($prepared['data']['price_mode'] ?? null)===$d['price_mode'],422,'Serviço, moeda ou modo incompatível.');
return $d;
}
public function calculate(int $draft,int $actor,array $input): array
{
return $this->command($actor,'calculate',function($s) use($draft,$actor,$input) {
$d=$this->exact($input,['profile_proposal_id'=>'required|integer|min:1','profile_version'=>'required|integer|min:1','temporal_proposal_id'=>'required|integer|min:1','temporal_version'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0']);
[$b,$prepared]=$this->draft($draft,$actor); $this->versions($b,$prepared,$d);
$p=$this->row((int)$d['profile_proposal_id'],$s); $t=$this->row((int)$d['temporal_proposal_id'],$s);
abort_unless((int)$p->version===(int)$d['profile_version'] && $t->kind==='temporal' && (int)$t->version===(int)$d['temporal_version'] && (int)$t->draft_id===$draft && (int)$t->profile_id===(int)$p->profile_id && $this->approved($t) && $this->latest($t,$s),409,'Decisão temporal exacta e aprovada necessária.');
$td=$this->data($t); $this->versions($b,$prepared,$td);
$definition=$this->compatible($p,$s,$td['reference_date'],$b,$prepared);
$profile=json_decode(DB::table('service_calculation_profiles')->where('id',$p->profile_id)->value('definition'),true,32,JSON_THROW_ON_ERROR);
$result=ServiceCalculation::evaluateReal($b['data'],$prepared['data'],$profile);
abort_if($result['state']==='pending',422,implode(' ',$result['pending']));
$pending=$td['event']==='pending';
$result['temporal_state']=$pending ? 'temporal_pending' : 'temporal_decision_validated';
$result['exigibility_date']=$pending ? null : $td['event_date'];
$result['assessment_period']=$pending ? null : substr($td['event_date'],0,7);
if ($pending) $result['pending'][]='Exigibilidade e período de apuramento não determinados; resultado condicionado.';
$input=['draft_id'=>$draft,'draft_version'=>$b['version'],'preparation_version'=>$prepared['version'],'profile_proposal_id'=>$p->id,'profile_id'=>$p->profile_id,'profile_version'=>(int)$p->version,'profile_identifier'=>$p->identifier,'profile'=>$profile,'temporal_proposal_id'=>$t->id,'temporal_version'=>(int)$t->version,'temporal'=>$td,'data'=>$b['data'],'prepared'=>$prepared['data'],'policy'=>ServiceCalculation::POLICY];
$hash=hash('sha256',json_encode($input,JSON_THROW_ON_ERROR));
$old=DB::table('service_calculation_results')->where('billing_draft_id',$draft)->where('fingerprint',$hash)->first();
if ($old) return ['id'=>$old->id,'replayed'=>true];
$id=DB::table('service_calculation_results')->insertGetId($s+['billing_draft_id'=>$draft,'profile_id'=>$p->profile_id,'actor_id'=>$actor,'fingerprint'=>$hash,'snapshot'=>json_encode(['input'=>$input,'result'=>$result],JSON_THROW_ON_ERROR),'created_at'=>now()]);
Audit::record('service_fiscal.calculated',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['result_id'=>$id,'profile_proposal_id'=>$p->id,'temporal_proposal_id'=>$t->id]);
return ['id'=>$id,'replayed'=>false];
});
}
public function history(int $draft,int $actor): array
{
$s=$this->scope($actor); [$b,$prepared]=$this->draft($draft,$actor); $history=[];
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return [];
foreach(DB::table('service_calculation_results')->where($s)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $r) {
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR); $in=$snapshot['input'];
if (!isset($in['profile_proposal_id'])) continue;
$current=true;
try { $this->versions($b,$prepared,$in); $p=$this->row($in['profile_proposal_id'],$s); $t=$this->row($in['temporal_proposal_id'],$s); abort_unless($this->approved($t) && $this->latest($t,$s),409); $this->compatible($p,$s,$in['temporal']['reference_date'],$b,$prepared); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $current=false; }
$history[]=['id'=>$r->id,'current'=>$current,'snapshot'=>$snapshot];
}
return $history;
}
}