Preserva classificacao contabilistica aceite e bases locais

Regista aceitacao funcional do primeiro incremento PGC-NIRF e preserva as dependencias documentais e tecnicas existentes. A validacao do calculo fiscal continua separada e pendente. Sem credenciais, bases operacionais ou publicacao.
This commit is contained in:
2026-10-26 13:47:11 +02:00
parent 8fcd52c31d
commit f8d57b52aa
86 changed files with 3621 additions and 8 deletions
@@ -0,0 +1,210 @@
<?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') ? DB::table('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(DB::table('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=DB::table('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(DB::table('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();
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class DocumentPreparation
{
public const PROFILE_FIELDS = ['nuit','address','locality','country'];
public const PAYMENT_FIELDS = ['label','bank','holder','account_number','nib','iban','swift','bank_code','currency','instructions'];
public const DRAFT_FIELDS = ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode','units','payment_instruction_ids'];
public function __construct(private CompanyContext $context, private Permissions $permissions, private ServiceWorkflow $workflow) {}
public function scope(string $kind, int $actor, string $action): array
{
abort_unless(in_array($kind, ['issuer','client','payment','service_drafts','billing_drafts'], true), 404);
$ids = $this->context->identifiers(); abort_unless($this->context->userId() === $actor, 403);
$unit = str_ends_with($kind, '_drafts') ? $this->context->unitId() : null;
abort_if(str_ends_with($kind, '_drafts') && ! $unit, 403, 'Seleccione uma unidade autorizada.');
$prefix = $kind === 'client' ? 'client_documents' : ($unit ? 'draft_documents' : 'issuer_documents');
foreach (array_unique(['view',$action]) as $right) { $this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$prefix.$right", $unit); }
return $ids + ['unit_id' => $unit];
}
private function query(string $kind, array $scope)
{
return DB::table('doc_records')->where($scope)->where('kind', $kind);
}
private function target(string $kind, int $id, array $scope): ?object
{
if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; }
$table = $kind === 'issuer' ? 'companies' : ($kind === 'client' ? 'a_clients' : 'a_'.$kind);
$query = DB::table($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
if ($scope['unit_id']) { $query->where('unit_id', $scope['unit_id']); }
$target = $query->first(); abort_unless($target, 403, 'Registo fora do âmbito autorizado.'); return $target;
}
public function index(string $kind, int $actor): array
{
$scope = $this->scope($kind, $actor, 'view');
if ($kind === 'payment') { return $this->query($kind, $scope)->orderBy('id')->get()->map(fn ($row) => $this->decode($row))->all(); }
if ($kind === 'issuer') { return [['id' => $scope['company_id'], 'name' => DB::table('companies')->where('id', $scope['company_id'])->value('name')]]; }
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
return $this->workflow->list($kind, $actor);
}
public function show(string $kind, int $id, int $actor): array
{
$scope = $this->scope($kind, $actor, 'view'); $target = $this->target($kind, $id, $scope);
abort_if($kind === 'payment' && ! $target, 403);
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); }
if ($scope['unit_id']) { $this->workflow->scope($kind, $actor, 'view'); }
$row = $kind === 'payment' ? $target : $this->query($kind, $scope)->where('record_id', $id)->first();
$record = $row ? $this->decode($row) : ['id' => null, 'version' => 0, 'data' => []];
$record['name'] = $target->name ?? ($record['data']['label'] ?? '');
$record['origin'] = $kind === 'payment' ? 'Instruções configuradas do emitente' : ($kind === 'issuer' ? 'Nome reutilizado do cadastro da empresa' : ($kind === 'client' ? 'Nome reutilizado do cadastro do cliente' : 'Rascunho existente na unidade seleccionada'));
$record['locked'] = $kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists();
$record['review'] = $this->review($kind, $record['data'], $target);
if ($scope['unit_id']) {
$data = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
$record['draft_version'] = (int) $target->version; $record['currency'] = $data['currency']; $record['lines'] = $data['lines'];
$record['review'][] = $this->check('currency', 'Moeda do rascunho', $data['currency'], 'Rascunho existente', 'Indicar moeda sem a presumir; validar aplicabilidade.');
$record['review'][] = ['field' => 'terms_due', 'label' => 'Condições e vencimento', 'state' => 'validation_pending', 'origin' => 'Preparação do rascunho', 'next' => 'Conferir prazo e vencimento proposto. Sem emissão efectiva ou convenção de contagem aprovada, a compatibilidade não é validada nem corrigida automaticamente.'];
if ($row && (int) ($record['data']['draft_version'] ?? 0) !== (int) $target->version) { $record['review'][] = ['field' => 'draft_version', 'label' => 'Rascunho alterado', 'state' => 'stale', 'origin' => 'Versão de origem', 'next' => 'Rever linhas, unidades e preparação antes de voltar a guardar.']; }
$record['related_profiles'] = [];
foreach (['issuer' => $scope['company_id'], 'client' => $target->client_id] as $relatedKind => $relatedId) {
$prefix = $relatedKind === 'issuer' ? 'issuer_documents.view' : 'client_documents.view';
if ($this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], $prefix)) { $related = $this->show($relatedKind, (int) $relatedId, $actor); $record['related_profiles'][$relatedKind] = ['name' => $related['name'], 'version' => $related['version'], 'review' => $related['review']]; }
}
}
return $record;
}
public function save(string $kind, int $id, int $actor, array $input): array
{
$scope = $this->scope($kind, $actor, 'edit'); $selected = $this->context->unitId();
try {
return DB::transaction(function () use ($kind, $id, $actor, $input, $scope, $selected): array {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selected); $this->scope($kind, $actor, 'edit');
$target = $this->target($kind, $id, $scope); abort_if($kind === 'payment' && $id && ! $target, 403);
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); }
$old = $kind === 'payment' ? $target : $this->query($kind, $scope)->where('record_id', $id)->first();
abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'Os dados mudaram; reabra o formulário.');
$fields = in_array($kind, ['issuer','client'], true) ? self::PROFILE_FIELDS : ($kind === 'payment' ? self::PAYMENT_FIELDS : self::DRAFT_FIELDS);
$data = array_intersect_key($input, array_flip($fields));
if ($scope['unit_id']) {
$this->workflow->scope($kind, $actor, 'view');
abort_if($kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'A fotografia e o rascunho B1 estão bloqueados.');
abort_unless((int) $target->version === $input['draft_version'], 409, 'O rascunho mudou; reveja a preparação.');
$draftData = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
abort_unless(count($data['units']) === count($draftData['lines']), 422, 'Indique uma unidade de medida, ou pendência, por linha existente.');
$data['payment_instruction_ids'] = array_values(array_unique($data['payment_instruction_ids']));
if ($data['payment_instruction_ids']) { $this->scope('payment', $actor, 'view'); }
foreach ($data['payment_instruction_ids'] as $instruction) { abort_unless($this->query('payment', ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'unit_id' => null])->where('id', $instruction)->exists(), 403, 'Instrução fora da empresa autorizada.'); }
if ($input['currency'] !== $draftData['currency']) {
$changes = ['version' => (int) $target->version, 'currency' => $input['currency']];
if ($kind === 'service_drafts') { $changes += ['client_id' => $target->client_id, 'lines' => $draftData['lines']]; }
$saved = $this->workflow->save($kind, $id, $actor, $changes, (string) \Illuminate\Support\Str::uuid());
$target->version = $saved['version'];
}
$data['draft_version'] = (int) $target->version;
}
$version = (int) ($old->version ?? 0) + 1;
$values = ['data' => json_encode((object) $data, JSON_THROW_ON_ERROR), 'version' => $version, 'updated_at' => now()];
if ($old) { $this->query($kind, $scope)->where('id', $old->id)->update($values); $record = $old->id; }
else { $record = DB::table('doc_records')->insertGetId($scope + $values + ['kind' => $kind, 'record_id' => $kind === 'payment' ? 0 : $id, 'created_at' => now()]); }
DB::table('doc_history')->insert(['doc_record_id' => $record, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]);
// Audit contains only identifiers/version; never bank identifiers or instructions.
Audit::record('documents.'.$kind.'.prepared', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => $kind, 'record_id' => $record, 'version' => $version]);
return ['id' => $kind === 'payment' ? $record : $id, 'version' => $version];
});
} catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a preparação. Nenhum valor das instruções foi incluído no erro; reabra e tente novamente.'); }
}
public function snapshot(int $draft, int $actor): ?array
{
$scope = $this->context->identifiers(); $scope['unit_id'] = $this->context->unitId();
$billing = DB::table('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
$rows = DB::table('doc_records')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where(function ($q) use ($billing, $draft) {
$q->where('kind', 'issuer')->orWhere(fn ($q) => $q->where('kind', 'client')->where('record_id', $billing->client_id))->orWhere(fn ($q) => $q->where('kind', 'billing_drafts')->where('record_id', $draft));
})->get();
if ($rows->isEmpty()) { return null; }
foreach (['issuer','client','billing_drafts'] as $kind) { $this->scope($kind, $actor, 'view'); }
$result = ['schema_version' => 2, 'records' => [], 'payment_instructions' => [], 'completeness' => [], 'validation_status' => 'not_fiscally_validated'];
foreach ($rows as $row) {
$decoded = $this->decode($row); $result['records'][] = $decoded;
$result['completeness'][$row->kind] = $this->show($row->kind, (int) $row->record_id, $actor)['review'];
if ($row->kind === 'billing_drafts') {
abort_unless((int) ($decoded['data']['draft_version'] ?? 0) === (int) $billing->version, 409, 'Reveja a preparação documental após a alteração do rascunho.');
foreach ($decoded['data']['payment_instruction_ids'] as $id) { $result['payment_instructions'][] = $this->show('payment', $id, $actor); }
}
}
return $result;
}
public function inheritToBilling(int $source, int $billing, int $actor, array $scope): void
{
if (! $this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], 'draft_documents.view', $scope['unit_id'])) { return; }
$row = $this->query('service_drafts', $scope)->where('record_id', $source)->first();
if (! $row) { return; }
$data = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
if ((int) $data['draft_version'] !== (int) DB::table('a_service_drafts')->where('id', $source)->value('version')) { return; }
$data['draft_version'] = 1; $data['source_preparation'] = ['id' => $row->id, 'version' => $row->version];
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
$id = DB::table('doc_records')->insertGetId($scope + ['kind' => 'billing_drafts', 'record_id' => $billing, 'version' => 1, 'data' => $encoded, 'created_at' => now(), 'updated_at' => now()]);
DB::table('doc_history')->insert(['doc_record_id' => $id, 'version' => 1, 'actor_id' => $actor, 'snapshot' => $encoded, 'created_at' => now()]);
Audit::record('documents.billing_drafts.reused', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['record_id' => $id, 'version' => 1]);
}
private function decode(object $row): array { $result = (array) $row; $result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR); return $result; }
private function check(string $field, string $label, mixed $value, string $origin, string $next): array { return ['field' => $field, 'label' => $label, 'state' => $value !== null && $value !== '' ? 'filled_not_validated' : 'missing', 'origin' => $origin, 'next' => $next]; }
private function review(string $kind, array $data, ?object $target): array
{
$fields = in_array($kind, ['issuer','client'], true) ? self::PROFILE_FIELDS : ($kind === 'payment' ? ['label','bank','holder','currency','instructions'] : ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode']);
$labels = ['nuit' => 'NUIT', 'address' => 'Morada', 'locality' => 'Localidade', 'country' => 'País', 'label' => 'Designação', 'bank' => 'Banco (quando aplicável)', 'holder' => 'Titular (quando aplicável)', 'currency' => 'Moeda (quando aplicável)', 'instructions' => 'Instruções', 'service_date' => 'Data do serviço', 'period_start' => 'Início do período', 'period_end' => 'Fim do período', 'payment_terms' => 'Condições de pagamento', 'proposed_due_date' => 'Vencimento proposto', 'price_mode' => 'Modo de preço'];
$result = [];
if (in_array($kind, ['issuer','client'], true)) { $result[] = $this->check('name','Denominação', $target?->name, 'Cadastro existente (sem duplicação)', 'Editar o nome no cadastro original se necessário; validar denominação legal.'); }
foreach ($fields as $field) { $value = $data[$field] ?? null; if ($field === 'price_mode' && $value === 'undefined') { $value = null; } $result[] = $this->check($field, $labels[$field], $value, 'Preparação introduzida pelo utilizador', 'Completar se aplicável e validar; preenchido não significa fiscalmente validado.'); }
if (str_ends_with($kind, '_drafts')) { foreach ($data['units'] ?? [] as $i => $value) { $result[] = $this->check('unit_'.$i,'Unidade da linha '.($i + 1),$value,'Preparação da linha existente','Indicar unidade de medida se aplicável; nenhuma unidade é presumida.'); } }
$result[] = ['field' => 'legal', 'label' => 'Validação legal/fiscal', 'state' => 'validation_pending', 'origin' => 'Regras ainda por validar', 'next' => 'Validar fontes e regras aplicáveis. Este ecrã não autoriza emissão fiscal nem verifica dados junto de bancos.'];
return $result;
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class DocumentPreview
{
public const CONFIGURE = 'document_preview.configure';
public const NOTICE = 'RASCUNHO — SEM VALIDADE FISCAL';
public const DEFAULT_MODEL = ['show_logo' => false, 'show_contacts' => true, 'show_observations' => true, 'show_payment_instructions' => true, 'contact_text' => '', 'observations' => ''];
public function __construct(private CompanyContext $context, private Permissions $permissions, private DocumentPreparation $preparation, private ServiceWorkflow $workflow) {}
private function consistent(callable $read): mixed
{
if (DB::transactionLevel() > 0) {
// RefreshDatabase fixtures already use a transaction. Production composition must start its own snapshot.
if (! app()->environment('testing')) { throw new HttpException(409, 'Reabra a consulta numa operação independente.'); }
return $read();
}
return DB::transaction(function () use ($read) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
return $read();
});
}
private function model(): array
{
$ids = $this->context->identifiers();
$row = DB::table('document_preview_models')->where($ids)->first();
return ['version' => (int) ($row->version ?? 0), 'data' => $row ? json_decode($row->data, true, 32, JSON_THROW_ON_ERROR) : self::DEFAULT_MODEL];
}
public function configuration(int $actor): array
{
return $this->consistent(function () use ($actor) { $this->preparation->scope('issuer', $actor, 'view'); return $this->model() + ['local_logo_available' => $this->logo() !== null]; });
}
public function configure(int $actor, array $input): array
{
$scope = $this->preparation->scope('issuer', $actor, 'view'); $unit = $this->context->unitId();
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE);
try { return DB::transaction(function () use ($scope, $unit, $actor, $input) {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $unit);
$this->preparation->scope('issuer', $actor, 'view');
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE);
$ids = ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']];
$old = DB::table('document_preview_models')->where($ids)->first();
abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'A configuração mudou. Reabra-a antes de guardar.');
$data = array_intersect_key($input, self::DEFAULT_MODEL); $version = (int) ($old->version ?? 0) + 1;
$values = ['version' => $version, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()];
if ($old) { DB::table('document_preview_models')->where('id', $old->id)->update($values); $id = $old->id; }
else { $id = DB::table('document_preview_models')->insertGetId($ids + $values + ['created_at' => now()]); }
DB::table('document_preview_model_history')->insert(['model_id' => $id, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]);
Audit::record('document_preview.configured', $actor, $scope['tenant_id'], $scope['company_id'], details: ['record_id' => $id, 'version' => $version]);
return ['version' => $version];
}); } catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a configuração; nenhum conteúdo foi incluído no erro.'); }
}
public function read(int $draft, int $actor, bool $historical = false): array
{
return $this->consistent(function () use ($draft, $actor, $historical) {
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
if ($historical) { $payload = $this->historical($draft, $actor, $scope); }
else { $payload = $this->current($draft, $actor, $scope); }
$payload['notice'] = self::NOTICE;
// Fingerprint is keyed, so a change token cannot be used to guess bank identifiers.
$revision = hash_hmac('sha256', json_encode($payload, JSON_THROW_ON_ERROR), (string) config('app.key'));
return $payload + ['revision' => $revision, 'consulted_at' => now()->toIso8601String()];
});
}
private function current(int $draft, int $actor, array $scope): array
{
foreach (['issuer','client','billing_drafts'] as $kind) { $this->preparation->scope($kind, $actor, 'view'); }
$billing = $this->workflow->show('billing_drafts', $draft, $actor);
abort_if($billing['internal_confirmation'] !== null, 409, 'Este registo tem confirmação interna. Abra a vista histórica separada.');
$issuer = $this->preparation->show('issuer', $scope['company_id'], $actor);
$client = $this->preparation->show('client', $billing['client_id'], $actor);
$prepared = $this->preparation->show('billing_drafts', $draft, $actor);
$model = $this->model(); $payments = []; $paymentVersions = [];
foreach ($prepared['data']['payment_instruction_ids'] ?? [] as $id) {
$payment = $this->preparation->show('payment', (int) $id, $actor); $paymentVersions[] = [$payment['id'], $payment['version']];
if ($model['data']['show_payment_instructions']) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; }
}
$pending = $billing['pending'];
foreach (['Emitente' => $issuer, 'Cliente' => $client, 'Preparação' => $prepared] as $section => $row) {
foreach ($row['review'] as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$section.'.'.$check['field']] = $section.' — '.$check['label'].': '.$check['next']; } }
}
if (! $paymentVersions) { $pending['instructions_missing'] = 'Instruções de pagamento não seleccionadas; preparar apenas as aplicáveis.'; }
if (! $model['data']['contact_text']) { $pending['contacts_missing'] = 'Contactos de apresentação não configurados; completar se aplicável, independentemente da visibilidade do bloco.'; }
$localLogo = $this->logo(); $logo = $model['data']['show_logo'] ? $localLogo : null;
if (! $localLogo) { $pending['logo_missing'] = 'Logótipo opcional não disponível: falta recurso local autorizado da empresa. A escolha de visibilidade não valida este recurso.'; }
return ['mode' => 'draft', 'title' => 'Pré-visualização do rascunho de factura de serviço', 'reference' => 'Rascunho interno #'.$draft,
'scope' => $scope, 'source_id' => $billing['source_id'], 'draft_version' => $billing['version'], 'preparation_version' => $prepared['version'], 'model_version' => $model['version'],
'issuer' => ['name' => $issuer['name'], 'data' => $issuer['data']], 'client' => ['name' => $client['name'], 'data' => $client['data']],
'data' => $billing['data'], 'prepared' => $prepared['data'], 'base_amount' => $billing['base_amount'], 'pending' => $pending,
'calculation' => app(ServiceCalculation::class)->read($draft, $actor),
'payments' => $payments, 'logo' => $logo, 'contacts' => $model['data']['show_contacts'] ? $model['data']['contact_text'] : null,
'observations' => $model['data']['show_observations'] ? $model['data']['observations'] : null,
'versions' => ['issuer' => $issuer['version'], 'client' => $client['version'], 'company_name' => $issuer['name'], 'client_name' => $client['name'], 'instructions' => $paymentVersions, 'model' => $model['version']]];
}
private function historical(int $draft, int $actor, array $scope): array
{
$confirmation = app(InternalConfirmation::class)->show($draft, $actor);
abort_unless($confirmation, 404, 'Não há confirmação interna histórica deste registo.');
$snapshot = $confirmation['snapshot']; $prepared = []; $issuer = []; $client = []; $payments = []; $pending = $snapshot['pending'];
$document = $snapshot['document_preparation'] ?? [];
foreach ($document['records'] ?? [] as $record) {
if ($record['kind'] === 'issuer') { $issuer = $record['data']; }
if ($record['kind'] === 'client') { $client = $record['data']; }
if ($record['kind'] === 'billing_drafts') { $prepared = $record['data']; }
}
foreach ($document['payment_instructions'] ?? [] as $payment) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; }
foreach ($document['completeness'] ?? [] as $kind => $checks) { foreach ($checks as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$kind.'.'.$check['field']] = $check['label'].': '.$check['next']; } } }
$pending['historical_limits'] = 'Logótipo, contactos, observações do modelo e denominação do emitente não foram capturados nesta fotografia. Não foram reconstruídos.';
return ['mode' => 'historical', 'title' => 'Confirmação interna histórica — sem validade fiscal', 'reference' => $confirmation['internal_reference'], 'scope' => $scope,
'confirmed_at' => $snapshot['confirmed_at'], 'actor_id' => $snapshot['actor_id'], 'snapshot_schema' => $snapshot['schema_version'] ?? 1, 'source_id' => $snapshot['service_draft']['id'],
'draft_version' => $snapshot['billing_draft']['version'], 'preparation_version' => null, 'model_version' => null,
'issuer' => ['name' => null, 'data' => $issuer], 'client' => ['name' => $snapshot['client']['name'], 'data' => $client],
'data' => $snapshot['data'], 'prepared' => $prepared, 'base_amount' => $snapshot['base_amount'], 'pending' => $pending, 'payments' => $payments,
'logo' => null, 'contacts' => null, 'observations' => null];
}
private function logo(): ?string
{
$company = $this->context->identifiers()['company_id'];
$disk = Storage::disk('local'); $path = 'document-logos/company-'.$company.'.png';
if (! $disk->exists($path) || $disk->size($path) > 524288) { return null; }
$bytes = $disk->get($path);
if (! str_starts_with($bytes, "\x89PNG\r\n\x1a\n") || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== 'image/png') { return null; }
return 'data:image/png;base64,'.base64_encode($bytes);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\CatalogueAccounting;
use Illuminate\Http\Request;
final class CatalogueAccountingController extends Controller
{
private function response(array $data) { return response()->json($data)->header('Cache-Control','no-store, private'); }
private function only(Request $r,array $keys): void { abort_if(array_diff(array_keys($r->all()),$keys)!==[],422,'Campo protegido ou fora do âmbito.'); }
public function index(Request $r,CatalogueAccounting $c) { return $this->response($c->listing($r->user()->id)); }
public function propose(Request $r,CatalogueAccounting $c) {
$this->only($r,['kind','data','valid_from','valid_until','reason','previous_id','replaces_id']);
$data=$r->validate(['kind'=>'required|in:plan,account,category,service_link,service_override','data'=>'required|array','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','reason'=>'required|string|max:2000','previous_id'=>'nullable|integer|min:1','replaces_id'=>'nullable|integer|min:1']);
return $this->response($c->propose($r->user()->id,$data,(string)$r->header('Idempotency-Key')));
}
public function submit(Request $r,int $id,CatalogueAccounting $c) { $this->only($r,[]); return $this->response($c->submit($id,$r->user()->id,(string)$r->header('Idempotency-Key'))); }
public function decide(Request $r,int $id,CatalogueAccounting $c) {
$this->only($r,['decision','reason']); return $this->response($c->decide($id,$r->user()->id,$r->validate(['decision'=>'required|in:approved,refused','reason'=>'required|string|max:2000']),(string)$r->header('Idempotency-Key')));
}
public function resolve(Request $r,int $service,CatalogueAccounting $c) { $this->only($r,['reference_date']); $r->validate(['reference_date'=>'nullable|date_format:Y-m-d']); return $this->response($c->resolve($service,$r->query('reference_date'),$r->user()->id)); }
public function capture(Request $r,CatalogueAccounting $c) {
$this->only($r,['area','record_id','record_version','service_id','reference_date','resolution_revision']);
$data=$r->validate(['area'=>'required|in:service_drafts,billing_drafts','record_id'=>'required|integer|min:1','record_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','reference_date'=>'required|date_format:Y-m-d','resolution_revision'=>'required|string|regex:/^[a-f0-9]{64}$/D']);
foreach(['record_id','record_version','service_id'] as $field)$data[$field]=(int)$data[$field];
return $this->response($c->capture($r->user()->id,$data,(string)$r->header('Idempotency-Key')));
}
public function history(Request $r,string $area,int $record,CatalogueAccounting $c) { abort_unless(in_array($area,['service_drafts','billing_drafts'],true),404); return $this->response(['records'=>$c->history($r->user()->id,$area,$record)]); }
}
@@ -0,0 +1,39 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\DocumentPreparation;
use Illuminate\Http\Request;
final class DocumentPreparationController extends Controller
{
public function index(Request $request, string $kind, DocumentPreparation $preparation) { return response()->json(['records' => $preparation->index($kind, $request->user()->id)]); }
public function show(Request $request, string $kind, int $id, DocumentPreparation $preparation) { return response()->json($preparation->show($kind, $id, $request->user()->id)); }
public function save(Request $request, string $kind, int $id, DocumentPreparation $preparation)
{
$preparation->scope($kind, $request->user()->id, 'edit');
$fields = in_array($kind, ['issuer','client'], true) ? DocumentPreparation::PROFILE_FIELDS : ($kind === 'payment' ? DocumentPreparation::PAYMENT_FIELDS : DocumentPreparation::DRAFT_FIELDS);
$allowed = array_merge($fields, ['version'], str_ends_with($kind, '_drafts') ? ['draft_version','currency'] : []);
abort_if(array_diff(array_keys($request->all()), $allowed) !== [], 422, 'Campo não suportado; não introduza credenciais, tokens ou dados de cartões.');
$rules = ['version' => 'required|integer|min:0'];
foreach ($fields as $field) { $rules[$field] = 'nullable|string|max:500'; }
if ($kind === 'payment') {
$rules['label'] = 'required|string|max:120'; $rules['instructions'] = 'nullable|string|max:2000'; $rules['currency'] = 'nullable|string|regex:/^[A-Z]{3}$/';
} elseif (str_ends_with($kind, '_drafts')) {
foreach (['service_date','period_start','period_end','proposed_due_date'] as $field) { $rules[$field] = 'nullable|date_format:Y-m-d'; }
if ($request->filled('period_start')) { $rules['period_end'] .= '|after_or_equal:period_start'; }
$rules['payment_terms'] = 'nullable|string|max:1000';
$rules['price_mode'] = 'required|in:undefined,included,added';
$rules['units'] = 'required|array|min:1|max:100'; $rules['units.*'] = 'nullable|string|max:80';
$rules['payment_instruction_ids'] = 'present|array|max:20'; $rules['payment_instruction_ids.*'] = 'required|integer|min:1|distinct';
$rules['draft_version'] = 'required|integer|min:1'; $rules['currency'] = 'nullable|string|regex:/^[A-Z]{3}$/';
}
$data = $request->validate($rules); $data['version'] = (int) $data['version'];
foreach ($fields as $field) { if (! array_key_exists($field, $data)) { $data[$field] = null; } }
if (isset($data['draft_version'])) { $data['draft_version'] = (int) $data['draft_version']; $data['currency'] ??= null; $data['payment_instruction_ids'] = array_map('intval', $data['payment_instruction_ids']); }
// Refuse obvious secret assignments in free text; no secret fields are supported.
foreach ($data as $value) { if (is_string($value)) { abort_if((bool) preg_match('/(?:password|palavra.passe|token|secret|cvv|cvc|pin)\s*[:=]/iu', $value), 422, 'Não introduza credenciais ou segredos nas instruções.'); } }
return response()->json($preparation->save($kind, $id, $request->user()->id, $data));
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\DocumentPreview;
use Illuminate\Http\Request;
final class DocumentPreviewController extends Controller
{
public function calculate(Request $request, int $id, \App\Modules\Services\ServiceCalculation $calculation)
{
abort_if(array_diff(array_keys($request->all()), ['draft_version','preparation_version','profile_id','profile_version']) !== [], 422);
$data = $request->validate(['draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0','profile_id'=>'required|integer|min:1','profile_version'=>'required|integer|min:1']);
return $this->response($calculation->calculate($id,$request->user()->id,array_map('intval',$data)));
}
private function response(array $data) { return response()->json($data)->header('Cache-Control', 'no-store, private, max-age=0')->header('Pragma', 'no-cache')->header('Vary', 'Cookie'); }
public function model(Request $request, DocumentPreview $preview) { return $this->response($preview->configuration($request->user()->id)); }
public function configure(Request $request, DocumentPreview $preview)
{
abort_if(array_diff(array_keys($request->all()), ['version', ...array_keys(DocumentPreview::DEFAULT_MODEL)]) !== [], 422, 'Campo não configurável: aviso e pendências são obrigatórios.');
$rules = ['version' => 'required|integer|min:0', 'contact_text' => 'nullable|string|max:2000', 'observations' => 'nullable|string|max:2000'];
foreach (['show_logo','show_contacts','show_observations','show_payment_instructions'] as $field) { $rules[$field] = 'required|boolean'; }
$data = $request->validate($rules); $data['version'] = (int) $data['version'];
foreach (['show_logo','show_contacts','show_observations','show_payment_instructions'] as $field) { $data[$field] = (bool) $data[$field]; }
$data['contact_text'] ??= ''; $data['observations'] ??= '';
return $this->response($preview->configure($request->user()->id, $data));
}
public function draft(Request $request, int $id, DocumentPreview $preview) { return $this->response($preview->read($id, $request->user()->id)); }
public function historical(Request $request, int $id, DocumentPreview $preview) { return $this->response($preview->read($id, $request->user()->id, true)); }
public function revision(Request $request, string $mode, int $id, DocumentPreview $preview)
{
abort_unless(in_array($mode, ['draft','history'], true), 404);
$result = $preview->read($id, $request->user()->id, $mode === 'history');
return $this->response(['revision' => $result['revision']]);
}
}
@@ -21,7 +21,9 @@ final class InternalConfirmation
if (! $row) { return null; }
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id');
$event = DB::table('a_events as e')->join('a_outbox as o', 'e.id', '=', 'o.event_id')->where('history_id', $history)->first(['e.id','e.type','o.state']);
return ['id' => $row->id, 'internal_reference' => 'INT-B1-'.$row->id, 'label' => self::LABEL, 'snapshot' => json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR), 'event' => $event];
$snapshot = json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR);
if (isset($snapshot['document_preparation'])) { foreach (['issuer','client','billing_drafts'] as $kind) { app(DocumentPreparation::class)->scope($kind, $actor, 'view'); } }
return ['id' => $row->id, 'internal_reference' => 'INT-B1-'.$row->id, 'label' => self::LABEL, 'snapshot' => $snapshot, 'event' => $event];
}
public function confirm(int $draft, int $actor, int $version, string $key): array
@@ -30,7 +32,7 @@ final class InternalConfirmation
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']);
abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.');
$hash = hash('sha256', json_encode(['b1.confirm_internal', $draft, $scope, $version], JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($scope, $draft, $actor, $version, $key, $hash): array {
try { return DB::transaction(function () use ($scope, $draft, $actor, $version, $key, $hash): array {
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'], $scope['unit_id']);
$this->workflow->scope('billing_drafts', $actor, 'view');
@@ -64,17 +66,19 @@ final class InternalConfirmation
$snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source),
'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data),
'pending' => $pending, 'actor_id' => $actor, 'confirmed_at' => now()->toIso8601String(), 'accounting_status' => 'blocked_pending', 'fiscal_status' => 'not_issued'];
$preparation = app(DocumentPreparation::class)->snapshot($draft, $actor);
if ($preparation !== null) { $snapshot['schema_version'] = 2; $snapshot['document_preparation'] = $preparation; }
$id = DB::table('b_internal_confirmations')->insertGetId($scope + ['billing_draft_id' => $draft, 'billing_version' => $version, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]);
$history = DB::table('a_history')->insertGetId($scope + ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]);
foreach ($pending as $code => $message) { DB::table('a_pending')->insert(['history_id' => $history, 'code' => $code, 'message' => $message, 'created_at' => now()]); }
$event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => 'b1.billing_draft.confirmed_internal', 'schema_version' => 1, 'payload' => json_encode(['confirmation_id' => $id, 'snapshot' => $snapshot], JSON_THROW_ON_ERROR), 'created_at' => now()]);
$event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => 'b1.billing_draft.confirmed_internal', 'schema_version' => $preparation !== null ? 2 : 1, 'payload' => json_encode(['confirmation_id' => $id, 'snapshot' => $snapshot], JSON_THROW_ON_ERROR), 'created_at' => now()]);
DB::table('a_outbox')->insert(['event_id' => $event, 'state' => 'captured', 'created_at' => now()]);
Audit::record('b1.billing_draft.confirmed_internal', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'event_id' => $event]);
$result = ['id' => $id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$id, 'event_id' => $event];
}
DB::table('a_commands')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'actor_id' => $actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
return $result + ['replayed' => isset($existing) && $existing !== null];
});
}); } catch (\Illuminate\Database\QueryException) { throw new \Symfony\Component\HttpKernel\Exception\HttpException(500, 'Não foi possível capturar a confirmação. Nenhum dado bancário foi incluído no erro.'); }
}
private function record(object $record): array
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
final class ServiceCalculation
{
public const POLICY = 'SGF-SERVICE-NET-VAT-TRUNCATE2-v1';
public const SOURCES = ['InvesteMoz/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md@v1.2', 'Lei22/2022-republicada:17(1)', 'CIVA:15,7,8,17(2)'];
public function __construct(private ServiceWorkflow $workflow, private DocumentPreparation $preparation, private CompanyContext $context) {}
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
public static function evaluate(array $data, array $prepared, ?array $profile): array
{
$pending = [];
if (count($data['lines'] ?? []) !== 1) { $pending[] = 'Apenas uma linha de serviço é suportada.'; }
if (($prepared['price_mode'] ?? null) !== 'added') { $pending[] = 'É necessário preço sem imposto (acrescido); incluído fica fora deste âmbito.'; }
if (!preg_match('/^[A-Z]{3}$/D', $data['currency'] ?? '')) { $pending[] = 'Moeda explícita em falta.'; }
if (!$profile || ($profile['kind'] ?? null) !== 'synthetic' || ($profile['policy'] ?? null) !== self::POLICY) { $pending[] = 'Perfil de ensaio e política identificados em falta ou incompatíveis.'; }
if (!preg_match('/^\d{1,3}(\.\d{1,8})?$/D', $profile['rate_percent'] ?? '') || BigDecimal::of($profile['rate_percent'] ?? '0')->isLessThanOrEqualTo(0)) { $pending[] = 'Taxa positiva identificada em falta.'; }
if (($profile['currency'] ?? null) !== ($data['currency'] ?? null)) { $pending[] = 'Moeda incompatível com o perfil.'; }
$line = $data['lines'][0] ?? [];
foreach (['quantity','unit_price'] as $field) {
if (!is_string($line[$field] ?? null) || !preg_match('/^\d{1,16}(\.\d{1,8})?$/D', $line[$field])) { $pending[] = 'Quantidade/preço ausente ou fora dos limites exactos de A.'; }
}
if (!$pending && BigDecimal::of($line['quantity'])->isLessThanOrEqualTo(0)) { $pending[] = 'Quantidade deve ser positiva.'; }
if ($pending) { return ['state' => 'pending', 'pending' => array_values(array_unique($pending)), 'policy' => self::POLICY]; }
$base = BigDecimal::of($line['quantity'])->multipliedBy($line['unit_price']);
$exact = $base->multipliedBy($profile['rate_percent'])->multipliedBy('0.01');
$document = $exact->toScale(2, RoundingMode::DOWN);
$representable = $base->stripTrailingZeros()->getScale() <= 2;
return ['state' => $representable ? 'synthetic_proposed' : 'synthetic_base_representation_pending', 'policy' => self::POLICY,
'base_exact' => (string) $base->stripTrailingZeros(), 'vat_exact' => (string) $exact->stripTrailingZeros(),
'vat_document_proposed' => (string) $document, 'discarded_difference' => (string) $exact->minus($document)->stripTrailingZeros(),
'total_document_proposed' => $representable ? (string) $base->plus($document)->toScale(2) : null,
'currency' => $data['currency'], 'pending' => $representable ? [] : ['Política de representação da base/total pendente; base não truncada.'],
'fiscally_validated' => false, 'policy_basis' => 'Decisão operacional do utilizador; não obrigação legal comprovada.'];
}
private function sources(int $draft, int $actor): array
{
$billing = $this->workflow->show('billing_drafts', $draft, $actor);
abort_if($billing['internal_confirmation'], 409, 'Fotografia B1 preservada; cálculo não altera confirmações.');
$prepared = $this->preparation->show('billing_drafts', $draft, $actor);
return [$billing, $prepared];
}
private function input(array $b, array $p, object $profile): array
{
return ['draft_id' => (int) $b['id'], 'draft_version' => $b['version'], 'preparation_version' => $p['version'],
'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id,
'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier,
'profile' => json_decode($profile->definition, true, 32, JSON_THROW_ON_ERROR), 'policy' => self::POLICY,
'source_current_version' => (int) DB::table('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
}
private function fingerprint(array $input): string { return hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); }
public function calculate(int $draft, int $actor, array $command): array
{
// Test-only command is deliberately not a fiscal permission or real profile activation path.
abort_unless(self::isolated(), 403, 'Cálculo de ensaio restrito ao ambiente isolado de testes.');
TestingDatabaseGuard::assertSafe();
$scope = $this->workflow->scope('billing_drafts', $actor, 'edit');
return DB::transaction(function () use ($draft,$actor,$command,$scope) {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$this->workflow->scope('billing_drafts', $actor, 'edit');
[$b,$p] = $this->sources($draft,$actor);
abort_unless($b['version'] === $command['draft_version'] && $p['version'] === $command['preparation_version'], 409, 'Versões desactualizadas.');
abort_unless(($p['data']['draft_version'] ?? null) === $b['version'], 409, 'Reveja a preparação após alterar o rascunho.');
$profile = DB::table('service_calculation_profiles')->where($scope)->where('id', $command['profile_id'])->first();
abort_unless($profile, 403, 'Perfil fora do âmbito autorizado.');
$latest = DB::table('service_calculation_profiles')->where($scope)->where('identifier', $profile->identifier)->max('version');
abort_unless((int) $latest === (int) $profile->version && (int) $profile->version === $command['profile_version'], 409, 'Perfil desactualizado.');
$input = $this->input($b,$p,$profile);
abort_unless($input['source_current_version'] === $b['data']['source_version'], 409, 'Prestação de origem desactualizada.');
$result = self::evaluate($b['data'],$p['data'],$input['profile']);
abort_if($result['state'] === 'pending', 422, implode(' ', $result['pending']));
$hash = $this->fingerprint($input);
$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($scope + ['billing_draft_id'=>$draft, 'profile_id'=>$profile->id, 'actor_id'=>$actor,
'fingerprint'=>$hash, 'snapshot'=>json_encode(['input'=>$input,'result'=>$result],JSON_THROW_ON_ERROR), 'created_at'=>now()]);
Audit::record('service_calculation.synthetic_created',$actor,$scope['tenant_id'],$scope['company_id'],$scope['unit_id'],details:['result_id'=>$id,'policy'=>self::POLICY]);
return ['id'=>$id,'replayed'=>false];
});
}
public function read(int $draft, int $actor): array
{
$scope = $this->workflow->scope('billing_drafts',$actor,'view');
if (!self::isolated()) { return ['environment'=>'normal','history'=>[], 'pending'=>['Aplicabilidade à empresa, serviço/localização, base e acontecimento fiscal por demonstrar; nenhum perfil real activado.']]; }
TestingDatabaseGuard::assertSafe();
[$b,$p] = $this->sources($draft,$actor);
$profiles = DB::table('service_calculation_profiles')->where($scope)->orderByDesc('version')->get();
$latest = []; foreach ($profiles as $profile) { $latest[$profile->identifier] ??= $profile; }
$history = [];
foreach (DB::table('service_calculation_results')->where($scope)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $row) {
$snapshot = json_decode($row->snapshot,true,32,JSON_THROW_ON_ERROR);
$profile = $latest[$snapshot['input']['profile_identifier']] ?? null;
$current = $profile && hash_equals($row->fingerprint,$this->fingerprint($this->input($b,$p,$profile)));
$history[] = ['id'=>$row->id,'current'=>(bool)$current,'snapshot'=>$snapshot];
}
return ['environment'=>'synthetic_test_only','policy'=>self::POLICY,'history'=>$history,
'can_calculate'=>app(\App\Modules\Identity\Services\Permissions::class)->has($actor,$scope['tenant_id'],$scope['company_id'],'billing_drafts.edit',$scope['unit_id']),
'profiles'=>array_map(fn ($r)=>['id'=>$r->id,'version'=>(int)$r->version,'identifier'=>$r->identifier],array_values($latest)),
'draft_version'=>$b['version'],'preparation_version'=>$p['version'],
'pending'=>['ENSAIO SINTÉTICO — não valida regime, base tributável, data fiscal ou emissão da empresa real.']];
}
}
+1
View File
@@ -94,6 +94,7 @@ final class ServiceWorkflow
if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); }
else { $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]); }
$record = $this->query($area, $scope)->where('id', $id)->first();
if ($area === 'billing_drafts' && ! $old) { app(DocumentPreparation::class)->inheritToBilling((int) $record->source_id, $id, $actor, $scope); }
$snapshot = (array) $record; $snapshot['data'] = $data;
$pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : [];
if ($area === 'billing_drafts') {