commit melhorias1
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-10 12:58:19 +02:00
parent 63d20b10fc
commit 976271f948
17 changed files with 640 additions and 0 deletions
@@ -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');
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\SgfInvoiceDispatches;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class SgfInvoiceDispatchController extends Controller
{
private function actor(Request $r,SgfInvoiceDispatches $service):User
{ $user=$r->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);
}
}
+1
View File
@@ -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.');
}
+186
View File
@@ -0,0 +1,186 @@
<?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));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use LogicException;
/** Prepared gateway only; no event listener, route, scheduled job or automatic transmission. */
final class SgfInvoiceGateway
{
public function clientFor(User $actor,string $connection): \Tempo\SgcInvoiceClient\InvoiceClient
{
if(config('sgf_invoice.enabled')!==true)throw new LogicException('SGF invoice gateway disabled.');
$all=config('sgf_invoice.connections',[]);$c=$all[$connection]??null;
abort_unless(is_array($c)&&($c['enabled']??false)===true&&auth()->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']);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'enabled' => false,
'workspace_enabled' => false,
'client_package' => null,
'client_package_sha256' => null,
// Explicit, separately approved source tenant ->8085 principal/target scope. No automatic mappings.
'connections' => [],
];
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\{DB,Schema};
return new class extends Migration {
public function up():void
{
if(!in_array(DB::getDriverName(),['pgsql','sqlite'],true))throw new LogicException('SGF dispatch schema requires PostgreSQL/SQLite.');
Schema::create('sgf_invoice_dispatches',function(Blueprint $t){
$t->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()');
}
};
+11
View File
@@ -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.
+11
View File
@@ -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.
+7
View File
@@ -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 && (
<a href="/facturas/envio-sgf" className="underline">
Rascunhos para conferência e envio ao SGF
</a>
)}
<p className="rounded-lg border border-amber-300 p-4 text-sm">
Desenvolvimento. Não autoriza pagamentos. A validação exige
compromisso orçamental aprovado. Diferenças, notas de
@@ -0,0 +1,16 @@
@php($doc=old('document',$draft['document']??[]))
<label>Referência de origem<input name="reference" value="{{ old('reference',$draft['reference']??'') }}" required maxlength="120" @readonly(isset($draft))></label>
<input type="hidden" name="connection_id" value="{{ $selection['connection'] }}"><input type="hidden" name="mapping_id" value="{{ $selection['id'] }}">
<p>Mapeamento escolhido: <strong>{{ $selection['label'] }}</strong>; ordem SGF #{{ $selection['mapping']['order_id'] }}; versão {{ $selection['mapping']['version'] }}.</p>
<label>Número do documento recebido<input name="document[number]" value="{{ $doc['number']??'' }}" required maxlength="100"></label>
<label>Data do documento<input type="date" name="document[issued_on]" value="{{ $doc['issued_on']??'' }}" required></label>
@foreach(['subtotal'=>'Base MZN','tax'=>'Imposto documental MZN','total'=>'Total MZN'] as $key=>$label)<label>{{ $label }}<input name="document[{{ $key }}]" value="{{ $doc[$key]??'' }}" required inputmode="decimal" pattern="[0-9]+\.[0-9]{2}" placeholder="0.00"></label>@endforeach
<fieldset><legend>Linhas da fonte — referências explicitamente mapeadas</legend><p>Introduzir as linhas recebidas. Não se convertem unidades ou preços automaticamente.</p>
@php($items=$doc['items']??[['source_line_ref'=>'','quantity'=>'','unit'=>'','unit_price'=>'','tax_rate'=>'']])
<div id="source-lines">@foreach($items as $i=>$item)<fieldset class="source-line"><legend>Linha {{ $loop->iteration }}</legend>
<label>Referência da linha<select name="document[items][{{ $i }}][source_line_ref]" required><option value="">Escolher</option>@foreach($selection['mapping']['lines'] as $ref=>$target)<option value="{{ $ref }}" @selected(($item['source_line_ref']??'')===$ref)>{{ $ref }} → linha SGF #{{ $target }}</option>@endforeach</select></label>
@foreach(['quantity'=>'Quantidade','unit'=>'Unidade','unit_price'=>'Preço unitário','tax_rate'=>'Taxa documental %'] as $key=>$label)<label>{{ $label }}<input name="document[items][{{ $i }}][{{ $key }}]" value="{{ $item[$key]??'' }}" required maxlength="30"></label>@endforeach
<button type="button" onclick="if(this.parentElement.parentElement.children.length>1)this.parentElement.remove()">Retirar linha</button></fieldset>@endforeach</div>
<button type="button" id="add-source-line">Acrescentar linha</button></fieldset>
<label>Fundamento<textarea name="reason" required minlength="10" maxlength="2000">{{ old('reason',$draft['reason']??'') }}</textarea></label>
<script>document.getElementById('add-source-line').addEventListener('click',()=>{const root=document.getElementById('source-lines');if(root.children.length>=50)return;let index=0;root.querySelectorAll('[name]').forEach(e=>{const m=e.name.match(/\[items\]\[(\d+)\]/);if(m)index=Math.max(index,Number(m[1])+1)});const next=root.firstElementChild.cloneNode(true);next.querySelector('legend').textContent='Nova linha';next.querySelectorAll('input,select').forEach(e=>{e.name=e.name.replace(/\[items\]\[\d+\]/,'[items]['+index+']');e.value=''});root.appendChild(next)});</script>
@@ -0,0 +1,2 @@
@if(is_array($value))<dl>@foreach($value as $key=>$item)<dt>{{ ['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 }}</dt><dd>@include('sgf-invoices.evidence',['value'=>$item])</dd>@endforeach</dl>
@elseif(is_bool($value)){{ $value?'Sim':'Não' }}@elseif($value===null)Não indicado@else{{ $value }}@endif
@@ -0,0 +1,9 @@
@extends('sgf-invoices.layout')
@section('content')
<section><h2>Escolher ligação e mapeamento aprovados</h2><form method="get"><label>Mapeamento<select id="mapping-choice" required><option value="">Escolher explicitamente</option>@foreach($options as $m)<option data-connection="{{ $m['connection'] }}" data-mapping="{{ $m['id'] }}" @selected($selection&&$selection['connection']===$m['connection']&&$selection['id']===$m['id'])>{{ $m['label'] }} · ordem #{{ $m['mapping']['order_id'] }}</option>@endforeach</select></label><input id="connection" type="hidden" name="connection"><input id="mapping" type="hidden" name="mapping"><button>Usar este mapeamento</button></form>
@if(!$options)<p>Não existem mapeamentos aprovados para esta empresa. Nenhum é escolhido automaticamente.</p>@endif</section>
@if($selection)<section><h2>Guardar novo rascunho</h2><form method="post" action="/facturas/envio-sgf">@csrf @include('sgf-invoices.document-form')<button>Guardar rascunho sem enviar</button></form></section>@endif
<section><h2>Rascunhos e resultados</h2><form method="get"><label>Estado<select name="phase"><option value="">Todos</option>@foreach(['draft'=>'Rascunho','previewed'=>'Conferido','sending'=>'Confirmação em curso','uncertain'=>'Resultado incerto','received_pending'=>'Recebido pendente','blocked'=>'Acesso bloqueado','review_required'=>'Nova conferência','not_sent'=>'Envio não confirmado','previewing'=>'Prévia em curso'] as $key=>$label)<option value="{{ $key }}" @selected(request('phase')===$key)>{{ $label }}</option>@endforeach</select></label><button>Filtrar</button></form>
<div class="scroll"><table><thead><tr><th>Referência</th><th>Documento</th><th>Estado</th><th>Versão</th></tr></thead><tbody>@forelse($rows as $r)<tr><td><a href="/facturas/envio-sgf/{{ $r->id }}">{{ $r->reference }}</a></td><td>{{ $r->number }}</td><td>{{ $r->phase }}</td><td>{{ $r->version }}</td></tr>@empty<tr><td colspan="4">Sem rascunhos neste filtro.</td></tr>@endforelse</tbody></table></div>{{ $rows->links() }}</section>
<script>const select=document.getElementById('mapping-choice');const fill=()=>{const o=select.selectedOptions[0];document.getElementById('connection').value=o?.dataset.connection??'';document.getElementById('mapping').value=o?.dataset.mapping??''};select.addEventListener('change',fill);fill();</script>
@endsection
@@ -0,0 +1,7 @@
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SGC · Envio de facturas ao SGF</title>
<style>body{font:16px/1.5 system-ui,sans-serif;color:#163934;background:#f3f6f5;margin:0}main{max-width:1100px;margin:auto;padding:24px}section,fieldset{background:white;border:1px solid #c5d5d0;border-radius:8px;padding:20px;margin:16px 0}h1,h2,h3{line-height:1.25}label{display:block;margin:12px 0}input,textarea,select,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}input,textarea,select{display:block;border:1px solid #69837a;border-radius:4px}textarea{width:100%;min-height:80px}button{background:#165c4c;color:white;border:0;border-radius:4px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}a{color:#165c4c}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:8px;border-bottom:1px solid #d3dfdb;vertical-align:top}dl{margin:8px 0}dt{font-weight:650}dd{margin:0 0 8px 16px;overflow-wrap:anywhere}.notice{padding:12px;background:#fff4d8}.error{color:#9d2323}.scroll{overflow:auto}small{color:#49665d}:focus-visible{outline:3px solid #bf6f13;outline-offset:3px}</style></head><body><main>
<nav><a href="/facturas">Facturas SGC</a> · <a href="/facturas/envio-sgf">Envio ao SGF</a> · <a href="/dashboard">Painel inicial</a></nav>
<h1>Rascunhos de factura para o SGF</h1><p class="notice">Só documentos ainda não registados no SGC. O envio cria uma factura pendente no SGF; não é validação, obrigação ou pagamento.</p>
@if($errors->any())<section class="error" role="alert"><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></section>@endif
@yield('content')
</main><script>let dirty=false,pending=false;document.addEventListener('input',e=>{if(e.isTrusted&&e.target.closest('form[method="post"]'))dirty=true});document.addEventListener('submit',e=>{if(e.target.method.toLowerCase()!=='post')return;pending=true;e.target.querySelectorAll('button').forEach(b=>b.disabled=true)});document.addEventListener('click',e=>{const a=e.target.closest('a');if(!a)return;if(pending){e.preventDefault();return}if(dirty&&!window.confirm('Sair e perder os campos ainda não guardados?'))e.preventDefault()});window.addEventListener('beforeunload',e=>{if(dirty&&!pending){e.preventDefault();e.returnValue=''}});</script></body></html>
@@ -0,0 +1,23 @@
@extends('sgf-invoices.layout')
@section('content')
<section><h2>{{ $row['reference'] }} · {{ $row['number'] }}</h2><p>Estado: <strong>{{ $row['phase'] }}</strong> · versão {{ $row['version'] }} · fonte {{ $row['source_version'] }}</p>
<p>Fundamento: {{ $row['reason'] }}</p>@include('sgf-invoices.evidence',['value'=>$row['document']])
@if($row['receipt'])<h3>Recibo conservado</h3>@include('sgf-invoices.evidence',['value'=>$row['receipt']])<p>A factura está pendente no SGF. A revisão financeira é uma decisão distinta.</p>
@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']])]))
<a href="{{ $continue }}">Conferir a factura no SGF após escolher a função autorizada</a>@endif
@if($row['lease_until'])<p role="status">Operação iniciada. Actualizar esta página para consultar o resultado; não criar outro rascunho.</p>@endif
</section>
@if(!$row['ever_sending'])
@php($selection=collect($options)->first(fn($m)=>$m['connection']===$row['connection_id']&&$m['id']===$row['mapping_id']))
@if($selection)<section><details><summary>Rever campos antes de enviar</summary><form method="post" action="/facturas/envio-sgf/{{ $row['id'] }}/rever">@csrf<input type="hidden" name="version" value="{{ $row['version'] }}">@include('sgf-invoices.document-form',['draft'=>$row])<button>Guardar nova versão e invalidar prévia</button></form></details></section>@endif
@endif
@if(!$row['receipt'])<section><h2>Conferência individual</h2>
@if(!$transportEnabled)<p class="notice">Transporte desligado. O rascunho está conservado; não há envio automático.</p>@else
<form method="post" action="/facturas/envio-sgf/{{ $row['id'] }}/operar">@csrf<input type="hidden" name="version" value="{{ $row['version'] }}"><input type="hidden" name="action" value="preview"><label>Fundamento da conferência<textarea name="reason" required minlength="10" maxlength="2000"></textarea></label><button>Obter prévia actual sem confirmar</button></form>
@endif
@if($row['impact'])<h3>Impacto devolvido pelo SGF</h3>@include('sgf-invoices.evidence',['value'=>$row['impact']])@endif
@if($transportEnabled&&$row['phase']==='previewed')<form method="post" action="/facturas/envio-sgf/{{ $row['id'] }}/operar">@csrf<input type="hidden" name="version" value="{{ $row['version'] }}"><input type="hidden" name="action" value="confirm"><input type="hidden" name="approval_hash" value="{{ $row['approval_hash'] }}"><label>Fundamento da decisão<textarea name="reason" required minlength="10" maxlength="2000"></textarea></label><p>Confirmar conserva a decisão e envia esta intenção. Não aprova financeiramente a factura.</p><button>Confirmar individualmente este impacto</button></form>@endif
@if($transportEnabled&&$row['ever_sending']&&in_array($row['phase'],['sending','uncertain','blocked','review_required','not_sent']))<form method="post" action="/facturas/envio-sgf/{{ $row['id'] }}/operar">@csrf<input type="hidden" name="version" value="{{ $row['version'] }}"><input type="hidden" name="action" value="recover"><label>Fundamento da recuperação<textarea name="reason" required minlength="10" maxlength="2000"></textarea></label><button>Recuperar a mesma intenção e chave</button></form>@endif
</section>@endif
<section><h2>Histórico auditado</h2>@foreach($events as $e)<details><summary>{{ $e->created_at }} UTC · {{ $e->action }} · autor #{{ $e->actor_id }}</summary><p>{{ $e->reason }}</p><h3>Antes</h3>@include('sgf-invoices.evidence',['value'=>$e->before_state?json_decode($e->before_state,true):null])<h3>Depois</h3>@include('sgf-invoices.evidence',['value'=>json_decode($e->after_state,true)])</details>@endforeach{{ $events->links() }}</section>
@endsection
+5
View File
@@ -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');
@@ -0,0 +1,218 @@
<?php
namespace Tests\Integration;
use App\Models\User;
use App\Services\{SgfInvoiceDispatches,InvoiceWorkflow};
use Illuminate\Support\Facades\{DB,Schema,Http};
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Client\Request as Outbound;
/** Only this schema and in-memory SQLite. Never RefreshDatabase or a pilot connection. */
final class SgfInvoiceDispatchesTest extends \Tests\TestCase
{
private User $actor;
private SgfInvoiceDispatches $service;
private array $remote=[];
private bool $lose=false;
private string $privateRoot;
public function createApplication()
{
$repo=dirname(__DIR__,2);$this->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']='<script>alert(1)</script>';$id=$this->service->create($this->actor,$data);
$this->get('/facturas/envio-sgf/'.$id)->assertOk()->assertDontSee('<script>alert(1)</script>',false)->assertSee('&lt;SCRIPT&gt;',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']);
}
}