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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.']];
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user