commit de compras

This commit is contained in:
2026-11-04 09:37:20 +02:00
parent fcf31e8d48
commit 0b05a1192c
741 changed files with 114127 additions and 116 deletions
@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{PurchaseOrder,Invoice,AuditLog};
use Tempo\Purchasing\Embedded\Services\{InvoiceWorkflow,ReceiptWorkflow,RequisitionWorkflow,InventoryDispositions,InventoryReversals,CurrentActor,OrganizationAccess};
/** Transport around the original supplier-document matching and budget-consumption workflow. */
final class InvoiceController
{
private function guard():void{abort_unless(config('receipt_qa.invoices_enabled')===true,404);}
private function viewer(object $a):void{abort_unless($a->hasFunctions(['admin','finance']),403);}
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
private function generation():string{return $this->hash(request()->session()->get('company_context'));}
private function order(int $id,array $b,object $a):PurchaseOrder{$this->viewer($a);$r=PurchaseOrder::visibleTo($a)->where('id',$id)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->first();abort_unless($r,403,'Ordem fora da origem autorizada.');return $r;}
private function state(PurchaseOrder $order):array{
$lines=$order->items()->orderBy('id')->get();$effects=[];foreach($lines as$line)$effects[]=['line'=>$line->id,'returned'=>app(InventoryDispositions::class)->returnedForOrderLine($line->id),'reversed'=>app(InventoryReversals::class)->voidedReceiptQuantity($line->id,'accepted_milli')];
return ['supplier'=>\Tempo\Purchasing\Embedded\Models\Supplier::findOrFail($order->supplier_id)->toArray(),'request'=>\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id)->load('steps')->toArray(),'conflicts'=>\Tempo\Purchasing\Embedded\Models\ConflictDeclaration::where(fn($q)=>$q->where('requisition_id',$order->requisition_id)->orWhere('supplier_id',$order->supplier_id))->orderBy('id')->get()->toArray(),'budgets'=>DB::table('budgets')->whereIn('id',DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->select('budget_id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'order'=>$order->load('items')->toArray(),'receipts'=>DB::table('goods_receipt_items')->whereIn('purchase_order_item_id',$lines->pluck('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'physical_corrections'=>$effects,'invoices'=>Invoice::where('purchase_order_id',$order->id)->with('items')->orderBy('id')->get()->toArray(),'budget_entries'=>DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'budget_consumptions'=>DB::table('budget_consumptions')->whereIn('invoice_id',Invoice::where('purchase_order_id',$order->id)->select('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
}
private function input(Request $http,array $data,object $actor):Request{$r=Request::create('/embedded/supplier-invoice','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;}
private function dispatch(string $action,array $data,PurchaseOrder $order,object $actor,Request $http):array{
$workflow=app(InvoiceWorkflow::class);
if($action==='register'){
abort_if(array_diff(array_keys($data),['number','issued_on','subtotal','tax','total','items']),422);Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:purchase_order_item_id,quantity,unit,unit_price,tax_rate'])->validate();
(new \Tempo\Purchasing\Embedded\Http\Controllers\InvoiceController)->store($this->input($http,$data,$actor),$order,$workflow);
$invoice=Invoice::where('purchase_order_id',$order->id)->where('created_by',$actor->id)->orderByDesc('id')->firstOrFail();
}else{
abort_if(array_diff(array_keys($data),['invoice_id']),422);$d=Validator::make($data,['invoice_id'=>'required|integer|min:1'])->validate();$invoice=Invoice::where('purchase_order_id',$order->id)->findOrFail($d['invoice_id']);$workflow->validate($actor,$invoice);$invoice->refresh();
}
return ['invoice'=>$invoice,'result'=>['invoice_id'=>$invoice->id,'order_id'=>$order->id,'status'=>$invoice->status],'impact'=>['operation'=>$action==='register'?'Registar factura recebida pendente':'Validar conferência da factura','invoice'=>$invoice->load('items')->toArray(),'budget_consumption_minor'=>$action==='validate'?(int)$invoice->total_minor:0,'stock_changed'=>false,'payable_created'=>false,'payment_created'=>false,'ledger_posted'=>false]];
}
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','validate']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_invoices.'.$r,$c->unitId());return $out;}
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_invoices.view',fn($a)=>$this->viewer($a));$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$link=config('receipt_qa.obligations_enabled')===true&&app(\App\Modules\Identity\Services\Permissions::class)->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.view',$c->unitId());return response()->view('supplier-invoice-workspace',['obligations'=>$link,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($d){$this->viewer($a);return response()->json(PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->orderBy('id')->paginate(25,['id','number','status','currency'],'page',(int)($d['page']??1)));});}
public function show(int $id,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$rights){$order=$this->order($id,$b,$a);$state=$this->state($order);$revision=$this->hash($state);$matching=config('receipt_qa.invoice_progress_enabled')===true?app(InvoiceMatchingProgress::class)->forOrder($order,$state):null;$workflow=app(InvoiceWorkflow::class);foreach($state['invoices']as&$i)$i['can_validate']=$rights['validate']&&$workflow->reviewerEligible($a,Invoice::findOrFail($i['id']));unset($i);return response()->json(['order'=>$state['order'],'invoices'=>$state['invoices'],'revision'=>$revision,'matching_progress'=>$matching,'can_prepare'=>$rights['prepare']&&$a->hasFunctions(['finance'])&&!app(OrganizationAccess::class)->maintenanceOnly($a),'receipt_progress'=>app(ReceiptWorkflow::class)->progress($order),'physical_corrections'=>$state['physical_corrections']]);});}
public function history(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$d){$order=$this->order($id,$b,$a);return response()->json(AuditLog::where('requisition_id',$order->requisition_id)->whereIn('action',['invoice.created','invoice.validated','sgf.invoice.command'])->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:register,validate','reason'=>'required|string|min:10|max:2000','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536,422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($d['action']==='register'?'prepare':'validate'),function($a,$b,$f)use($id,$d,$r,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(hash_equals($this->hash($this->state($order)),$d['revision']),409,'A conferência mudou. Actualize a ordem.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$order,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if($d['action']==='register'){unset($out['impact']['invoice']['id']);foreach($out['impact']['invoice']['items']as&$line)unset($line['id'],$line['invoice_id']);unset($line);}$cut=$d+['order_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['reason'=>$d['reason']]]);});}
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['register','validate'],true),409);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($cut['action']==='register'?'prepare':'validate'),function($a,$b,$f,$origin)use($id,$r,$cut,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(($cut['order_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data'],$cut['reason']]);$old=AuditLog::where('requisition_id',$order->requisition_id)->where('user_id',$a->id)->where('action','sgf.invoice.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outro conteúdo.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($order)),$cut['revision']),409,'A base mudou desde a conferência.');$before=$this->state($order);$out=$this->dispatch($cut['action'],$cut['data'],$order,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.invoice.command',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),['invoices'=>$before['invoices']],$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'reason'=>$cut['reason'],'command'=>$cut['action'],'result'=>$out['result'],'invoice'=>$out['invoice']->load('items')->toArray()]);return response()->json($out['result']+['replayed'=>false],201);});}
}