Files
compras/app/Services/SgfInvoiceDispatches.php
T
nala 976271f948
tests / ci (push) Canceled after 0s
commit melhorias1
2026-11-10 12:58:19 +02:00

187 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\{User,Supplier,Invoice};
use Illuminate\Support\Facades\{Crypt,DB,Schema,Validator};
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Durable source intent and audit. No native invoice, payment, budget or stock writer. */
final class SgfInvoiceDispatches
{
public function actor(User $actor,bool $locked=false):User
{
abort_unless(config('sgf_invoice.workspace_enabled')===true,404);
abort_unless(auth()->id()===$actor->id&&$actor->tenant_id!==null,403);
if($locked)app(ConflictGuard::class)->lockCompany($actor);
$q=User::where('tenant_id',$actor->tenant_id)->whereKey($actor->id);if($locked)$q->lockForUpdate();$user=$q->firstOrFail();
abort_unless($user->hasFunctions(['finance'])&&!app(OrganizationAccess::class)->maintenanceOnly($user),403);
abort_unless(Schema::hasTable('sgf_invoice_dispatches')&&Schema::hasTable('sgf_invoice_dispatch_events'),503,'Estrutura de envio ainda não instalada.');
return $user;
}
public static function assertLocalRegistration(int $tenant,int $supplier,string $number):void
{
// Keep the claim even if the workspace/transport is later disabled.
if(Schema::hasTable('sgf_invoice_dispatches')&&DB::table('sgf_invoice_dispatches')->where(['tenant_id'=>$tenant,'supplier_id'=>$supplier,'number'=>$number])->exists())
throw ValidationException::withMessages(['invoice'=>'Documento reservado ao envio SGF; consultar o recibo, sem segundo registo local.']);
}
public function mappings(User $actor):array
{
$this->actor($actor);$out=[];
foreach(config('sgf_invoice.connections',[])as$c=>$connection)if(($connection['source_scope']['tenant_id']??null)===$actor->tenant_id)
foreach($connection['mappings']??[]as$m=>$entry)if(($entry['approved']??false)===true)$out[]=['connection'=>$c,'id'=>$m,'label'=>$entry['label']??$m,'mapping'=>$entry['mapping']];
return $out;
}
private function mapping(User $actor,string $connection,string $id):array
{
$all=config('sgf_invoice.connections',[]);$c=$all[$connection]??null;$m=$c['mappings'][$id]??null;
abort_unless(is_array($c)&&($c['source_scope']['tenant_id']??null)===$actor->tenant_id&&is_array($m)&&($m['approved']??false)===true,403);
abort_if(array_diff(array_keys($m),['approved','label','source_supplier_id','mapping','approved_by','approved_at']),503,'Configuração de mapeamento inválida.');
abort_unless(($m['mapping']['source_scope']['tenant_id']??null)===$actor->tenant_id&&($m['mapping']['authority']??null)==='sgf_only',403);
abort_unless(is_int($m['source_supplier_id']??null)&&Supplier::where('tenant_id',$actor->tenant_id)->whereKey($m['source_supplier_id'])->exists(),403);
return $m;
}
private function checkSource(object $row,User $actor):array
{
$m=$this->mapping($actor,$row->connection_id,$row->mapping_id);
abort_unless(hash_equals($row->mapping_hash,self::hash($m)),409,'O mapeamento mudou; exige nova conferência.');
abort_if(Invoice::where('tenant_id',$actor->tenant_id)->where('supplier_id',$row->supplier_id)->where('number',$row->number)->exists(),409,'Documento já registado no SGC. Não reenviar.');
return $m;
}
private function validated(array $input):array
{
$d=Validator::make($input,['reference'=>'required|string|min:2|max:120','connection_id'=>'required|string|max:64','mapping_id'=>'required|string|max:64','reason'=>'required|string|min:10|max:2000',
'document'=>'required|array:number,issued_on,subtotal,tax,total,items','document.number'=>'required|string|max:100','document.issued_on'=>'required|date_format:Y-m-d',
'document.subtotal'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D','document.tax'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D','document.total'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
'document.items'=>'required|array|min:1|max:50','document.items.*'=>'array:source_line_ref,quantity,unit,unit_price,tax_rate',
'document.items.*.source_line_ref'=>'required|string|max:120|distinct','document.items.*.quantity'=>'required|string|regex:/^\d{1,9}(\.\d{1,3})?$/D',
'document.items.*.unit'=>'required|string|max:20','document.items.*.unit_price'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D','document.items.*.tax_rate'=>'required|string|regex:/^\d{1,3}(\.\d{1,2})?$/D'])->validate();
abort_if(array_diff(array_keys($input),array_keys($d)),422);
return $d;
}
public function create(User $actor,array $input):int
{
$d=$this->validated($input);
return DB::transaction(function()use($actor,$d){
$user=$this->actor($actor,true);$m=$this->mapping($user,$d['connection_id'],$d['mapping_id']);$doc=$d['document'];$doc['number']=Str::upper(Str::squish($doc['number']));
$ids=[];foreach($doc['items']as$item){$id=$m['mapping']['lines'][$item['source_line_ref']]??null;abort_unless(is_int($id)&&$id>0&&!in_array($id,$ids,true),422,'Linha sem mapeamento explícito.');$ids[]=$id;}
abort_if(Invoice::where('tenant_id',$user->tenant_id)->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])->exists(),409,'Documento já registado no SGC.');
abort_if(DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->where(fn($q)=>$q->where('reference',$d['reference'])->orWhere(fn($q)=>$q->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])))->exists(),409,'Já existe uma intenção para esta fonte.');
$id=DB::table('sgf_invoice_dispatches')->insertGetId(['tenant_id'=>$user->tenant_id,'supplier_id'=>$m['source_supplier_id'],'reference'=>$d['reference'],'number'=>$doc['number'],
'connection_id'=>$d['connection_id'],'mapping_id'=>$d['mapping_id'],'mapping_snapshot'=>json_encode($m,JSON_THROW_ON_ERROR),'mapping_hash'=>self::hash($m),
'document'=>json_encode($doc,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_by'=>$user->id,'created_at'=>now('UTC'),'updated_at'=>now('UTC')]);
$row=$this->row($user,$id);$this->event($row,$user,'draft.created',$d['reason'],null);return $id;
});
}
public function revise(User $actor,int $id,int $version,array $input):object
{
$d=$this->validated($input);
return DB::transaction(function()use($actor,$id,$version,$d){
$user=$this->actor($actor,true);$row=$this->row($user,$id,true);
abort_unless((int)$row->version===$version&&!$row->ever_sending&&$row->phase!=='received_pending'
&&(!$row->lease_until||now('UTC')->gte($row->lease_until)),409,'Fonte já enviada/em curso ou versão alterada.');
abort_unless($d['reference']===$row->reference,422,'Referência de origem imutável.');
$m=$this->mapping($user,$d['connection_id'],$d['mapping_id']);$doc=$d['document'];$doc['number']=Str::upper(Str::squish($doc['number']));
$used=[];foreach($doc['items']as$item){$line=$m['mapping']['lines'][$item['source_line_ref']]??null;abort_unless(is_int($line)&&!in_array($line,$used,true),422);$used[]=$line;}
abort_if(Invoice::where('tenant_id',$user->tenant_id)->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])->exists()
||DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])->where('id','<>',$id)->exists(),409,'Documento já reservado/registado.');
return $this->update($row,$user,['supplier_id'=>$m['source_supplier_id'],'number'=>$doc['number'],'connection_id'=>$d['connection_id'],'mapping_id'=>$d['mapping_id'],
'mapping_snapshot'=>json_encode($m,JSON_THROW_ON_ERROR),'mapping_hash'=>self::hash($m),'document'=>json_encode($doc,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],
'source_version'=>$row->source_version+1,'phase'=>'draft','operation_key'=>null,'state_ciphertext'=>null,'approval_hash'=>null,'lease_id'=>null,'lease_until'=>null], 'draft.revised',$d['reason']);
});
}
public function row(User $actor,int $id,bool $locked=false):object
{
$q=DB::table('sgf_invoice_dispatches')->where('tenant_id',$actor->tenant_id)->where('id',$id);if($locked)$q->lockForUpdate();
$row=$q->first();abort_unless($row,404);return $row;
}
private function summary(object $row):array
{
$state=$this->state($row);
return ['id'=>$row->id,'reference'=>$row->reference,'number'=>$row->number,'version'=>(int)$row->version,'source_version'=>(int)$row->source_version,
'phase'=>$row->phase,'mapping_hash'=>$row->mapping_hash,'document_hash'=>self::hash(json_decode($row->document,true)),
'document'=>json_decode($row->document,true),'mapping_snapshot'=>json_decode($row->mapping_snapshot,true),'reason'=>$row->reason,
'operation_key'=>$row->operation_key,'approval_hash'=>$row->approval_hash,'ever_sending'=>(bool)$row->ever_sending,
'intent_hash'=>$state['intent']['intent_hash']??null,'impact_hash'=>isset($state['impact'])?self::hash($state['impact']):null,
'preview_actor_id'=>$row->preview_actor_id,'source_context_hash'=>$row->source_context_hash,
'receipt'=>$row->receipt?json_decode($row->receipt,true):null];
}
private function event(object $row,User $actor,string $action,string $reason,?array $before):void
{
DB::table('sgf_invoice_dispatch_events')->insert(['dispatch_id'=>$row->id,'tenant_id'=>$row->tenant_id,'version'=>$row->version,'actor_id'=>$actor->id,
'action'=>$action,'reason'=>$reason,'before_state'=>$before?json_encode($before,JSON_THROW_ON_ERROR):null,'after_state'=>json_encode($this->summary($row),JSON_THROW_ON_ERROR),'created_at'=>now('UTC')]);
}
private function update(object $row,User $actor,array $changes,string $action,string $reason):object
{
$before=$this->summary($row);$changes+=['version'=>$row->version+1,'updated_at'=>now('UTC')];
DB::table('sgf_invoice_dispatches')->where('id',$row->id)->where('tenant_id',$row->tenant_id)->update($changes);
$next=$this->row($actor,$row->id);$this->event($next,$actor,$action,$reason,$before);return $next;
}
private function state(object $row):?array{return $row->state_ciphertext?json_decode(Crypt::decryptString($row->state_ciphertext),true,128,JSON_THROW_ON_ERROR):null;}
private function contextHash(User $actor):string
{
return self::hash([$actor->tenant_id,request()->hasSession()?request()->session()->get('work_context'):null]);
}
public function dispatch(User $actor,int $id,int $version,string $action,string $reason,?string $approval):object
{
abort_unless(in_array($action,['preview','confirm','recover'],true),422);
abort_unless(config('sgf_invoice.enabled')===true,423,'Transporte desligado; o rascunho permanece conservado.');
Validator::make(['reason'=>$reason],['reason'=>'required|string|min:10|max:2000'])->validate();
// Commit the intent/decision/lease BEFORE network. Neither DB locks nor an HTTP retry may silently duplicate a decision.
[$row,$client,$state,$m,$lease]=DB::transaction(function()use($actor,$id,$version,$action,$reason,$approval){
$user=$this->actor($actor,true);$row=$this->row($user,$id,true);abort_unless((int)$row->version===$version,409,'Versão desactualizada.');
abort_unless(!$row->lease_until||now('UTC')->gte($row->lease_until),409,'Operação em curso; aguarde e consulte o estado.');
abort_if($row->phase==='received_pending',409,'Recibo já conservado.');$m=$this->checkSource($row,$user);
$client=app(SgfInvoiceGateway::class)->clientFor($user,$row->connection_id);$state=$this->state($row);
if($state){
$expected=$client->intent($m['mapping'],['reference'=>$row->reference,'version'=>(string)$row->source_version,'state'=>'unregistered_draft','document'=>json_decode($row->document,true)],$row->operation_key,$row->reason);
if(($state['intent']['intent_hash']??null)!==$expected['intent_hash']){
abort_unless($action==='preview',409,'Ligação ou contexto mudaram; exige nova conferência.');
$state=['intent'=>$expected,'phase'=>'draft'];
}
}
$lease=(string)Str::uuid();$changes=['lease_id'=>$lease,'lease_until'=>now('UTC')->addSeconds(120),'phase'=>$action==='preview'?'previewing':'sending'];
if($action==='preview'){
$changes+=['preview_actor_id'=>$user->id,'source_context_hash'=>$this->contextHash($user)];
if(!$state){$key=(string)Str::uuid();$intent=$client->intent($m['mapping'],['reference'=>$row->reference,'version'=>(string)$row->source_version,'state'=>'unregistered_draft','document'=>json_decode($row->document,true)],$key,$row->reason);$state=['intent'=>$intent,'phase'=>'draft'];$changes['operation_key']=$key;}
}elseif($action==='confirm'){
abort_unless($row->phase==='previewed'&&$state&&is_string($approval)&&hash_equals($state['approval_hash']??'',$approval),409,'Aprovar individualmente a prévia actual.');
abort_unless((int)$row->preview_actor_id===$user->id&&$row->source_context_hash===$this->contextHash($user),409,'Operador ou contexto mudaram; conferir novamente.');
$changes+=['ever_sending'=>true,'approval_hash'=>$approval];
}else{
abort_unless((bool)$row->ever_sending&&in_array($row->phase,['sending','uncertain','blocked','review_required','not_sent'],true)&&$state&&$row->approval_hash,409,'Sem confirmação anterior para recuperar.');
$state['phase']='uncertain';$approval=$row->approval_hash;
}
$changes['state_ciphertext']=Crypt::encryptString(json_encode($state,JSON_THROW_ON_ERROR));
$row=$this->update($row,$user,$changes,$action==='confirm'?'decision.confirmed':($action==='recover'?'recovery.started':'preview.started'),$reason);
return [$row,$client,$state,$m,$lease];
});
try{
if($action==='preview')$result=array_replace($state,$client->preview($state['intent'],(string)$row->source_version,\Tempo\SgcInvoiceClient\InvoiceClient::mappingHash($m['mapping'])));
else $result=$client->confirm($state,$action==='confirm'?$approval:$row->approval_hash,(string)$row->source_version,\Tempo\SgcInvoiceClient\InvoiceClient::mappingHash($m['mapping']));
}catch(\Throwable){$result=array_replace($state,['phase'=>$action==='preview'?'not_sent':'uncertain','error'=>'DELIVERY_NOT_CONFIRMED']);}
return DB::transaction(function()use($actor,$id,$lease,$result,$reason){
app(ConflictGuard::class)->lockCompany($actor);$current=$this->row($actor,$id,true);
abort_unless($current->lease_id===$lease,409,'Operação recuperada por outra sessão; actualizar.');
$allowed=['previewed','received_pending','uncertain','blocked','review_required','not_sent'];
$phase=in_array($result['phase']??null,$allowed,true)?$result['phase']:'uncertain';
return $this->update($current,$actor,['phase'=>$phase,'lease_id'=>null,'lease_until'=>null,
'state_ciphertext'=>Crypt::encryptString(json_encode($result,JSON_THROW_ON_ERROR)),
'receipt'=>isset($result['receipt'])?json_encode($result['receipt'],JSON_THROW_ON_ERROR):null], 'transport.observed',$reason);
});
}
public function presentation(User $actor,object $row):array
{
$this->actor($actor);$state=$this->state($row);$out=$this->summary($row);
return array_replace($out,['connection_id'=>$row->connection_id,'mapping_id'=>$row->mapping_id,'document'=>json_decode($row->document,true),'reason'=>$row->reason,
'impact'=>$state['impact']??null,'approval_hash'=>$row->phase==='previewed'?($state['approval_hash']??null):null,
'lease_until'=>$row->lease_until]);
}
public static function hash(mixed $v):string
{
$normalise=function($x)use(&$normalise){if(!is_array($x))return $x;if(!array_is_list($x))ksort($x,SORT_STRING);foreach($x as&$item)$item=$normalise($item);return $x;};
return hash('sha256',json_encode($normalise($v),JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
}
}