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())- @foreach($errors->all() as $error)
- {{ $error }} @endforeach
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 + + + 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))Não existem mapeamentos aprovados para esta empresa. Nenhum é escolhido automaticamente.
@endif| Referência | Documento | Estado | Versão |
|---|---|---|---|
| {{ $r->reference }} | {{ $r->number }} | {{ $r->phase }} | {{ $r->version }} |
| Sem rascunhos neste filtro. | |||
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())Estado: {{ $row['phase'] }} · versão {{ $row['version'] }} · fonte {{ $row['source_version'] }}
+Fundamento: {{ $row['reason'] }}
@include('sgf-invoices.evidence',['value'=>$row['document']]) +@if($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 +Transporte desligado. O rascunho está conservado; não há envio automático.
@else + +@endif +@if($row['impact']){{ $e->reason }}