diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 649efa9..52c4709 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -34,6 +34,7 @@ class InvoiceController extends Controller 'invoices' => Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusOrder !== null, fn ($q) => $q->where('purchase_order_id', $focusOrder))->with('items')->latest('id')->paginate(15)->withQueryString() ->through(fn (Invoice $invoice) => [...$invoice->toArray(), 'canValidate' => app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)]), 'orders' => PurchaseOrder::query()->visibleTo($actor)->operational()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), + 'sgfDispatchAvailable' => config('sgf_invoice.workspace_enabled')===true && $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($actor, 'view'), 'canManage' => $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor)])->toResponse($request); $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); diff --git a/app/Http/Controllers/SgfInvoiceDispatchController.php b/app/Http/Controllers/SgfInvoiceDispatchController.php new file mode 100644 index 0000000..dea775f --- /dev/null +++ b/app/Http/Controllers/SgfInvoiceDispatchController.php @@ -0,0 +1,47 @@ +user('web');abort_unless($user instanceof User,403);return $service->actor($user); } + public function index(Request $r,SgfInvoiceDispatches $service) + { + $user=$this->actor($r,$service);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','phase'=>'nullable|in:draft,previewing,previewed,sending,uncertain,received_pending,blocked,review_required,not_sent','connection'=>'nullable|string|max:64','mapping'=>'nullable|string|max:64']); + abort_if(array_diff(array_keys($r->query()),array_keys($d)),422); + $rows=DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->when($d['phase']??null,fn($q,$v)=>$q->where('phase',$v)) + ->orderByDesc('id')->paginate(25,['id','reference','number','phase','version','updated_at'])->withQueryString(); + $mapping=null;foreach($service->mappings($user)as$m)if($m['connection']===($d['connection']??null)&&$m['id']===($d['mapping']??null))$mapping=$m; + return response()->view('sgf-invoices.index',['rows'=>$rows,'options'=>$service->mappings($user),'selection'=>$mapping])->header('Cache-Control','private, no-store'); + } + public function show(int $id,Request $r,SgfInvoiceDispatches $service) + { + $user=$this->actor($r,$service);$d=$r->validate(['events_page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422); + $row=$service->row($user,$id);$events=DB::table('sgf_invoice_dispatch_events')->where('tenant_id',$user->tenant_id)->where('dispatch_id',$id)->orderByDesc('id') + ->paginate(25,['actor_id','action','reason','before_state','after_state','created_at'],'events_page')->withQueryString(); + return response()->view('sgf-invoices.show',['row'=>$service->presentation($user,$row),'events'=>$events,'options'=>$service->mappings($user),'transportEnabled'=>config('sgf_invoice.enabled')===true])->header('Cache-Control','private, no-store'); + } + public function create(Request $r,SgfInvoiceDispatches $service) + { + $user=$this->actor($r,$service);$input=$r->except('_token');$id=$service->create($user,$input); + return redirect('/facturas/envio-sgf/'.$id); + } + public function revise(int $id,Request $r,SgfInvoiceDispatches $service) + { + $user=$this->actor($r,$service);$d=$r->validate(['version'=>'required|integer|min:1']); + $service->revise($user,$id,(int)$d['version'],$r->except('_token','version')); + return redirect('/facturas/envio-sgf/'.$id); + } + public function dispatch(int $id,Request $r,SgfInvoiceDispatches $service) + { + $user=$this->actor($r,$service);$d=$r->validate(['version'=>'required|integer|min:1','action'=>'required|in:preview,confirm,recover','reason'=>'required|string|min:10|max:2000','approval_hash'=>'nullable|string|size:64']); + abort_if(array_diff(array_keys($r->except('_token')),array_keys($d)),422); + $service->dispatch($user,$id,(int)$d['version'],$d['action'],$d['reason'],$d['approval_hash']??null); + return redirect('/facturas/envio-sgf/'.$id); + } +} diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php index ea3d745..f0a50ed 100644 --- a/app/Services/InvoiceWorkflow.php +++ b/app/Services/InvoiceWorkflow.php @@ -105,6 +105,7 @@ class InvoiceWorkflow $request = Requisition::query()->findOrFail($order->requisition_id); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user); $number = Str::upper(Str::squish($data['number'])); + SgfInvoiceDispatches::assertLocalRegistration($user->tenant_id,$order->supplier_id,$number); if ($number === '' || Invoice::query()->where('supplier_id', $order->supplier_id)->where('number', $number)->exists()) { $this->invalid('Número de factura vazio ou já registado para este fornecedor.'); } diff --git a/app/Services/SgfInvoiceDispatches.php b/app/Services/SgfInvoiceDispatches.php new file mode 100644 index 0000000..b103042 --- /dev/null +++ b/app/Services/SgfInvoiceDispatches.php @@ -0,0 +1,186 @@ +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)); + } +} diff --git a/app/Services/SgfInvoiceGateway.php b/app/Services/SgfInvoiceGateway.php new file mode 100644 index 0000000..fc57c91 --- /dev/null +++ b/app/Services/SgfInvoiceGateway.php @@ -0,0 +1,40 @@ +id()===$actor->id,403); + $current=User::where('tenant_id',$actor->tenant_id)->findOrFail($actor->id); + abort_unless(($c['source_scope']['tenant_id']??null)===$current->tenant_id&&$current->hasFunctions(['finance']) + &&!app(OrganizationAccess::class)->maintenanceOnly($current),403); + $path=config('sgf_invoice.client_package');$pin=config('sgf_invoice.client_package_sha256'); + if(!is_string($path)||is_link($path)||!is_file($path)||!is_string($pin)||!preg_match('/^[a-f0-9]{64}$/D',$pin) + ||!hash_equals($pin,hash_file('sha256',$path)))throw new LogicException('Verified client package required.'); + if(class_exists(\Tempo\SgcInvoiceClient\InvoiceClient::class,false)){ + if(!hash_equals($pin,hash_file('sha256',(new \ReflectionClass(\Tempo\SgcInvoiceClient\InvoiceClient::class))->getFileName())))throw new LogicException('Client version changed; restart separately.'); + }else require_once $path; + $configurationHash=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR)); + return new \Tempo\SgcInvoiceClient\InvoiceClient($c['client_id'],$c['token'],$c['context_version'],$c['source_scope'],$c['target_scope'], + function(string $method,string $url,array $headers,?array $body)use($actor,$connection,$configurationHash):array{ + // Recheck the originating person's authority immediately before every network operation. + $now=config('sgf_invoice.connections',[])[$connection]??null; + if(config('sgf_invoice.enabled')!==true||!is_array($now)||!hash_equals($configurationHash,hash('sha256',json_encode($now,JSON_THROW_ON_ERROR))) + ||auth()->id()!==$actor->id)return ['status'=>403,'json'=>null]; + $current=User::where('tenant_id',$actor->tenant_id)->find($actor->id); + if(!$current||!$current->hasFunctions(['finance'])||app(OrganizationAccess::class)->maintenanceOnly($current))return ['status'=>403,'json'=>null]; + $http=Http::withHeaders($headers)->acceptJson()->asJson()->withoutRedirecting()->connectTimeout(3)->timeout(15); + $response=$method==='GET'?$http->get($url):$http->post($url,$body); + return ['status'=>$response->status(),'json'=>$response->json()]; + },$c['origin']); + } +} diff --git a/config/sgf_invoice.php b/config/sgf_invoice.php new file mode 100644 index 0000000..9f5dd83 --- /dev/null +++ b/config/sgf_invoice.php @@ -0,0 +1,10 @@ + false, + 'workspace_enabled' => false, + 'client_package' => null, + 'client_package_sha256' => null, + // Explicit, separately approved source tenant ->8085 principal/target scope. No automatic mappings. + 'connections' => [], +]; diff --git a/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php b/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php new file mode 100644 index 0000000..957e870 --- /dev/null +++ b/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php @@ -0,0 +1,46 @@ +id();$t->foreignId('tenant_id')->constrained('tenants')->restrictOnDelete(); + $t->foreignId('supplier_id')->constrained('suppliers')->restrictOnDelete(); + $t->string('reference',120);$t->string('number',100);$t->string('connection_id',64);$t->string('mapping_id',64); + $t->json('mapping_snapshot');$t->char('mapping_hash',64);$t->json('document');$t->text('reason'); + $t->unsignedInteger('source_version')->default(1);$t->unsignedInteger('version')->default(1); + $t->string('phase',32)->default('draft');$t->boolean('ever_sending')->default(false); + $t->text('state_ciphertext')->nullable();$t->char('approval_hash',64)->nullable(); + $t->foreignId('preview_actor_id')->nullable()->constrained('users')->restrictOnDelete();$t->char('source_context_hash',64)->nullable(); + $t->uuid('operation_key')->nullable()->unique();$t->uuid('lease_id')->nullable();$t->timestamp('lease_until')->nullable(); + $t->json('receipt')->nullable();$t->foreignId('created_by')->constrained('users')->restrictOnDelete();$t->timestamps(); + $t->unique(['tenant_id','reference']);$t->unique(['tenant_id','supplier_id','number'],'sgf_dispatch_document_unique'); + $t->index(['tenant_id','id']);$t->index(['tenant_id','phase','id']); + }); + Schema::create('sgf_invoice_dispatch_events',function(Blueprint $t){ + $t->id();$t->foreignId('dispatch_id')->constrained('sgf_invoice_dispatches')->restrictOnDelete(); + $t->foreignId('tenant_id')->constrained('tenants')->restrictOnDelete();$t->unsignedInteger('version'); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete();$t->string('action',40);$t->text('reason'); + $t->json('before_state')->nullable();$t->json('after_state');$t->timestamp('created_at'); + $t->unique(['dispatch_id','version']);$t->index(['tenant_id','dispatch_id','id']); + }); + if(DB::getDriverName()==='pgsql'){ + DB::unprepared("CREATE FUNCTION sgf_dispatch_preserve() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve SGF dispatch audit/history'; END; \$\$;"); + DB::unprepared('CREATE TRIGGER sgf_dispatch_event_immutable BEFORE UPDATE OR DELETE ON sgf_invoice_dispatch_events FOR EACH ROW EXECUTE FUNCTION sgf_dispatch_preserve()'); + DB::unprepared('CREATE TRIGGER sgf_dispatch_no_delete BEFORE DELETE ON sgf_invoice_dispatches FOR EACH ROW EXECUTE FUNCTION sgf_dispatch_preserve()'); + }elseif(DB::getDriverName()==='sqlite'){ + foreach(['UPDATE','DELETE']as$action)DB::unprepared("CREATE TRIGGER sgf_dispatch_events_".strtolower($action)." BEFORE $action ON sgf_invoice_dispatch_events BEGIN SELECT RAISE(ABORT,'Preserve SGF dispatch audit'); END"); + DB::unprepared("CREATE TRIGGER sgf_dispatch_no_delete BEFORE DELETE ON sgf_invoice_dispatches BEGIN SELECT RAISE(ABORT,'Preserve SGF dispatch history'); END"); + }else throw new LogicException('SGF dispatch schema validated only for PostgreSQL/SQLite.'); + } + public function down():void + { + Schema::dropIfExists('sgf_invoice_dispatch_events');Schema::dropIfExists('sgf_invoice_dispatches'); + if(DB::getDriverName()==='pgsql')DB::unprepared('DROP FUNCTION IF EXISTS sgf_dispatch_preserve()'); + } +}; diff --git a/docs/sgf-invoice-preparation.md b/docs/sgf-invoice-preparation.md new file mode 100644 index 0000000..a85584c --- /dev/null +++ b/docs/sgf-invoice-preparation.md @@ -0,0 +1,11 @@ +# Preparação do envio de rascunhos de factura para SGF8085 + +Continuação autorizada: [ecrã/persistência auditados entregues](sgf-invoice-workspace.md). O texto abaixo conserva a preparação anterior; essas funcionalidades já foram implementadas, mas instalação e activação continuam pendentes. + +10/11/2026. Gateway de transporte preparado, **desligado** e sem integração automática no workflow actual. `config/sgf_invoice.php`: `enabled=false`, pacote/hashNULL e ligações vazias. Nenhuma conta, segredo, concessão, migração, listener, job ou rota de envio foi criada/activada; o piloto e os workflows existentes não foram executados. + +`app/Services/SgfInvoiceGateway.php` prepara um cliente portátil verificado por SHA256, com autenticação/empresa/função financeira/limites de manutenção revalidados antes de cada operação. Transporte Laravel sem redirects/retry automático, timeout15s e ligação3s. O pacote proposto está em `C:/laragon/www/sgf-tempo/packages/sgc-invoice-client/src/InvoiceClient.php`; não foi instalado ou configurado no SGC. + +O contrato aceita somente rascunho recebido **ainda não registado** e autoridade documental exclusiva SGF. Não reenviar modelos `Invoice` pendentes/validados/pagos nem transformar a dívida do SGC noutra responsabilidade em8085. A ligação de um rascunho autorizado à intenção durável, conferência/decisão e recibo necessita do ecrã/persistência próprios antes de utilização; não está implementada como fluxo do utilizador nesta preparação. Não há simulação de que a aprovação ocorreu por chamar uma função. + +[Contrato completo, pacote inactivo, provas e passos pendentes](C:/laragon/www/sgf-tempo/docs/SGC_8085_INTEGRATION_PREPARATION.md). Reutiliza o mesmo motor do SGF; não cria outro escritor de facturas, orçamento, stock, obrigações, pagamentos ou contabilidade. Documentos históricos e futuras contas/configurações reais continuam sujeitos a decisões explícitas. diff --git a/docs/sgf-invoice-workspace.md b/docs/sgf-invoice-workspace.md new file mode 100644 index 0000000..f80009a --- /dev/null +++ b/docs/sgf-invoice-workspace.md @@ -0,0 +1,11 @@ +# Rascunhos e envio auditado ao SGF + +10/11/2026. Ecrã `/facturas/envio-sgf`, serviço `SgfInvoiceDispatches` e migração `2026_11_10_000001_create_sgf_invoice_dispatches.php` implementados e provados, **sem instalação/activação no piloto**. `workspace_enabled=false`, transporte desligado e ligações vazias. A rota nativa de facturas só oferece o atalho quando a capacidade estiver autorizada/activa. + +Percurso: escolha de mapeamento explícito → rascunho versionado → prévia → decisão individual conservada antes da rede → factura pendente/recibo → recuperação da mesma intenção em falha. Reserva empresa/fornecedor/número bloqueia duplicação pelo writer nativo mesmo se a capacidade for desligada. Não se envia `Invoice` já registada, não se valida financeiramente a factura nem se cria dívida/pagamento/stock/orçamento paralelo. + +Duas tabelas novas guardam fonte/intenção/estado cifrado e eventos append-only com autor/data UTC/fundamento/antes/depois. Instalação da migração tem de ser exacta e aprovada; não executar migrations pendentes por inferência. Ficheiros/caches/fontes do piloto não foram limpos, migrados ou activados. Nenhum acesso real/MFA/política nativa foi alterado. + +20 casos únicos/142 asserções em SQLite em memória;14 verificações PostgreSQL entre duas aplicações/livros de regressão existentes, sem criar demonstrações. A concorrência produz uma decisão/envio e409 na segunda; resposta perdida após commit recupera o mesmo recibo. Tipos e compilação TSX privada passaram, Blade/HTTP testados. Visual, TCP em8085 e implantação não foram declarados. + +[Entrega completa, prova, pacote inactivo e passos de instalação/configuração](C:/laragon/www/sgf-tempo/docs/SGC_INVOICE_WORKSPACE_DELIVERY.md). A preparação de transporte anterior continua histórica; ecrã/persistência já não são lacunas deste bloco. Usar somente após aprovação da instalação/configuração/activação, sem criar outra demonstração. diff --git a/resources/js/pages/invoices/index.tsx b/resources/js/pages/invoices/index.tsx index 5daf8d5..f3b3ce5 100644 --- a/resources/js/pages/invoices/index.tsx +++ b/resources/js/pages/invoices/index.tsx @@ -50,6 +50,7 @@ export default function Invoices({ orders, canManage, payablesAvailable = false, + sgfDispatchAvailable = false, stockReturns = [], stockReversals = [], focusInvoiceId = null, @@ -61,6 +62,7 @@ export default function Invoices({ focusInvoiceId?: number | null; focusOrderId?: number | null; payablesAvailable?: boolean; + sgfDispatchAvailable?: boolean; stockReturns?: { reference: string; quantity_milli: number; @@ -116,6 +118,11 @@ export default function Invoices({ title="Facturas e conferência" description="Conferência da ordem, recepção aceite e factura. Validação por outra pessoa de Finanças." /> + {sgfDispatchAvailable && ( + + Rascunhos para conferência e envio ao SGF + + )}

Desenvolvimento. Não autoriza pagamentos. A validação exige compromisso orçamental aprovado. Diferenças, notas de diff --git a/resources/views/sgf-invoices/document-form.blade.php b/resources/views/sgf-invoices/document-form.blade.php new file mode 100644 index 0000000..03f04c5 --- /dev/null +++ b/resources/views/sgf-invoices/document-form.blade.php @@ -0,0 +1,16 @@ +@php($doc=old('document',$draft['document']??[])) + + +

Mapeamento escolhido: {{ $selection['label'] }}; ordem SGF #{{ $selection['mapping']['order_id'] }}; versão {{ $selection['mapping']['version'] }}.

+ + +@foreach(['subtotal'=>'Base MZN','tax'=>'Imposto documental MZN','total'=>'Total MZN'] as $key=>$label)@endforeach +
Linhas da fonte — referências explicitamente mapeadas

Introduzir as linhas recebidas. Não se convertem unidades ou preços automaticamente.

+@php($items=$doc['items']??[['source_line_ref'=>'','quantity'=>'','unit'=>'','unit_price'=>'','tax_rate'=>'']]) +
@foreach($items as $i=>$item)
Linha {{ $loop->iteration }} + +@foreach(['quantity'=>'Quantidade','unit'=>'Unidade','unit_price'=>'Preço unitário','tax_rate'=>'Taxa documental %'] as $key=>$label)@endforeach +
@endforeach
+
+ + diff --git a/resources/views/sgf-invoices/evidence.blade.php b/resources/views/sgf-invoices/evidence.blade.php new file mode 100644 index 0000000..f963240 --- /dev/null +++ b/resources/views/sgf-invoices/evidence.blade.php @@ -0,0 +1,2 @@ +@if(is_array($value))
@foreach($value as $key=>$item)
{{ ['operation'=>'Operação','invoice'=>'Factura','number'=>'Número','items'=>'Linhas','quantity_milli'=>'Quantidade (milésimos)','subtotal_minor'=>'Base (centésimos)','tax_minor'=>'Imposto (centésimos)','total_minor'=>'Total (centésimos)','budget_consumption_minor'=>'Consumo orçamental (centésimos)','payable_created'=>'Obrigação criada','payment_created'=>'Pagamento criado','ledger_posted'=>'Lançamento criado','reason'=>'Fundamento','actor_id'=>'Autor','phase'=>'Estado','version'=>'Versão','document'=>'Documento','mapping_snapshot'=>'Mapeamento','receipt'=>'Recibo','before_state'=>'Antes','after_state'=>'Depois'][$key]??$key }}
@include('sgf-invoices.evidence',['value'=>$item])
@endforeach
+@elseif(is_bool($value)){{ $value?'Sim':'Não' }}@elseif($value===null)Não indicado@else{{ $value }}@endif diff --git a/resources/views/sgf-invoices/index.blade.php b/resources/views/sgf-invoices/index.blade.php new file mode 100644 index 0000000..1828f1d --- /dev/null +++ b/resources/views/sgf-invoices/index.blade.php @@ -0,0 +1,9 @@ +@extends('sgf-invoices.layout') +@section('content') +

Escolher ligação e mapeamento aprovados

+@if(!$options)

Não existem mapeamentos aprovados para esta empresa. Nenhum é escolhido automaticamente.

@endif
+@if($selection)

Guardar novo rascunho

@csrf @include('sgf-invoices.document-form')
@endif +

Rascunhos e resultados

+
@forelse($rows as $r)@empty@endforelse
ReferênciaDocumentoEstadoVersão
{{ $r->reference }}{{ $r->number }}{{ $r->phase }}{{ $r->version }}
Sem rascunhos neste filtro.
{{ $rows->links() }}
+ +@endsection diff --git a/resources/views/sgf-invoices/layout.blade.php b/resources/views/sgf-invoices/layout.blade.php new file mode 100644 index 0000000..43b4fc5 --- /dev/null +++ b/resources/views/sgf-invoices/layout.blade.php @@ -0,0 +1,7 @@ +SGC · Envio de facturas ao SGF +
+ +

Rascunhos de factura para o SGF

Só documentos ainda não registados no SGC. O envio cria uma factura pendente no SGF; não é validação, obrigação ou pagamento.

+@if($errors->any())@endif +@yield('content') +
diff --git a/resources/views/sgf-invoices/show.blade.php b/resources/views/sgf-invoices/show.blade.php new file mode 100644 index 0000000..6349c1e --- /dev/null +++ b/resources/views/sgf-invoices/show.blade.php @@ -0,0 +1,23 @@ +@extends('sgf-invoices.layout') +@section('content') +

{{ $row['reference'] }} · {{ $row['number'] }}

Estado: {{ $row['phase'] }} · versão {{ $row['version'] }} · fonte {{ $row['source_version'] }}

+

Fundamento: {{ $row['reason'] }}

@include('sgf-invoices.evidence',['value'=>$row['document']]) +@if($row['receipt'])

Recibo conservado

@include('sgf-invoices.evidence',['value'=>$row['receipt']])

A factura está pendente no SGF. A revisão financeira é uma decisão distinta.

+@php($continue='http://127.0.0.1:8085/compras?'.http_build_query(['return_to'=>'/purchasing/invoices?'.http_build_query(['order'=>$row['receipt']['order_id'],'invoice'=>$row['receipt']['invoice_id']])])) +Conferir a factura no SGF após escolher a função autorizada@endif +@if($row['lease_until'])

Operação iniciada. Actualizar esta página para consultar o resultado; não criar outro rascunho.

@endif +
+@if(!$row['ever_sending']) +@php($selection=collect($options)->first(fn($m)=>$m['connection']===$row['connection_id']&&$m['id']===$row['mapping_id'])) +@if($selection)
Rever campos antes de enviar
@csrf@include('sgf-invoices.document-form',['draft'=>$row])
@endif +@endif +@if(!$row['receipt'])

Conferência individual

+@if(!$transportEnabled)

Transporte desligado. O rascunho está conservado; não há envio automático.

@else +
@csrf
+@endif +@if($row['impact'])

Impacto devolvido pelo SGF

@include('sgf-invoices.evidence',['value'=>$row['impact']])@endif +@if($transportEnabled&&$row['phase']==='previewed')
@csrf

Confirmar conserva a decisão e envia esta intenção. Não aprova financeiramente a factura.

@endif +@if($transportEnabled&&$row['ever_sending']&&in_array($row['phase'],['sending','uncertain','blocked','review_required','not_sent']))
@csrf
@endif +
@endif +

Histórico auditado

@foreach($events as $e)
{{ $e->created_at }} UTC · {{ $e->action }} · autor #{{ $e->actor_id }}

{{ $e->reason }}

Antes

@include('sgf-invoices.evidence',['value'=>$e->before_state?json_decode($e->before_state,true):null])

Depois

@include('sgf-invoices.evidence',['value'=>json_decode($e->after_state,true)])
@endforeach{{ $events->links() }}
+@endsection diff --git a/routes/web.php b/routes/web.php index 27f57b3..a9756eb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -105,6 +105,11 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group( Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store'); Route::middleware([EnsureCompanyIsConfigured::class, EnsureWorkContext::class])->group(function () { + Route::get('facturas/envio-sgf', [\App\Http\Controllers\SgfInvoiceDispatchController::class,'index']); + Route::post('facturas/envio-sgf', [\App\Http\Controllers\SgfInvoiceDispatchController::class,'create']); + Route::get('facturas/envio-sgf/{id}', [\App\Http\Controllers\SgfInvoiceDispatchController::class,'show'])->whereNumber('id'); + Route::post('facturas/envio-sgf/{id}/rever', [\App\Http\Controllers\SgfInvoiceDispatchController::class,'revise'])->whereNumber('id'); + Route::post('facturas/envio-sgf/{id}/operar', [\App\Http\Controllers\SgfInvoiceDispatchController::class,'dispatch'])->whereNumber('id'); Route::get('administracao/divisao-administrativa', [AdministrativeGeographyController::class, 'review']); Route::get('administracao/divisao-administrativa.csv', [AdministrativeGeographyController::class, 'exportReview']); Route::post('stock/armazens/{warehouse}/localizacao', [StockController::class, 'warehouseGeography'])->whereNumber('warehouse'); diff --git a/tests/Integration/SgfInvoiceDispatchesTest.php b/tests/Integration/SgfInvoiceDispatchesTest.php new file mode 100644 index 0000000..848da8e --- /dev/null +++ b/tests/Integration/SgfInvoiceDispatchesTest.php @@ -0,0 +1,218 @@ +privateRoot='C:/laragon/data/sgf-tempo/run/sgc-dispatch-tests-'.bin2hex(random_bytes(6)); + foreach(['logs','framework/views','framework/sessions','framework/cache/data']as$d)mkdir($this->privateRoot.'/storage/'.$d,0700,true); + foreach(['DB_CONNECTION'=>'sqlite','DB_DATABASE'=>':memory:','DB_URL'=>'','APP_ENV'=>'testing', + 'APP_CONFIG_CACHE'=>$this->privateRoot.'/config.php','APP_SERVICES_CACHE'=>$this->privateRoot.'/services.php','APP_PACKAGES_CACHE'=>$this->privateRoot.'/packages.php', + 'APP_ROUTES_CACHE'=>$this->privateRoot.'/routes.php','SESSION_DRIVER'=>'array','CACHE_STORE'=>'array']as$k=>$v){putenv($k.'='.$v);$_ENV[$k]=$v;} + $app=require $repo.'/bootstrap/app.php';$app->addAbsoluteCachePathPrefix('C:');$app->useStoragePath($this->privateRoot.'/storage'); + $this->traitsUsedByTest=class_uses_recursive(static::class);$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();return $app; + } + protected function setUp():void + { + parent::setUp(); + self::assertSame('sqlite',DB::getDriverName());self::assertSame(':memory:',DB::connection()->getDatabaseName()); + config(['app.key'=>'base64:'.base64_encode(str_repeat('x',32)),'sgf_invoice.workspace_enabled'=>true,'sgf_invoice.enabled'=>true]); + Schema::create('tenants',fn(Blueprint $t)=>[$t->id(),$t->string('name'),$t->json('work_context_policy')->nullable()]); + Schema::create('users',function(Blueprint $t){$t->id();$t->unsignedBigInteger('tenant_id');$t->string('purchasing_role');$t->string('name');$t->string('email');$t->string('password');$t->timestamp('email_verified_at')->nullable();$t->rememberToken();$t->timestamps();}); + Schema::create('suppliers',fn(Blueprint $t)=>[$t->id(),$t->unsignedBigInteger('tenant_id'),$t->string('name')]); + Schema::create('invoices',fn(Blueprint $t)=>[$t->id(),$t->unsignedBigInteger('tenant_id'),$t->unsignedBigInteger('supplier_id'),$t->string('number')]); + DB::table('tenants')->insert([['id'=>1,'name'=>'QA source'],['id'=>2,'name'=>'Foreign QA']]); + foreach([1=>[1,'finance'],2=>[2,'finance'],3=>[1,'warehouse'],4=>[1,'finance']]as$id=>[$tenant,$role])DB::table('users')->insert(['id'=>$id,'tenant_id'=>$tenant,'purchasing_role'=>$role,'name'=>'QA '.$id,'email'=>'qa'.$id.'@example.test','password'=>'unused','email_verified_at'=>now(),'created_at'=>now(),'updated_at'=>now()]); + DB::table('suppliers')->insert([['id'=>1,'tenant_id'=>1,'name'=>'QA Supplier'],['id'=>2,'tenant_id'=>2,'name'=>'Foreign']]); + $migration=require dirname(__DIR__,2).'/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php';$migration->up(); + $this->actor=User::findOrFail(1);$this->actingAs($this->actor);$this->service=app(SgfInvoiceDispatches::class); + $package='C:/laragon/www/sgf-tempo/packages/sgc-invoice-client/src/InvoiceClient.php'; + config(['sgf_invoice.client_package'=>$package,'sgf_invoice.client_package_sha256'=>hash_file('sha256',$package), + 'sgf_invoice.connections'=>['qa'=>['enabled'=>true,'source_scope'=>['tenant_id'=>1],'target_scope'=>['tenant_id'=>1,'company_id'=>2,'unit_id'=>2], + 'client_id'=>'qa','token'=>str_repeat('q',64),'context_version'=>str_repeat('a',64),'origin'=>'http://127.0.0.1:8085','mappings'=>['map'=>[ + 'approved'=>true,'label'=>'Escolha QA','source_supplier_id'=>1,'mapping'=>['id'=>'qa-map','version'=>1,'source_scope'=>['tenant_id'=>1], + 'target_scope'=>['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],'authority'=>'sgf_only','order_id'=>7,'lines'=>['QA-LINE'=>8]]]]]]]); + Http::preventStrayRequests();Http::fake(function(Outbound $r){ + if($r->method()==='GET')return Http::response(['order'=>['id'=>7],'revision'=>str_repeat('b',64)]); + $key=$r->header('X-Idempotency-Key')[0]; + if($r['phase']==='preview')return Http::response(['preview'=>'QA-encrypted-'.$key,'impact'=>['operation'=>'Registar pendente','budget_consumption_minor'=>0,'payable_created'=>false]]); + $replay=isset($this->remote[$key]);$this->remote[$key]??=40+count($this->remote); + if($this->lose){$this->lose=false;throw new \Illuminate\Http\Client\ConnectionException('Synthetic lost response');} + return Http::response(['invoice_id'=>$this->remote[$key],'order_id'=>7,'status'=>'pending','replayed'=>$replay],$replay?200:201); + }); + } + private function data(string $ref='QA-SOURCE-1'):array + { + return ['reference'=>$ref,'connection_id'=>'qa','mapping_id'=>'map','reason'=>'Conferência documental exclusivamente sintética.','document'=>[ + 'number'=>$ref,'issued_on'=>'2026-11-10','subtotal'=>'100.00','tax'=>'0.00','total'=>'100.00','items'=>[ + ['source_line_ref'=>'QA-LINE','quantity'=>'1.000','unit'=>'UN','unit_price'=>'100.00','tax_rate'=>'0.00']]]]; + } + private function prepared():object + { + $id=$this->service->create($this->actor,$this->data());return $this->service->dispatch($this->actor,$id,1,'preview','Conferir impacto documental actual.',null); + } + private function approval(object $row):string{return $this->service->presentation($this->actor,$row)['approval_hash'];} + public function test_full_durable_cycle_and_audited_decision():void + { + $row=$this->prepared();self::assertSame('previewed',$row->phase);self::assertSame(3,$row->version); + $approved=$this->approval($row);self::assertIsString($approved);self::assertStringNotContainsString('QA-encrypted',$row->state_ciphertext); + $done=$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Aceitar individualmente este impacto.',$approved); + self::assertSame('received_pending',$done->phase);self::assertNotNull($done->receipt);self::assertCount(1,$this->remote); + self::assertSame(0,DB::table('invoices')->count()); + $events=DB::table('sgf_invoice_dispatch_events')->orderBy('id')->get();self::assertCount(5,$events); + self::assertSame('decision.confirmed',$events[3]->action);self::assertSame(1,$events[3]->actor_id);self::assertNotNull($events[3]->before_state); + self::assertSame($approved,json_decode($events[3]->after_state,true)['approval_hash']); + } + public function test_lost_response_reuses_same_key_cut_and_durable_intent():void + { + $row=$this->prepared();$this->lose=true;$unknown=$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Aceitar e conservar decisão sintética.',$this->approval($row)); + self::assertSame('uncertain',$unknown->phase);$key=$unknown->operation_key; + $recovered=$this->service->dispatch($this->actor,$row->id,$unknown->version,'recover','Recuperar a mesma intenção sem duplicação.',null); + self::assertSame('received_pending',$recovered->phase);self::assertSame($key,$recovered->operation_key);self::assertCount(1,$this->remote); + self::assertTrue(json_decode($recovered->receipt,true)['replayed']); + } + public function test_failure_after_remote_commit_keeps_durable_sending_for_recovery():void + { + $row=$this->prepared();DB::unprepared("CREATE TRIGGER fail_observation BEFORE INSERT ON sgf_invoice_dispatch_events WHEN NEW.action='transport.observed' BEGIN SELECT RAISE(ABORT,'Synthetic persistence failure'); END"); + try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Confirmar decisão antes do transporte.',$this->approval($row));self::fail('Expected storage failure');}catch(\Illuminate\Database\QueryException){} + $pending=$this->service->row($this->actor,$row->id);self::assertSame('sending',$pending->phase);self::assertTrue((bool)$pending->ever_sending);self::assertCount(1,$this->remote); + DB::unprepared('DROP TRIGGER fail_observation');$this->travel(121)->seconds(); + $done=$this->service->dispatch($this->actor,$row->id,$pending->version,'recover','Recuperar após libertação do lease.',null); + self::assertSame('received_pending',$done->phase);self::assertCount(1,$this->remote); + } + public function test_revision_preserves_before_after_and_invalidates_preview():void + { + $row=$this->prepared();$data=$this->data();$data['document']['subtotal']='120.00';$data['document']['total']='120.00';$data['document']['items'][0]['unit_price']='120.00'; + $new=$this->service->revise($this->actor,$row->id,$row->version,$data);self::assertSame('draft',$new->phase);self::assertNull($new->operation_key);self::assertNull($new->state_ciphertext); + self::assertSame(2,$new->source_version);$e=DB::table('sgf_invoice_dispatch_events')->orderByDesc('id')->first(); + self::assertSame('100.00',json_decode($e->before_state,true)['document']['total']);self::assertSame('120.00',json_decode($e->after_state,true)['document']['total']); + } + public function test_no_edit_after_any_confirmation_started():void + { + $row=$this->prepared();$this->lose=true;$row=$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Confirmar individualmente o documento.',$this->approval($row)); + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);$this->service->revise($this->actor,$row->id,$row->version,$this->data()); + } + public function test_local_registration_is_blocked_even_when_feature_is_disabled():void + { + $this->service->create($this->actor,$this->data());config(['sgf_invoice.workspace_enabled'=>false]); + $this->expectException(\Illuminate\Validation\ValidationException::class);SgfInvoiceDispatches::assertLocalRegistration(1,1,'QA-SOURCE-1'); + } + public function test_existing_local_invoice_cannot_be_sent():void + { + DB::table('invoices')->insert(['tenant_id'=>1,'supplier_id'=>1,'number'=>'QA-SOURCE-1']); + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);$this->service->create($this->actor,$this->data()); + } + public function test_duplicate_source_cannot_get_second_intent():void + { + $this->service->create($this->actor,$this->data());$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);$this->service->create($this->actor,$this->data()); + } + public function test_stale_version_and_wrong_approval_do_not_send():void + { + $row=$this->prepared();foreach([1=>str_repeat('0',64),$row->version=>str_repeat('0',64)]as$version=>$approval){ + try{$this->service->dispatch($this->actor,$row->id,$version,'confirm','Fundamento de confirmação explícita.',$approval);self::fail('Expected409');} + catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());}} + self::assertCount(0,$this->remote); + } + public function test_operator_context_and_mapping_changes_require_new_conference():void + { + $row=$this->prepared();$approval=$this->approval($row);$this->actingAs(User::find(4)); + try{$this->service->dispatch(User::find(4),$row->id,$row->version,'confirm','Outro operador deve conferir novamente.',$approval);self::fail('Expected409');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());} + $this->actingAs($this->actor);config(['sgf_invoice.connections.qa.mappings.map.mapping.version'=>2]); + try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Mapeamento mudou; não deve enviar.',$approval);self::fail('Expected409');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());} + self::assertCount(0,$this->remote); + } + public function test_http_screen_csrf_and_audited_history_are_private():void + { + $r=$this->post('/facturas/envio-sgf',$this->data());$r->assertRedirect('/facturas/envio-sgf/1'); + $this->get('/facturas/envio-sgf/1')->assertOk()->assertSee('QA-SOURCE-1')->assertSee('Histórico auditado')->assertHeader('Cache-Control','no-store, private'); + $this->get('/facturas/envio-sgf')->assertOk()->assertSee('Escolher explicitamente')->assertDontSee('Guardar novo rascunho'); + $this->get('/facturas/envio-sgf?connection=qa&mapping=map')->assertOk()->assertSee('Guardar novo rascunho'); + $this->app->instance('env','local');try{$this->post('/facturas/envio-sgf',$this->data('CSRF-FAIL'))->assertStatus(419);}finally{$this->app->instance('env','testing');} + self::assertSame(1,DB::table('sgf_invoice_dispatches')->count()); + } + public function test_foreign_tenant_and_warehouse_actor_cannot_read_or_write():void + { + $id=$this->service->create($this->actor,$this->data());$this->actingAs(User::find(2));$this->get('/facturas/envio-sgf/'.$id)->assertNotFound(); + $this->post('/facturas/envio-sgf',$this->data('FOREIGN'))->assertForbidden(); + $this->actingAs(User::find(3));$this->get('/facturas/envio-sgf')->assertForbidden(); + self::assertSame(1,DB::table('sgf_invoice_dispatches')->count()); + } + public function test_pagination_history_and_output_escaping():void + { + for($i=0;$i<27;$i++)$this->service->create($this->actor,$this->data('QA-LIST-'.$i)); + $this->get('/facturas/envio-sgf?page=2')->assertOk()->assertSee('QA-LIST-0')->assertDontSee('QA-LIST-26'); + $row=$this->service->row($this->actor,1);for($i=0;$i<27;$i++)$row=$this->service->revise($this->actor,1,$row->version,$this->data('QA-LIST-0')); + $this->get('/facturas/envio-sgf/1?events_page=2')->assertOk()->assertSee('draft.created'); + $data=$this->data('XSS');$data['document']['number']='';$id=$this->service->create($this->actor,$data); + $this->get('/facturas/envio-sgf/'.$id)->assertOk()->assertDontSee('',false)->assertSee('<SCRIPT>',false); + } + public function test_events_and_drafts_cannot_be_deleted_or_rewritten():void + { + $id=$this->service->create($this->actor,$this->data()); + foreach([fn()=>DB::table('sgf_invoice_dispatch_events')->update(['action'=>'tampered']),fn()=>DB::table('sgf_invoice_dispatch_events')->delete(),fn()=>DB::table('sgf_invoice_dispatches')->delete()]as$mutate){ + try{$mutate();self::fail('Expected immutable audit');}catch(\Illuminate\Database\QueryException){self::assertSame(1,DB::table('sgf_invoice_dispatch_events')->count());}} + } + public function test_disabled_transport_keeps_draft_and_disabled_workspace_refuses():void + { + $id=$this->service->create($this->actor,$this->data());config(['sgf_invoice.enabled'=>false]); + $this->post('/facturas/envio-sgf/'.$id.'/operar',['version'=>1,'action'=>'preview','reason'=>'Não activar o transporte nesta entrega.'])->assertStatus(423); + self::assertNull($this->service->row($this->actor,$id)->operation_key);Http::assertNothingSent(); + config(['sgf_invoice.workspace_enabled'=>false]);$this->get('/facturas/envio-sgf')->assertNotFound(); + } + public function test_changed_remote_context_refuses_decision_before_recording_it():void + { + $row=$this->prepared();$hash=$this->approval($row);$events=DB::table('sgf_invoice_dispatch_events')->count(); + config(['sgf_invoice.connections.qa.context_version'=>str_repeat('c',64)]); + try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Contexto remoto mudou; não confirmar.',$hash);self::fail('Expected409');} + catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());} + self::assertSame($events,DB::table('sgf_invoice_dispatch_events')->count());self::assertCount(0,$this->remote); + $new=$this->service->dispatch($this->actor,$row->id,$row->version,'preview','Conferir novamente o contexto actual.',null); + self::assertSame('previewed',$new->phase);self::assertSame($row->operation_key,$new->operation_key);self::assertNotSame($hash,$this->approval($new)); + } + public function test_revoked_operator_cannot_confirm_and_live_lease_prevents_duplicate_send():void + { + $row=$this->prepared();$hash=$this->approval($row); + DB::table('users')->where('id',1)->update(['purchasing_role'=>'warehouse']); + try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Não enviar com autoridade revogada.',$hash);self::fail('Expected403');} + catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(403,$e->getStatusCode());} + DB::table('users')->where('id',1)->update(['purchasing_role'=>'finance']); + DB::table('sgf_invoice_dispatches')->where('id',$row->id)->update(['lease_until'=>now('UTC')->addSeconds(120)]); + try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Uma operação em curso não duplica.',$hash);self::fail('Expected409');} + catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());} + self::assertCount(0,$this->remote); + } + public function test_actual_screen_forms_complete_registration_and_keep_exact_sgf_handoff():void + { + $this->post('/facturas/envio-sgf',$this->data())->assertRedirect('/facturas/envio-sgf/1'); + $this->post('/facturas/envio-sgf/1/operar',['version'=>1,'action'=>'preview','reason'=>'Conferência individual pela interface.'])->assertRedirect('/facturas/envio-sgf/1'); + $html=$this->get('/facturas/envio-sgf/1')->assertOk()->assertSee('Impacto devolvido pelo SGF')->assertSee('Confirmar individualmente')->assertDontSee('QA-encrypted',false)->getContent(); + preg_match('/name="approval_hash" value="([a-f0-9]{64})"/',$html,$match);self::assertNotEmpty($match); + file_put_contents($this->privateRoot.'/screen-preview.html',$html); + $this->post('/facturas/envio-sgf/1/operar',['version'=>3,'action'=>'confirm','reason'=>'Confirmar apenas este impacto mostrado.','approval_hash'=>$match[1]])->assertRedirect('/facturas/envio-sgf/1'); + $done=$this->get('/facturas/envio-sgf/1')->assertOk()->assertSee('Recibo conservado')->assertDontSee('Confirmar individualmente')->assertSee('http://127.0.0.1:8085/compras?',false)->getContent(); + file_put_contents($this->privateRoot.'/screen-receipt.html',$done);self::assertCount(1,$this->remote); + } + public function test_missing_schema_is_refused_without_activating_or_migrating():void + { + $migration=require dirname(__DIR__,2).'/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php';$migration->down(); + $this->get('/facturas/envio-sgf')->assertStatus(503);Http::assertNothingSent();self::assertFalse(Schema::hasTable('sgf_invoice_dispatches')); + } + public function test_audit_freezes_preview_actor_and_context_fingerprint():void + { + $row=$this->prepared();$event=DB::table('sgf_invoice_dispatch_events')->orderByDesc('id')->first();$state=json_decode($event->after_state,true); + self::assertSame(1,$state['preview_actor_id']);self::assertSame($row->source_context_hash,$state['source_context_hash']); + } +}