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)); } }