Files
SGF/app/Modules/Services/CatalogueAccounting.php
2026-11-02 08:06:45 +02:00

211 lines
19 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 Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
final class CatalogueAccounting
{
public const SOURCE_VERSION='Decreto70/2009-PGC-NIRF-PDF134-BR372(148)';
public const SOURCE_HASH='45C38AED64DE43042ED048586ACCE2EC660B74FC3F8FBCE33D39AAB45C7529E3';
public const REFERENCE=['plan'=>'PGC-NIRF','source_version'=>self::SOURCE_VERSION,'sha256'=>self::SOURCE_HASH,'accounts'=>[['code'=>'7','name'=>'Rendimentos e ganhos','movable'=>false],['code'=>'7.2','name'=>'Prestação de serviços','movable'=>false]],'status'=>'Exemplar fornecido; consolidação posterior não comprovada; não é aprovação de conta empresarial.'];
public const NOTICE='CLASSIFICAÇÃO CONTABILÍSTICA — SEM LANÇAMENTOS; IVA E RECONHECIMENTO INDEPENDENTES';
public function __construct(private CompanyContext $context,private Permissions $permissions,private ServiceWorkflow $workflow) {}
private function scope(int $actor,string $action='view'): array
{
$ids=$this->context->identifiers(); abort_unless($this->context->userId()===$actor,403);
foreach (array_unique(['view',$action]) as $a) { $this->permissions->require($actor,$ids['tenant_id'],$ids['company_id'],"catalogue_accounting.$a"); }
return $ids;
}
private function query(array $scope) { return DB::table('ca_proposals')->where($scope); }
private function row(int $id,array $scope): object { $row=$this->query($scope)->where('id',$id)->first(); abort_unless($row,403,'Proposta fora da empresa autorizada.'); return $row; }
private function decode(object $row): array
{
$result=(array)$row; $result['data']=json_decode($row->data,true,32,JSON_THROW_ON_ERROR);
$result['submission']=DB::table('ca_submissions')->where('proposal_id',$row->id)->first();
$result['decision']=DB::table('ca_decisions')->where('proposal_id',$row->id)->first();
$result['state']=$result['decision']->decision ?? ($result['submission'] ? 'submitted' : 'draft');
return $result;
}
public function listing(int $actor): array
{
$scope=$this->scope($actor); $rows=$this->query($scope)->orderBy('id')->get()->map(fn($r)=>$this->decode($r))->all();
$services=$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'services.view') ? \App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->orderBy('name')->get(['id','version','name'])->all() : [];
return ['notice'=>self::NOTICE,'reference'=>self::REFERENCE,'proposals'=>$rows,'services'=>$services];
}
private function command(int $actor,string $action,array $request,string $key,callable $write): array
{
$scope=$this->scope($actor,$action); $unit=$this->context->unitId();
abort_unless((bool)preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422,'Chave de repetição necessária.');
$canonical=function($v) use (&$canonical) { if(!is_array($v)) return $v; if(!array_is_list($v))ksort($v); return array_map($canonical,$v); };
$hash=hash('sha256',json_encode($canonical($request),JSON_THROW_ON_ERROR));
return DB::transaction(function() use($actor,$action,$request,$key,$write,$scope,$unit,$hash) {
DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor,$scope['tenant_id'],$scope['company_id'],$unit); $this->scope($actor,$action);
$old=DB::table('ca_commands')->where($scope)->where('command_key',$key)->first();
if($old) { abort_unless((int)$old->actor_id===$actor && hash_equals($old->request_hash,$hash),409,'Repetição com autor ou conteúdo diferente.'); return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true]; }
$result=$write($scope);
DB::table('ca_commands')->insert($scope+['actor_id'=>$actor,'command_key'=>$key,'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>now()]);
return $result+['replayed'=>false];
});
}
private function date(string $date): void { Validator::make(['date'=>$date],['date'=>'required|date_format:Y-m-d'])->validate(); }
private function data(string $kind,array $data,array $scope): array
{
$schemas=[
'plan'=>['plan'=>'required|in:PGC-NIRF','source_version'=>'required|in:'.self::SOURCE_VERSION],
'account'=>['plan_id'=>'required|integer|min:1','code'=>'required|string|max:40|regex:/^7\.2(?:\.[0-9A-Za-z_-]+)*$/D','name'=>'required|string|max:255','origin'=>'required|in:reference,enterprise','movable'=>'required|boolean','evidence'=>'required|string|max:2000'],
'category'=>['name'=>'required|string|max:255','account_id'=>'required|integer|min:1','conditions'=>'required|string|max:2000'],
'service_link'=>['service_id'=>'required|integer|min:1','category_id'=>'required|integer|min:1'],
'service_override'=>['service_id'=>'required|integer|min:1','account_id'=>'required|integer|min:1','conditions'=>'required|string|max:2000'],
];
abort_unless(isset($schemas[$kind]),422); abort_if(array_diff(array_keys($data),array_keys($schemas[$kind]))!==[],422,'Campo fora do âmbito: sem IVA, reconhecimento ou lançamentos.');
$data=Validator::make($data,$schemas[$kind])->validate();
foreach(['plan_id'=>'plan','account_id'=>'account','category_id'=>'category'] as $field=>$type) {
if(isset($data[$field])) { $data[$field]=(int)$data[$field]; abort_unless($this->row($data[$field],$scope)->kind===$type,422,'Tipo de referência incompatível.'); }
}
if(isset($data['service_id'])) {
$data['service_id']=(int)$data['service_id'];
$this->permissions->require($this->context->userId(),$scope['tenant_id'],$scope['company_id'],'services.view');
abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$data['service_id'])->exists(),403,'Serviço fora da empresa.');
}
if($kind==='account') {
$data['movable']=(bool)$data['movable'];
if($data['origin']==='reference') { abort_unless($data['code']==='7.2' && !$data['movable'] && $data['name']==='Prestação de serviços',422,'Referência 7.2 não é conta de movimento aprovada.'); }
else { abort_if(in_array($data['code'],['7.2','7.2.1','7.2.6'],true),422,'Código reservado; não usar como desdobramento empresarial.'); }
}
return $data;
}
public function propose(int $actor,array $input,string $key): array
{
return $this->command($actor,'propose',['operation'=>'propose','input'=>$input],$key,function($scope) use($actor,$input) {
$data=$this->data($input['kind'],$input['data'],$scope); $previous=isset($input['previous_id']) ? $this->row((int)$input['previous_id'],$scope) : null;
$entity=match($input['kind']) { 'plan'=>'PGC-NIRF','account'=>$data['code'],'service_link','service_override'=>'service:'.$data['service_id'],default=>$previous?->entity_key ?? (string)Str::uuid() };
if($previous) { abort_unless($previous->kind===$input['kind'] && $previous->entity_key===$entity,422,'A correcção deve preservar a identidade.'); }
$latest=$this->query($scope)->where('kind',$input['kind'])->where('entity_key',$entity)->orderByDesc('version')->first();
abort_unless(($latest?->id ?? null)===($previous?->id ?? null),409,'Existe uma versão mais recente; reveja antes de propor.');
$replaces=$input['replaces_id'] ?? null;
if($replaces) { $r=$this->row((int)$replaces,$scope); abort_unless($r->kind===$input['kind'] && $r->entity_key===$entity && $r->valid_from<=$input['valid_from'] && $this->approved($r),422,'Substituição exige versão aprovada da mesma identidade e data compatível.'); }
$id=DB::table('ca_proposals')->insertGetId($scope+['kind'=>$input['kind'],'entity_key'=>$entity,'version'=>(int)($latest?->version ?? 0)+1,'previous_id'=>$previous?->id,'replaces_id'=>$replaces,
'valid_from'=>$input['valid_from'],'valid_until'=>$input['valid_until'] ?? null,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$input['reason'],'actor_id'=>$actor,'created_at'=>now()]);
Audit::record('catalogue_accounting.proposed',$actor,$scope['tenant_id'],$scope['company_id'],details:['record_id'=>$id,'version'=>(int)($latest?->version ?? 0)+1]);
return ['id'=>$id];
});
}
private function approved(object $row): bool { return DB::table('ca_decisions')->where('proposal_id',$row->id)->where('decision','approved')->exists(); }
private function approvedRows(array $scope,string $kind,?string $entity=null): array
{
$q=$this->query($scope)->where('kind',$kind)->whereExists(fn($q)=>$q->selectRaw('1')->from('ca_decisions')->whereColumn('ca_decisions.proposal_id','ca_proposals.id')->where('decision','approved'));
if($entity!==null)$q->where('entity_key',$entity); return $q->get()->all();
}
private function active(object $row,string $date,array $scope): bool
{
if($row->valid_from>$date || ($row->valid_until && $row->valid_until<$date))return false;
// Explicit approved replacement ends the replaced interval from its start onward.
return ! $this->query($scope)->where('replaces_id',$row->id)->where('valid_from','<=',$date)->whereExists(fn($q)=>$q->selectRaw('1')->from('ca_decisions')->whereColumn('ca_decisions.proposal_id','ca_proposals.id')->where('decision','approved'))->exists();
}
private function select(array $scope,string $kind,string $entity,string $date): ?object
{
$rows=array_values(array_filter($this->approvedRows($scope,$kind,$entity),fn($r)=>$this->active($r,$date,$scope)));
abort_if(count($rows)>1,409,'Conflito explícito: várias versões aprovadas vigentes.'); return $rows[0] ?? null;
}
private function reference(int $id,string $kind,string $date,array $scope): object
{
$r=$this->row($id,$scope); abort_unless($r->kind===$kind && $this->approved($r) && $this->active($r,$date,$scope),422,'Referência sem aprovação/vigência compatível.'); return $r;
}
private function dependencies(object $row,string $date,array $scope): array
{
$data=json_decode($row->data,true,32,JSON_THROW_ON_ERROR);
if($row->kind==='plan')return ['plan'=>$row];
if($row->kind==='account') { return ['account'=>$row,'plan'=>$this->reference($data['plan_id'],'plan',$date,$scope)]; }
if($row->kind==='service_link') {
$categoryRef=$this->row($data['category_id'],$scope);
$category=$this->select($scope,'category',$categoryRef->entity_key,$date);
abort_unless($category,422,'Categoria sem regra-base aprovada vigente.');
return ['link'=>$row]+$this->dependencies($category,$date,$scope);
}
$account=$this->reference($data['account_id'],'account',$date,$scope);
$accountData=json_decode($account->data,true,32,JSON_THROW_ON_ERROR);
abort_unless($accountData['movable'],422,'Conta agregadora: classificação de movimento pendente.');
return [$row->kind==='category' ? 'category' : 'override'=>$row]+$this->dependencies($account,$date,$scope);
}
public function submit(int $id,int $actor,string $key): array
{
return $this->command($actor,'propose',['operation'=>'submit','id'=>$id],$key,function($scope) use($actor,$id) {
$r=$this->row($id,$scope); $latest=$this->query($scope)->where('kind',$r->kind)->where('entity_key',$r->entity_key)->max('version');
abort_unless((int)$latest===(int)$r->version,409,'Submeta a versão mais recente.');
abort_if(DB::table('ca_submissions')->where('proposal_id',$id)->exists(),409,'Versão já submetida.');
$this->dependencies($r,$r->valid_from,$scope);
DB::table('ca_submissions')->insert($scope+['proposal_id'=>$id,'actor_id'=>$actor,'created_at'=>now()]);
Audit::record('catalogue_accounting.submitted',$actor,$scope['tenant_id'],$scope['company_id'],details:['record_id'=>$id,'version'=>(int)$r->version]); return ['id'=>$id];
});
}
public function decide(int $id,int $actor,array $input,string $key): array
{
return $this->command($actor,'approve',['operation'=>'decide','id'=>$id,'input'=>$input],$key,function($scope) use($id,$actor,$input) {
$r=$this->row($id,$scope); abort_unless(DB::table('ca_submissions')->where('proposal_id',$id)->exists(),409,'Versão não submetida.');
abort_if(DB::table('ca_decisions')->where('proposal_id',$id)->exists(),409,'Versão já decidida.');
abort_unless((int)$this->query($scope)->where('kind',$r->kind)->where('entity_key',$r->entity_key)->max('version')===(int)$r->version,409,'Uma nova proposta exige revisão da versão actual.');
if($input['decision']==='approved') {
$this->dependencies($r,$r->valid_from,$scope);
foreach($this->approvedRows($scope,$r->kind,$r->entity_key) as $other) {
// Conflicting overlapping intervals require an explicit replacement, never latest-wins.
$start=max($r->valid_from,$other->valid_from); $end=min($r->valid_until ?? '9999-12-31',$other->valid_until ?? '9999-12-31');
if($start<=$end && $this->active($other,$start,$scope)) { abort_unless((int)$r->replaces_id===(int)$other->id,409,'Sobreposição: indique explicitamente a versão aprovada substituída.'); }
}
}
DB::table('ca_decisions')->insert($scope+['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$input['decision'],'reason'=>$input['reason'],'created_at'=>now()]);
Audit::record('catalogue_accounting.'.$input['decision'],$actor,$scope['tenant_id'],$scope['company_id'],details:['record_id'=>$id,'version'=>(int)$r->version,'reason'=>$input['reason']]); return ['id'=>$id,'decision'=>$input['decision']];
});
}
private function resolveInternal(int $service,string $date,array $scope): array
{
$s=\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$service)->first(); abort_unless($s,403,'Serviço fora da empresa.');
$base=['service'=>['id'=>$service,'version'=>(int)$s->version,'name'=>$s->name],'reference_date'=>$date,'notice'=>self::NOTICE];
$override=$this->select($scope,'service_override','service:'.$service,$date);
$link=$this->select($scope,'service_link','service:'.$service,$date);
if(!$override && !$link)return $base+['state'=>'pending','pending'=>['Ligação à categoria ou excepção aprovada vigente em falta.']];
try { $refs=$this->dependencies($override ?? $link,$date,$scope); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { if($e->getStatusCode()!==422)throw $e; return $base+['state'=>'pending','pending'=>[$e->getMessage()],'conflict'=>true]; }
$plan=$this->select($scope,'plan','PGC-NIRF',$date);
if(!$plan || (int)$plan->id!==(int)$refs['plan']->id)return $base+['state'=>'pending','pending'=>['Versão do plano incompatível; rever associações.'],'conflict'=>true];
return $base+['state'=>'resolved_classification_only','origin'=>$override ? 'explicit_service_exception' : 'category_base','references'=>array_map(fn($r)=>['id'=>$r->id,'version'=>(int)$r->version,'kind'=>$r->kind,'entity_key'=>$r->entity_key,'valid_from'=>$r->valid_from,'valid_until'=>$r->valid_until,'data'=>json_decode($r->data,true),'approval'=>(array)DB::table('ca_decisions')->where('proposal_id',$r->id)->first()],$refs),
'source'=>self::REFERENCE,'pending'=>['IVA e reconhecimento continuam independentes; nenhum lançamento produzido.']];
}
public function resolve(int $service,?string $date,int $actor): array
{
$scope=$this->scope($actor); $this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'services.view');
abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$service)->exists(),403);
if(!$date)return ['state'=>'pending','pending'=>['Data de referência explícita em falta; nenhuma data foi presumida.']]; $this->date($date);
$read=function()use($service,$date,$scope){$result=$this->resolveInternal($service,$date,$scope);return $result+['revision'=>hash_hmac('sha256',json_encode($result,JSON_THROW_ON_ERROR),(string)config('app.key'))];};
if(DB::transactionLevel())return $read();
return DB::transaction(function()use($read){DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $read();});
}
public function capture(int $actor,array $input,string $key): array
{
return $this->command($actor,'propose',['operation'=>'capture','input'=>$input],$key,function($scope) use($actor,$input) {
$operationScope=$this->workflow->scope($input['area'],$actor,'view');
$draft=$this->workflow->show($input['area'],$input['record_id'],$actor);
abort_unless($draft['version']===$input['record_version'],409,'Operação desactualizada.');
abort_if(($draft['internal_confirmation'] ?? null)!==null,409,'B1 preservado; não capturar classificação retroactiva.');
abort_unless(in_array($input['service_id'],array_column($draft['data']['lines'],'service_id'),true),422,'Serviço não pertence à operação.');
$snapshot=$this->resolve($input['service_id'],$input['reference_date'],$actor);
abort_unless(hash_equals($snapshot['revision'],$input['resolution_revision']),409,'A classificação mudou desde a consulta; consulte novamente antes de guardar.');
abort_unless($snapshot['state']==='resolved_classification_only',422,implode(' ',$snapshot['pending']));
$id=DB::table('ca_resolutions')->insertGetId($operationScope+['area'=>$input['area'],'record_id'=>$input['record_id'],'record_version'=>$input['record_version'],'service_id'=>$input['service_id'],'reference_date'=>$input['reference_date'],'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'actor_id'=>$actor,'created_at'=>now()]);
Audit::record('catalogue_accounting.resolution_captured',$actor,$scope['tenant_id'],$scope['company_id'],$operationScope['unit_id'],details:['record_id'=>$id,'version'=>$input['record_version']]); return ['id'=>$id];
});
}
public function history(int $actor,string $area,int $record): array
{
$this->scope($actor); $scope=$this->workflow->scope($area,$actor,'view'); $this->workflow->show($area,$record,$actor);
return DB::table('ca_resolutions')->where($scope)->where('area',$area)->where('record_id',$record)->orderByDesc('id')->get()->map(function($r) { $r->snapshot=json_decode($r->snapshot,true); return $r; })->all();
}
}