commit de compras
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,PurchaseOrder,GoodsReceipt,Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\SupplierPayables;
|
||||
|
||||
/** Read-only view of existing facts, with independent authority for every stage. */
|
||||
final class DossierController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.dossiers_enabled')===true&&config('receipt_qa.requests_enabled')===true,404); }
|
||||
private function source(int $id,array $binding,object $actor):Requisition {
|
||||
abort_unless($actor->hasFunctions(['requester','procurement','finance','department_head'])&&RequisitionAccess::allowed($id,$binding),403);
|
||||
return Requisition::visibleTo($actor)->find($id)??abort(403,'Requisição fora do contexto autorizado.');
|
||||
}
|
||||
private function page($query,array $columns,string $name,array $input):array {
|
||||
$page=$query->orderBy('id')->paginate(25,$columns,$name,(int)($input[$name]??1));
|
||||
return ['accessible'=>true,'data'=>$page->items(),'pagination'=>['page'=>$page->currentPage(),'last_page'=>$page->lastPage(),'total'=>$page->total(),'per_page'=>25]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$runtime->run('purchasing_requests.view',function($a){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);});
|
||||
return response()->view('purchasing-dossier',['context'=>$r->session()->get('company_context.token')]);
|
||||
}
|
||||
public function index(Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:draft,pending,approved,rejected','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_requests.view',function($a,$b)use($d){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(lower(?) in lower(number))>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','process_version'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$rules=[];foreach(['quotes','awards','orders','receipts','invoices','obligations','events']as$key)$rules[$key.'_page']='sometimes|integer|min:1|max:10000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
$host=DB::connection();$c=app(\App\Shared\Context\CompanyContext::class);$scope=$c->identifiers()+['unit_id'=>$c->unitId(),'user_id'=>$c->userId()];
|
||||
return $runtime->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d,$host,$scope){
|
||||
$request=$this->source($id,$b,$a);$allowed=[];
|
||||
foreach(['sourcing','orders','receipts','invoices','obligations']as$stage){$flag=$stage==='receipts'?'enabled':$stage.'_enabled';$allowed[$stage]=config('receipt_qa.'.$flag)===true&&$host->table('permission_grants')->where($scope)->where('permission','purchasing_'.$stage.'.view')->lockForUpdate()->first()!==null;}
|
||||
$viewer=$a->hasFunctions(['admin','procurement','finance','department_head']);$finance=$a->hasFunctions(['admin','finance']);$allowed['sourcing']=$allowed['sourcing']&&$viewer;$allowed['orders']=$allowed['orders']&&$viewer;$allowed['receipts']=$allowed['receipts']&&Gate::allows('viewAny',GoodsReceipt::class);$allowed['invoices']=$allowed['invoices']&&$finance;$allowed['obligations']=$allowed['obligations']&&$finance;
|
||||
$orders=fn()=>PurchaseOrder::where('tenant_id',$a->tenant_id)->where('requisition_id',$id)->select('id');
|
||||
$invoices=fn()=>Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$orders());
|
||||
$restricted=['accessible'=>false];$stages=['quotes'=>$restricted,'awards'=>$restricted,'orders'=>$restricted,'receipts'=>$restricted,'invoices'=>$restricted,'obligations'=>$restricted];$patterns=['requisition.%','sgf.request.command'];
|
||||
if($allowed['sourcing']){$stages['quotes']=$this->page(Quotation::where('requisition_id',$id),['id','reference','supplier_id','total_minor','created_by','created_at'],'quotes_page',$d);$stages['awards']=$this->page(PurchaseAward::where('requisition_id',$id),['id','quotation_id','status','total_minor','created_by','created_at'],'awards_page',$d);array_push($patterns,'quotation.%','award.%','sgf.sourcing.command');}
|
||||
if($allowed['orders']){$stages['orders']=$this->page(PurchaseOrder::visibleTo($a)->where('requisition_id',$id),['id','number','status','total_minor','issued_by','issued_at'],'orders_page',$d);array_push($patterns,'order.%','sgf.order.command');}
|
||||
if($allowed['receipts']){$stages['receipts']=$this->page(GoodsReceipt::whereIn('purchase_order_id',$orders()),['id','number','purchase_order_id','status','received_on','received_by','confirmed_at'],'receipts_page',$d);array_push($patterns,'receipt.%','sgf.receipt.command','sgf.receipt.document','sgf.receipt.document_downloaded');}
|
||||
if($allowed['invoices']){$stages['invoices']=$this->page($invoices(),['id','number','purchase_order_id','status','total_minor','created_by','validated_by','validated_at'],'invoices_page',$d);array_push($patterns,'invoice.%','sgf.invoice.command');}
|
||||
if($allowed['obligations']){
|
||||
$service=app(SupplierPayables::class);$service->readContext($a);
|
||||
// The window is on source invoices, not the complete financial universe.
|
||||
$window=$invoices()->orderBy('id')->paginate(25,['id'],'obligations_page',(int)($d['obligations_page']??1));
|
||||
$positions=$service->engine($a)->positionsPage($window->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all());
|
||||
$stages['obligations']=['accessible'=>true,'data'=>$positions['obligations'],'pagination'=>['page'=>$window->currentPage(),'last_page'=>$window->lastPage(),'total_sources'=>$window->total(),'per_page'=>25],'source_window'=>true];$patterns[]='sgf.obligation.command';
|
||||
}
|
||||
$events=AuditLog::where('requisition_id',$id)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});$history=$this->page($events,['id','user_id','action','created_at'],'events_page',$d);
|
||||
$view=['request'=>$request->only(['id','number','status','process_version','requester_id','total_minor']),'stages'=>$stages,'events'=>$history,'read_only'=>true,'revision_scope'=>'visible_pages_only'];
|
||||
$view=app(ProcessNavigation::class)->decorate($view,$allowed,$id);
|
||||
$view['view_revision']=hash('sha256',Payables::canonical([$fingerprint,$view]));
|
||||
return response()->json($view)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
/** Mounts the existing shared requisition adapter in a fresh, pinned QA composition. */
|
||||
final class InternalRequisitionQa
|
||||
{
|
||||
public const FILES = [
|
||||
'packages/purchasing/src/RequisitionPort.php',
|
||||
'packages/purchasing/src/RequisitionFailure.php',
|
||||
'packages/purchasing/src/InternalRequisitions.php',
|
||||
'integrations/sgf/internal-requisitions/bootstrap.php',
|
||||
'integrations/sgf/internal-requisitions/Schema.php',
|
||||
'integrations/sgf/internal-requisitions/Port.php',
|
||||
'integrations/sgf/internal-requisitions/Controller.php',
|
||||
'integrations/sgf/internal-requisitions/routes.php',
|
||||
'integrations/sgf/internal-requisitions/screen.php',
|
||||
'integrations/sgf/internal-requisitions/screen.mjs',
|
||||
];
|
||||
|
||||
public static function verifiedBundle(string $bundle, string $pin): string
|
||||
{
|
||||
$root = realpath($bundle);
|
||||
if ($root === false || is_link($bundle) || ! preg_match('/^[a-f0-9]{64}$/D', $pin)
|
||||
|| ! is_file($root.'/manifest.json') || is_link($root.'/manifest.json')
|
||||
|| ! hash_equals($pin, hash_file('sha256', $root.'/manifest.json'))) {
|
||||
throw new LogicException('REQUISITION_QA_BUNDLE_REFUSED');
|
||||
}
|
||||
$manifest = json_decode(file_get_contents($root.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$files = $manifest['files'] ?? [];
|
||||
$expected = self::FILES; $actual = array_keys($files); sort($expected); sort($actual);
|
||||
if (($manifest['format'] ?? null) !== 'sgf.requisitions.qa-bundle.v1' || $actual !== $expected) {
|
||||
throw new LogicException('REQUISITION_QA_MANIFEST_REFUSED');
|
||||
}
|
||||
foreach ($files as $relative => $hash) {
|
||||
$file = realpath($root.'/'.$relative);
|
||||
if ($file === false || ! str_starts_with(strtolower($file), strtolower($root.DIRECTORY_SEPARATOR))
|
||||
|| ! is_file($file) || ! is_string($hash) || ! preg_match('/^[a-f0-9]{64}$/D', $hash)
|
||||
|| ! hash_equals($hash, hash_file('sha256', $file))) {
|
||||
throw new LogicException('REQUISITION_QA_SOURCE_REFUSED');
|
||||
}
|
||||
$part = $root;
|
||||
foreach (explode('/', $relative) as $segment) {
|
||||
$part .= '/'.$segment;
|
||||
if (is_link($part)) throw new LogicException('REQUISITION_QA_LINK_REFUSED');
|
||||
}
|
||||
}
|
||||
return $root;
|
||||
}
|
||||
|
||||
public static function mount(): ?string
|
||||
{
|
||||
if (config('internal_requisitions.qa_enabled') !== true) return null;
|
||||
if (! app()->environment('budget-testing')) throw new LogicException('REQUISITION_QA_ENVIRONMENT_REFUSED');
|
||||
$c = DB::connection(); $config = $c->getConfig();
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
if (! preg_match('/^[a-f0-9]{12}$/D', $run) || ($config['driver'] ?? null) !== 'pgsql'
|
||||
|| ($config['host'] ?? null) !== '127.0.0.1' || (string) ($config['port'] ?? '') !== '55439'
|
||||
|| ! preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D', $config['database'] ?? '', $m)
|
||||
|| ($config['username'] ?? null) !== 'ir_composition_'.$m[1]
|
||||
|| ! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])
|
||||
|| ($config['search_path'] ?? 'public') !== 'public') throw new LogicException('REQUISITION_QA_DATABASE_REFUSED');
|
||||
$identity = $c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,
|
||||
rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
if ($identity->db !== $config['database'] || $identity->role !== $config['username'] || $identity->elevated
|
||||
|| $identity->cluster !== 'sgf_modular_regression_'.$run) throw new LogicException('REQUISITION_QA_IDENTITY_REFUSED');
|
||||
$bundle = self::verifiedBundle((string) config('internal_requisitions.qa_bundle'), (string) config('internal_requisitions.qa_manifest_sha256'));
|
||||
$expected = 'C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/requisition-composition-'.$m[1].'/bundle';
|
||||
if (strtolower(str_replace('\\', '/', $bundle)) !== strtolower($expected)) throw new LogicException('REQUISITION_QA_LOCATION_REFUSED');
|
||||
require_once $bundle.'/integrations/sgf/internal-requisitions/bootstrap.php';
|
||||
return $bundle.'/integrations/sgf/internal-requisitions/routes.php';
|
||||
}
|
||||
}
|
||||
@@ -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);});}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
|
||||
|
||||
/** Read projection of the native matching quantities; never a reservation or writer. */
|
||||
final class InvoiceMatchingProgress
|
||||
{
|
||||
public function quantities(int $ordered,int $accepted,int $returned,int $reversed,int $validated,int $pending):array
|
||||
{
|
||||
$net=$accepted-$returned-$reversed;$ceiling=min($ordered,$net);
|
||||
return ['ordered_milli'=>$ordered,'accepted_milli'=>$accepted,'returned_milli'=>$returned,'reversed_milli'=>$reversed,'accepted_net_milli'=>$net,'validated_milli'=>$validated,'pending_milli'=>$pending,'remaining_milli'=>max(0,$ceiling-$validated),'deficit_milli'=>max(0,$validated-$ceiling)];
|
||||
}
|
||||
public function forOrder(object $order,array $state):array
|
||||
{
|
||||
$ids=array_column($state['order']['items'],'id');if($ids===[])return [];
|
||||
$accepted=DB::table('goods_receipt_items')->where('tenant_id',$order->tenant_id)->whereIn('purchase_order_item_id',$ids)->groupBy('purchase_order_item_id')->selectRaw('purchase_order_item_id,SUM(accepted_milli) AS accepted')->get()->keyBy('purchase_order_item_id');
|
||||
$invoiced=DB::table('invoice_items as l')->join('invoices as i','i.id','=','l.invoice_id')->where(['l.tenant_id'=>$order->tenant_id,'i.tenant_id'=>$order->tenant_id,'i.purchase_order_id'=>$order->id])->whereIn('l.purchase_order_item_id',$ids)->groupBy('l.purchase_order_item_id')->selectRaw("l.purchase_order_item_id,SUM(CASE WHEN i.status='validated' THEN l.quantity_milli ELSE 0 END) AS validated,SUM(CASE WHEN i.status='pending' THEN l.quantity_milli ELSE 0 END) AS pending")->get()->keyBy('purchase_order_item_id');
|
||||
$effects=array_column($state['physical_corrections'],null,'line');$rows=[];
|
||||
foreach($state['order']['items']as$line){$id=$line['id'];
|
||||
$rows[]=['line_id'=>$id,'description'=>$line['description'],'unit'=>$line['unit']]+$this->quantities(ReceiptWorkflow::milli($line['quantity']),(int)($accepted[$id]->accepted??0),(int)($effects[$id]['returned']??0),(int)($effects[$id]['reversed']??0),(int)($invoiced[$id]->validated??0),(int)($invoiced[$id]->pending??0));
|
||||
}return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Invoice,PurchaseOrder,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{SupplierPayables,RequisitionWorkflow};
|
||||
|
||||
/** Explicit recognition in the original native book; no imported mirror or second writer. */
|
||||
final class ObligationController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.obligations_enabled')===true,404); }
|
||||
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function orders(object $a,array $b) {
|
||||
return PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)));
|
||||
}
|
||||
private function invoice(int $id,object $a,array $b):Invoice {
|
||||
return Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'))->find($id)??abort(403,'Documento fora da origem autorizada.');
|
||||
}
|
||||
private function adapter(object $a):SupplierPayables {
|
||||
$service=app(SupplierPayables::class);$service->readContext($a);return $service;
|
||||
}
|
||||
private function state(object $a,Invoice $invoice,SupplierPayables $service):array {
|
||||
$scope=['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0];
|
||||
$sources=DB::table('pa_sources')->where($scope)->where('local_source_id',$invoice->id)->select('id');
|
||||
return ['document'=>$service->document($a,$invoice->id),
|
||||
'proposals'=>DB::table('pa_proposals')->where($scope)->whereIn('source_id',$sources)->orderByDesc('id')->limit(1)->get()->all(),
|
||||
'obligations'=>DB::table('pa_obligations')->where($scope)->whereIn('source_id',$sources)->orderBy('id')->get()->all(),
|
||||
'identity'=>DB::table('pa_access')->where($scope)->where('user_id',$a->id)->orderBy('action')->get(['action','person'])->all()];
|
||||
}
|
||||
private function rights():array {
|
||||
$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$rights=[];
|
||||
foreach(['prepare','validate']as$r)$rights[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.'.$r,$c->unitId());return $rights;
|
||||
}
|
||||
private function commandId(string $action,int $proposal,Invoice $invoice,object $a):int {
|
||||
if($action==='prepare')return $invoice->id;
|
||||
abort_unless(DB::table('pa_proposals as p')->join('pa_sources as s','s.id','=','p.source_id')->where(['p.id'=>$proposal,'p.tenant_id'=>$a->tenant_id,'p.company_id'=>0,'p.unit_id'=>0,'s.tenant_id'=>$a->tenant_id,'s.company_id'=>0,'s.unit_id'=>0,'s.local_source_id'=>$invoice->id])->exists(),403,'Proposta fora do documento autorizado.');return $proposal;
|
||||
}
|
||||
private function execute(Payables $engine,array $cut,Invoice $invoice,object $a):array {
|
||||
try{return $engine->command($cut['action'],$this->commandId($cut['action'],$cut['proposal_id'],$invoice,$a),$cut['reason'],$cut['operation_key']);}
|
||||
catch(\DomainException|\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$rt->run('purchasing_obligations.view',fn($a)=>$this->adapter($a));return response()->view('supplier-obligation-workspace',['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','status'=>'sometimes|in:pending,validated','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $rt->run('purchasing_obligations.view',function($a,$b)use($d){$this->adapter($a);$q=Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(? in number)>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','total_minor','purchase_order_id'],'page',(int)($d['page']??1)));});
|
||||
}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['proposal_page'=>'sometimes|integer|min:1|max:10000','event_page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$rights=$this->rights();
|
||||
return $rt->run('purchasing_obligations.view',function($a,$b)use($id,$d,$rights){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$out=$service->engine($a)->listingPage([$id],$d['proposal_page']??1,$d['event_page']??1,25);foreach($rights as$right=>$allowed)$out['permissions'][$right]=$allowed&&$service->can($a,$right);return response()->json($out+['revision'=>$this->hash($this->state($a,$invoice,$service))]);});
|
||||
}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['action'=>'required|in:prepare,validate','proposal_id'=>'required|integer|min:0','operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();
|
||||
return $rt->run('purchasing_obligations.'.$d['action'],function($a,$b,$f)use($id,$d,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);abort_unless(hash_equals($this->hash($this->state($a,$invoice,$service)),$d['revision']),409,'A conferência mudou.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$result=$this->execute($service->engine($a),$d,$invoice,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
unset($result['proposal_id'],$result['obligation_id']);$cut=$d+['person_revision'=>$this->hash($this->state($a,$invoice,$service)['identity']),'invoice_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'=>['result'=>$result,'principal_minor'=>$invoice->total_minor,'due_on'=>null,'due_status'=>'unknown','budget_changed'=>false,'stock_changed'=>false,'payment_created'=>false,'ledger_posted'=>false,'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,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['prepare','validate'],true),409);$generation=$this->generation();
|
||||
return $rt->run('purchasing_obligations.'.$cut['action'],function($a,$b,$f,$origin)use($id,$cut,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$engine=$service->engine($a);abort_unless(($cut['invoice_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.');
|
||||
// A committed retry still calls the kernel: current source/payment eligibility is rechecked.
|
||||
$receipt=DB::table('pa_commands')->where(['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0,'actor_id'=>$a->id,'command_key'=>$cut['operation_key']])->first();if($receipt)abort_unless((json_decode($receipt->result,true)['local_source_id']??null)===$id,409);
|
||||
$before=$this->state($a,$invoice,$service);abort_unless(($cut['person_revision']??null)===$this->hash($before['identity']),409,'Identidade alterada.');if(!$receipt)abort_unless(hash_equals($this->hash($before),$cut['revision']),409,'A base mudou desde a conferência.');
|
||||
$result=$this->execute($engine,$cut,$invoice,$a);
|
||||
if(!($result['replayed']??false))app(RequisitionWorkflow::class)->audit($a,'sgf.obligation.command',Requisition::findOrFail(PurchaseOrder::findOrFail($invoice->purchase_order_id)->requisition_id),$before,$origin+['operation_key'=>$cut['operation_key'],'reason'=>$cut['reason'],'result'=>$result,'after'=>$this->state($a,$invoice,$service)]);
|
||||
return response()->json($result,($result['state']??'')==='conflict'?409:(($result['replayed']??false)?200:201));});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Tempo\Purchasing\Embedded\Models\{PurchaseAward,PurchaseOrder,Requisition,Quotation};
|
||||
use Tempo\Purchasing\Embedded\Services\{PurchaseWorkflow,ProposalComparison,RequisitionWorkflow};
|
||||
|
||||
/** Issuance from an already approved award; no supplier selection or approval by this controller. */
|
||||
final class OrderController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.orders_enabled')===true,404);}
|
||||
private function viewActor(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function award(int $id,array $binding,object $actor):array{
|
||||
$this->viewActor($actor);
|
||||
$award=PurchaseAward::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($award,403);
|
||||
$request=Requisition::visibleTo($actor)->findOrFail($award->requisition_id);
|
||||
return [$award,$request];
|
||||
}
|
||||
private function basis(PurchaseAward $award,Requisition $request):array{
|
||||
$quote=Quotation::findOrFail($award->quotation_id)->load(['items','supplier']);
|
||||
$basis=['award'=>$award->load('approvals')->toArray(),'requisition'=>$request->load(['items','steps'])->toArray(),'quote'=>$quote->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
|
||||
return ['revision'=>hash('sha256',json_encode($basis,JSON_THROW_ON_ERROR)),'basis'=>$basis];
|
||||
}
|
||||
private function output(PurchaseOrder $order,bool $replayed):array{return ['order'=>$order->load('items')->toArray(),'replayed'=>$replayed,'receipt_url'=>'/purchasing/receipts','stock_created'=>false,'invoice_created'=>false,'payment_created'=>false];}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_orders.view',fn($actor)=>$this->viewActor($actor));return response()->view('order-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$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 $runtime->run('purchasing_orders.view',function($actor,$binding)use($d){$this->viewActor($actor);return response()->json(PurchaseAward::where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->whereIn('requisition_id',Requisition::visibleTo($actor)->select('id'))->orderBy('id')->paginate(25,['id','requisition_id','status','total_minor'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$ctx=app(\App\Shared\Context\CompanyContext::class);$scope=$ctx->identifiers();$can=app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$scope['tenant_id'],$scope['company_id'],'purchasing_orders.issue',$ctx->unitId());return $runtime->run('purchasing_orders.view',function($actor,$binding)use($id,$can){[$award,$request]=$this->award($id,$binding,$actor);$basis=$this->basis($award,$request);$order=PurchaseOrder::where('requisition_id',$request->id)->first();return response()->json($basis+['can_issue'=>$can&&app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),'existing'=>$order?$this->output($order,true):null]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['revision'=>'required|string|size:64']);abort_if(array_diff(array_keys($r->all()),['revision']),422);return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint)use($id,$d){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),403);$basis=$this->basis($award,$request);abort_unless(hash_equals($basis['revision'],$d['revision']),409,'A base aprovada mudou. Confira novamente.');$cut=['award'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'revision'=>$basis['revision'],'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['supplier'=>$basis['basis']['quote']['supplier']['name'],'total_minor'=>$award->total_minor,'currency'=>$basis['basis']['quote']['currency'],'delivery_date'=>$basis['basis']['award']['delivery_date'],'delivery_location'=>$award->delivery_location,'items'=>$basis['basis']['quote']['items'],'budget'=>'O workflow original revalida e regista o compromisso, se configurado.','stock'=>false,'invoice'=>false,'payment'=>false]]);});}
|
||||
public function issue(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:32768']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint,$origin)use($id,$cut){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(($cut['award']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O contexto ou a prévia mudou.');$existing=PurchaseOrder::where('requisition_id',$request->id)->first();abort_unless($existing||hash_equals($this->basis($award,$request)['revision'],$cut['revision']),409,'A base mudou desde a conferência.');$order=app(PurchaseWorkflow::class)->issue($actor,$award);if(!$existing)app(RequisitionWorkflow::class)->audit($actor,'sgf.order.issued',$request,null,$origin+['order_id'=>$order->id,'award_id'=>$award->id,'binding_revision'=>$fingerprint]);return response()->json($this->output($order,(bool)$existing),$existing?200:201);});}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Purchasing\Embedded\Models\AuditLog;
|
||||
|
||||
/** A stage's history must not become a back door into downstream financial payloads. */
|
||||
final class ProcessHistory
|
||||
{
|
||||
public function query(int $request,string $stage)
|
||||
{
|
||||
$patterns=match($stage){
|
||||
'requests'=>['requisition.%','sgf.request.command'],
|
||||
'sourcing'=>['requisition.%','sgf.request.command','quotation.%','award.%','sgf.sourcing.command'],
|
||||
default=>throw new \InvalidArgumentException('Unknown history stage'),
|
||||
};
|
||||
return AuditLog::where('requisition_id',$request)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
/** Links to already visible records; every destination retains its own authority checks. */
|
||||
final class ProcessNavigation
|
||||
{
|
||||
public function decorate(array $view,array $allowed,int $request):array
|
||||
{
|
||||
if(config('receipt_qa.focus_enabled')!==true)return $view;
|
||||
$link=fn(string $stage,array $ids,string $label)=>['href'=>'/purchasing/'.$stage.'?'.http_build_query($ids),'label'=>$label];
|
||||
$view['_links']=[$link('requests',['request'=>$request],'Consultar esta requisição')];
|
||||
if($allowed['sourcing'])$view['_links'][]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
|
||||
foreach($view['stages']as$key=>&$stage){
|
||||
if(!($stage['accessible']??false))continue;
|
||||
$stage['data']=array_map(function($record)use($key,$allowed,$request,$link){
|
||||
$row=is_array($record)?$record:(method_exists($record,'toArray')?$record->toArray():(array)$record);$links=[];
|
||||
if($key==='quotes'&&$allowed['sourcing'])$links[]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
|
||||
if($key==='awards'&&$allowed['orders'])$links[]=$link('orders',['award'=>$row['id']],'Consultar esta adjudicação e ordem');
|
||||
if($key==='orders'){
|
||||
if($allowed['receipts'])$links[]=$link('receipts',['order'=>$row['id']],'Consultar recepções desta ordem');
|
||||
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['id']],'Consultar facturas desta ordem');
|
||||
}
|
||||
if($key==='receipts'&&$allowed['receipts'])$links[]=$link('receipts',['order'=>$row['purchase_order_id'],'receipt'=>$row['id']],'Consultar esta recepção e os seus anexos');
|
||||
if($key==='invoices'){
|
||||
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['purchase_order_id'],'invoice'=>$row['id']],'Consultar esta factura');
|
||||
if($allowed['obligations'])$links[]=$link('obligations',['invoice'=>$row['id']],'Consultar obrigação desta factura');
|
||||
}
|
||||
return $row+['_links'=>$links];
|
||||
},$stage['data']);
|
||||
}unset($stage);return $view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Explicit access to existing synthetic identities, only in the selected local installation. */
|
||||
final class PurchasingEntry
|
||||
{
|
||||
public static function profiles(array $fixture):array
|
||||
{
|
||||
$definitions=['requester'=>['Solicitante','Prepara e submete a necessidade.'],'approver'=>['Chefia','Decide requisições e adjudicações por identidade distinta.'],'actor'=>['Compras','Prepara fornecedores, propostas, comparação e ordens.'],'issuer'=>['Finanças','Revê fornecedores, avalia propostas, regista facturas e prepara obrigações.'],'validator'=>['Armazém','Confere e confirma a recepção física.'],'reviewer'=>['Revisor financeiro','Valida facturas e obrigações por identidade distinta.'],'auditor'=>['Consulta financeira','Consulta autorizada dos documentos financeiros.']];
|
||||
$stages=['requests'=>'Requisições','suppliers'=>'Fornecedores','sourcing'=>'Propostas e adjudicação','orders'=>'Ordens','receipts'=>'Recepções','invoices'=>'Facturas','obligations'=>'Obrigações'];$profiles=[];
|
||||
$grants=DB::table('permission_grants')->where($fixture['scope'])->whereIn('user_id',array_intersect_key($fixture,$definitions))->get(['user_id','permission'])->groupBy('user_id');
|
||||
foreach($definitions as$key=>[$label,$description]){
|
||||
if(!isset($fixture[$key]))continue;$available=[];
|
||||
$rights=($grants[$fixture[$key]]??collect())->pluck('permission')->all();foreach($stages as$stage=>$name)if(in_array('purchasing_'.$stage.'.view',$rights,true))$available[$stage]=$name;
|
||||
$profiles[$key]=['user_id'=>$fixture[$key],'label'=>$label,'description'=>$description,'stages'=>$available];
|
||||
}return $profiles;
|
||||
}
|
||||
private static function guard():void
|
||||
{
|
||||
abort_unless(config('purchasing_workspace.single_installation')===true&&app()->environment('budget-testing')&&in_array(request()->ip(),['127.0.0.1','::1'],true),404);
|
||||
BudgetAdapter::guard();
|
||||
$id=(string)config('purchasing_workspace.id');$db=DB::connection();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$id)&&preg_match('/^[a-f0-9]{12}$/D',$run)
|
||||
&&$db->getDatabaseName()==='budget_core_sgf_'.$id&&$db->getConfig('username')==='ir_composition_'.$id&&(int)$db->getConfig('port')===55439,403);
|
||||
$identity=$db->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->role==='ir_composition_'.$id&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
}
|
||||
public function screen(Request $r)
|
||||
{
|
||||
self::guard();$scope=config('purchasing_workspace.scope');
|
||||
return response()->view('purchasing-entry',['profiles'=>config('purchasing_workspace.profiles'),'company'=>DB::table('companies')->find($scope['company_id']),'unit'=>DB::table('units')->find($scope['unit_id']),'returnTo'=>(string)$r->query('return_to','')])->header('Cache-Control','no-store')->header('X-SGF-Installation',config('purchasing_workspace.id'));
|
||||
}
|
||||
public function enter(Request $r)
|
||||
{
|
||||
self::guard();$d=$r->validate(['role'=>'required|string','stage'=>'required|string','return_to'=>'nullable|string|max:300']);abort_if(array_diff(array_keys($r->all()),['_token','role','stage','return_to']),422);
|
||||
$profile=config('purchasing_workspace.profiles.'.$d['role']);abort_unless(is_array($profile)&&isset($profile['stages'][$d['stage']]),403);
|
||||
$target='/purchasing/'.$d['stage'];
|
||||
if(!empty($d['return_to'])){
|
||||
$parts=parse_url($d['return_to']);abort_unless(is_array($parts)&&!isset($parts['scheme'])&&!isset($parts['host'])&&!isset($parts['fragment'])&&($parts['path']??null)===$target,422);
|
||||
parse_str($parts['query']??'',$query);$allowed=['requests'=>['request'],'suppliers'=>['supplier'],'sourcing'=>['request'],'orders'=>['award'],'receipts'=>['order','receipt','movement'],'invoices'=>['order','invoice'],'obligations'=>['invoice']][$d['stage']];
|
||||
abort_if(array_diff(array_keys($query),$allowed),422);foreach($query as$v)abort_unless(is_string($v)&&preg_match('/^[1-9][0-9]{0,14}$/D',$v),422);
|
||||
$pairs=isset($parts['query'])?explode('&',$parts['query']):[];abort_unless(count($pairs)===count($query),422);
|
||||
if($query){$primary=$d['stage']==='receipts'&&isset($query['movement'])?'movement':$allowed[0];abort_unless(isset($query[$primary]),422);if($primary==='movement')abort_unless(count($query)===1,422);}
|
||||
$target.=isset($parts['query'])?'?'.http_build_query($query):'';
|
||||
}
|
||||
$user=User::findOrFail($profile['user_id']);auth()->login($user);$scope=config('purchasing_workspace.scope');$r->session()->put('auth_version',$user->auth_version);
|
||||
$r->session()->put('company_context',$scope+['module_id'=>'purchasing','token'=>bin2hex(random_bytes(24))]);
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{StockItem,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{SupplyPlanning,EquipmentWorkflow};
|
||||
|
||||
/** Bounded catalogue discovery; the existing receipt workflow remains the physical authority. */
|
||||
final class ReceiptCatalogue
|
||||
{
|
||||
public function query(object $order,object $line) {
|
||||
$q=StockItem::query()->whereRaw('lower(btrim(unit))=lower(btrim(?))',[$line->unit]);
|
||||
if($line->purchase_type==='service')return $q->whereRaw('1=0');
|
||||
$fixed=$order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id']??null;
|
||||
if($fixed!==null)$q->whereKey($fixed);
|
||||
if(Schema::hasTable('supply_records')){
|
||||
$mapping=app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id',$order->id)->where('data->purchase_order_item_id',$line->id)->first();
|
||||
if($mapping)$q->whereKey($mapping->stock_item_id);
|
||||
}
|
||||
if($line->purchase_type==='spare_part'){
|
||||
abort_unless($line->spare_part_id&&$line->equipment_id,422,'A ordem não contém associação autorizada da peça.');
|
||||
$part=app(EquipmentWorkflow::class)->linkedPart((int)$line->spare_part_id,(int)$line->equipment_id,Requisition::findOrFail($order->requisition_id)->department_id);$q->whereKey($part->stock_item_id);
|
||||
}
|
||||
return $q;
|
||||
}
|
||||
public function revision(StockItem $item):string { return hash('sha256',Payables::canonical($item->toArray())); }
|
||||
public function selected(array $data):array {
|
||||
$ids=array_values(array_unique(array_filter(array_column($data['items'],'stock_item_id'))));sort($ids,SORT_NUMERIC);$revisions=[];
|
||||
foreach($ids as$id){$item=StockItem::whereKey($id)->lockForUpdate()->first();abort_unless($item,422,'Artigo fora do catálogo autorizado.');$revisions[(string)$id]=$this->revision($item);}
|
||||
ksort($revisions);return $revisions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate,Validator};
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,StockItem,Warehouse};
|
||||
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
|
||||
|
||||
final class ReceiptController
|
||||
{
|
||||
private function canConfirm():bool{$ctx=app(\App\Shared\Context\CompanyContext::class);$s=$ctx->identifiers();return app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$s['tenant_id'],$s['company_id'],'purchasing_receipts.confirm',$ctx->unitId());}
|
||||
private function order(int $id,array $binding):PurchaseOrder{$order=PurchaseOrder::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);return $order;}
|
||||
private function revision(PurchaseOrder $order,array $binding):string{return hash('sha256',json_encode([$order->getAttributes(),app(ReceiptWorkflow::class)->progress($order),$binding],JSON_THROW_ON_ERROR));}
|
||||
private function result(GoodsReceipt $receipt,bool $replayed):array{return ['receipt'=>$receipt->load('items')->toArray(),'replayed'=>$replayed,'movements'=>DB::table('stock_movements')->where('tenant_id',$receipt->tenant_id)->whereIn('goods_receipt_item_id',$receipt->items->pluck('id'))->orderBy('id')->get(['id','goods_receipt_item_id','quantity_milli','warehouse_id','stock_item_id','movement_type'])->all()];}
|
||||
public function catalogue(int $id,int $line,Request $r,ReceiptRuntime $runtime) {
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_receipts.confirm',function($actor,$binding)use($id,$line,$d){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$source=$order->items()->find($line);abort_unless($source,403,'Linha fora da ordem autorizada.');$catalogue=app(ReceiptCatalogue::class);$q=$catalogue->query($order,$source);if(isset($d['search'])&&$d['search']!=='')$q->where(fn($w)=>$w->whereRaw('position(lower(?) in lower(code))>0',[$d['search']])->orWhereRaw('position(lower(?) in lower(name))>0',[$d['search']]));$page=$q->orderBy('code')->orderBy('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$page->through(fn($item)=>$item->only(['id','code','name','unit'])+['selection_revision'=>$catalogue->revision($item)]);return response()->json($page)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d){$order=$this->order($id,$binding);return response()->json(GoodsReceipt::where('purchase_order_id',$order->id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$runtime->run('purchasing_receipts.view',fn()=>true);return response()->view('receipt-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($d){Gate::authorize('viewAny',GoodsReceipt::class);$orders=PurchaseOrder::where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->orderBy('id')->paginate((int)($d['per_page']??25),['id','number','status'], 'page',(int)($d['page']??1));return response()->json($orders);});}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d,$can){$order=$this->order($id,$binding);return response()->json(['order'=>$order->only(['id','number','status']),'progress'=>app(ReceiptWorkflow::class)->progress($order),'revision'=>$this->revision($order,$binding),'can_confirm'=>$can&&Gate::allows('create',[GoodsReceipt::class,$order]),'warehouses'=>Warehouse::whereIn('id',$binding['warehouse_ids'])->orderBy('id')->get(['id','code','name']),'items'=>StockItem::orderBy('id')->limit(25)->get(['id','code','name','unit']),'history'=>GoodsReceipt::where('purchase_order_id',$id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1))]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint)use($id,$r){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$input=$r->all();$revision=$input['revision']??null;$selected=$input['item_revisions']??null;unset($input['revision'],$input['item_revisions']);abort_if(array_diff(array_keys($input),['idempotency_key','delivery_note','received_on','warehouse_id','items']),422);$rules=(new \Tempo\Purchasing\Embedded\Http\Requests\ConfirmReceiptRequest)->setUserResolver(fn()=>$actor)->rules();$data=Validator::make($input,$rules)->validate();abort_unless(in_array((int)($data['warehouse_id']??0),$binding['warehouse_ids'],true),403);abort_unless(is_string($revision)&&hash_equals($this->revision($order,$binding),$revision),409,'A origem mudou. Actualize e confira novamente.');$items=app(ReceiptCatalogue::class)->selected($data);if($selected!==null){Validator::make(['selected'=>$selected],['selected'=>'array|max:50','selected.*'=>'required|string|size:64'])->validate();foreach($selected as$key=>$value)abort_unless(isset($items[(string)$key])&&hash_equals($items[(string)$key],$value),409,'O artigo mudou desde a escolha. Pesquise novamente.');} $cut=['item_revisions'=>$items,'actor'=>$actor->id,'fingerprint'=>$fingerprint,'order'=>$id,'revision'=>$revision,'data'=>$data,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['document'=>'Recepção confirmada, com histórico imutável','stock'=>'Apenas quantidades físicas aceites geram entrada, uma única vez.','payments'=>false,'accounting'=>false],'data'=>$data]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['preview'=>'required|string|max:100000']);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. Confira novamente.');}return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint,$origin)use($id,$cut){$order=$this->order($id,$binding);abort_unless(($cut['actor']??null)===$actor->id&&($cut['order']??null)===$id&&($cut['fingerprint']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou. Confira novamente.');$existing=GoodsReceipt::where('purchase_order_id',$id)->where('idempotency_key',$cut['data']['idempotency_key'])->first();abort_unless($existing||hash_equals($this->revision($order,$binding),$cut['revision']),409,'Recepção alterada por outra operação. Confira novamente.');if(!$existing){$current=app(ReceiptCatalogue::class)->selected($cut['data']);abort_unless(($cut['item_revisions']??null)===$current,409,'O artigo mudou desde a previa. Confira novamente.');}$receipt=app(ReceiptWorkflow::class)->confirm($actor,$order,$cut['data']);if(!$existing)app(\Tempo\Purchasing\Embedded\Services\RequisitionWorkflow::class)->audit($actor,'sgf.receipt.confirmed',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),null,$origin+['receipt_id'=>$receipt->id,'binding_revision'=>$fingerprint]);return response()->json($this->result($receipt,(bool)$existing),$existing?200:201);});}
|
||||
public function receipt(int $id,ReceiptRuntime $runtime){$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$can){$receipt=GoodsReceipt::findOrFail($id);$this->order($receipt->purchase_order_id,$binding);Gate::authorize('view',$receipt);return response()->json($this->result($receipt,true)+['documents_enabled'=>config('receipt_qa.receipt_documents_enabled')===true,'document_revision'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical([$receipt->getAttributes()])),'can_attach'=>$can&&config('receipt_qa.receipt_documents_enabled')===true&&app(\Tempo\Purchasing\Embedded\Services\ReceiptDocuments::class)->canAdd($actor,$receipt)]);});}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\{Request,UploadedFile};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,ReceiptDocument,AuditLog,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{ReceiptDocuments,RequisitionWorkflow};
|
||||
|
||||
/** Private transport; all attachment decisions and writes belong to the pinned native service. */
|
||||
final class ReceiptDocumentsController
|
||||
{
|
||||
private const FIELDS=['id','goods_receipt_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'];
|
||||
private function guard():void { abort_unless(config('receipt_qa.receipt_documents_enabled')===true,404); }
|
||||
private function hash(array $v):string { return hash('sha256',Payables::canonical($v)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function source(int $id,array $binding):GoodsReceipt {
|
||||
$receipt=GoodsReceipt::findOrFail($id);
|
||||
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
|
||||
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);return $receipt;
|
||||
}
|
||||
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
|
||||
private function integrity(ReceiptDocument $doc,string $root):void {
|
||||
$file=app(SupplierDocumentFiles::class)->path($root,$doc->path,(int)$doc->tenant_id,(int)$doc->goods_receipt_id);
|
||||
abort_unless(filesize($file)===(int)$doc->size&&hash_equals($doc->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
|
||||
}
|
||||
private function same(GoodsReceipt $receipt,array $cut):?ReceiptDocument { return ReceiptDocument::where('goods_receipt_id',$receipt->id)->where('document_type',$cut['document_type'])->where('sha256',$cut['file']['sha256'])->first(); }
|
||||
public function index(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$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 $runtime->run('purchasing_receipts.view',function($a,$b)use($id,$d){$receipt=$this->source($id,$b);abort_unless(app(ReceiptDocuments::class)->canRead($a,$receipt),403);return response()->json(ReceiptDocument::where('goods_receipt_id',$id)->orderByDesc('id')->paginate(15,self::FIELDS,'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
|
||||
return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f)use($id,$r,$d,$generation,$meta){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$d['revision']),409,'A recepção mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true,'receipt');$db=DB::connection();$level=$db->transactionLevel();$document=null;$existing=$this->same($receipt,$d+['file'=>$meta]);$db->beginTransaction();
|
||||
try{$document=$files->using($stage,fn()=>$service->add($a,$receipt,$d,$r->file('file')),'receipt_documents');$impact=['document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at']);}
|
||||
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document&&!$existing)$files->discard($stage,$document->path,(int)$receipt->tenant_id,$id,'receipt_documents');}
|
||||
// A reused document belongs to permanent storage; never delete or stage it.
|
||||
if($existing)$this->integrity($existing,$files->root(false,'receipt'));
|
||||
$cut=$d+['receipt_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
|
||||
}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$meta=$this->meta($r->file('file'));$generation=$this->generation();$created=null;
|
||||
try{return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f,$o)use($id,$r,$cut,$meta,$generation,&$created){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);
|
||||
abort_unless(($cut['receipt_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
|
||||
$hash=$this->hash([$id,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$old=AuditLog::where('action','sgf.receipt.document')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless($old->user_id===$a->id&&($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($old->after['result']['document_id']);$this->integrity($doc,$root);return response()->json($old->after['result']+['replayed'=>true]);}
|
||||
abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$cut['revision']),409,'A recepção mudou desde a conferência.');$existing=$this->same($receipt,$cut);$document=$files->using($root,fn()=>$service->add($a,$receipt,$cut,$r->file('file')),'receipt_documents');if(!$existing)$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$receipt->tenant_id,'receipt'=>$id];$this->integrity($document,$root);
|
||||
$result=['receipt_id'=>$id,'document_id'=>$document->id,'document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true];$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document',Requisition::findOrFail($order->requisition_id),null,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
|
||||
catch(\Throwable $e){if($created){try{$exists=DB::connection('receipt_native')->table('receipt_documents')->where(['tenant_id'=>$created['tenant'],'goods_receipt_id'=>$id,'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$id,'receipt_documents');}catch(\Throwable){/* An uncertain commit retains its evidence. */}}throw $e;}
|
||||
}
|
||||
public function download(int $id,int $document,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_receipts.view',function($a,$b,$f,$o)use($id,$document,$r){$receipt=$this->source($id,$b);$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($document);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$files->path($root,$doc->path,(int)$doc->tenant_id,$id);$input=Request::create('/embedded/receipt-document','GET');$input->setUserResolver(fn($guard=null)=>$a);
|
||||
$response=$files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\ReceiptDocumentController)->download($input,$receipt,$document,app(ReceiptDocuments::class)),'receipt_documents');$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document_downloaded',Requisition::findOrFail($order->requisition_id),null,$o+['receipt_id'=>$id,'document_id'=>$document,'sha256'=>$doc->sha256]);return $response;});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\{Auth,DB,Gate,Schema};
|
||||
|
||||
/** Local QA composition of the original receipt workflow, never an HTTP proxy or second writer. */
|
||||
final class ReceiptRuntime
|
||||
{
|
||||
private static array $loaded=[];
|
||||
private static function loadClass(string $class,string $bundle,array $files):void
|
||||
{
|
||||
foreach(['Tempo\\Purchasing\\Embedded\\'=>'app/','Laravel\\Fortify\\'=>'fortify/']as$prefix=>$directory){
|
||||
if(!str_starts_with($class,$prefix))continue;
|
||||
$tail=substr($class,strlen($prefix));abort_unless(preg_match('/^[A-Za-z0-9_\\\\]+$/D',$tail),503);
|
||||
$relative=$directory.str_replace('\\','/',$tail).'.php';$pin=$files[$relative]??null;$file=realpath($bundle.'/'.$relative);$base=realpath($bundle);
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&$base&&$file&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR))&&!is_link($bundle.'/'.$relative)&&hash_equals($pin,hash_file('sha256',$file)),503,'Dependência do pacote incompatível.');
|
||||
require_once $file;return;
|
||||
}
|
||||
}
|
||||
public function run(string $right,\Closure $work):mixed
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_obligations.'))abort_unless(config('receipt_qa.obligations_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_invoices.'))abort_unless(config('receipt_qa.invoices_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_requests.'))abort_unless(config('receipt_qa.requests_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_orders.'))abort_unless(config('receipt_qa.orders_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_suppliers.'))abort_unless(config('receipt_qa.suppliers_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_sourcing.'))abort_unless(config('receipt_qa.sourcing_enabled')===true,404);
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
BudgetAdapter::guard();
|
||||
$host=DB::connection();$db=$host->getDatabaseName();$role=$host->getConfig('username');
|
||||
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$db,$m)&&$role==='ir_composition_'.$m[1],403);
|
||||
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$hostConfig=$host->getConfig();abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($hostConfig['host']??null)==='127.0.0.1'&&(int)($hostConfig['port']??0)===55439&&empty($hostConfig['read'])&&empty($hostConfig['write'])&&($hostConfig['search_path']??'public')==='public',403);
|
||||
$identity=$host->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->role===$role&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
$expected='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
|
||||
$profile=realpath((string)config('receipt_qa.profile'));
|
||||
abort_unless($profile&&strtolower(str_replace('\\','/',$profile))===strtolower($expected.'/binding.json'),403);
|
||||
$p=json_decode(file_get_contents($profile),true,64,JSON_THROW_ON_ERROR);
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();
|
||||
abort_unless($scope['unit_id']&&request()->user()->id===$user,403);
|
||||
$key=implode(':',array_values($scope)).':'.$user;$binding=$p['bindings'][$key]??null;abort_unless($binding,403,'Origem de recepção não autorizada neste contexto.');
|
||||
if(config('receipt_qa.obligations_enabled')===true)abort_unless(($binding['payable_instance']??null)==='qa-stock-ledger-'.$m[1]&&config('common-payables.source_instance')===$binding['payable_instance']&&config('common-payables.history_enabled')===false&&config('common-payables.history_decisions_enabled')===false,403);
|
||||
$p['context_key']=$key;
|
||||
$bundle=realpath($expected.'/bundle');$manifest=$bundle.'/manifest.json';
|
||||
abort_unless($bundle&&hash_equals($p['manifest_sha256'],hash_file('sha256',$manifest)),503);
|
||||
$files=json_decode(file_get_contents($manifest),true,64,JSON_THROW_ON_ERROR)['files'];
|
||||
foreach($files as$file=>$pin)abort_unless(preg_match('~^(app|fortify)/[A-Za-z0-9_/]+\.php$~D',$file)&&!str_contains($file,'..')&&is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin),503);
|
||||
if(config('receipt_qa.orders_enabled')===true||config('receipt_qa.sourcing_enabled')===true){
|
||||
$economic=$expected.'/economic';abort_unless(isset($p['economic_manifest_sha256'])&&is_file($economic.'/manifest.json')&&hash_equals($p['economic_manifest_sha256'],hash_file('sha256',$economic.'/manifest.json')),503);
|
||||
$economicFiles=json_decode(file_get_contents($economic.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($economicFiles);sort($names);abort_unless($names===['ProposalScoring.php','QuotationPricing.php'],503);
|
||||
foreach($economicFiles as$file=>$pin){abort_unless(is_file($economic.'/'.$file)&&!is_link($economic.'/'.$file)&&hash_equals($pin,hash_file('sha256',$economic.'/'.$file)),503);$class='Tempo\\Purchasing\\Economic\\'.basename($file,'.php');if(class_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $economic.'/'.$file;}
|
||||
}
|
||||
if(config('receipt_qa.requests_enabled')===true){
|
||||
$internal=$expected.'/internal';abort_unless(isset($p['internal_manifest_sha256'])&&hash_equals($p['internal_manifest_sha256'],hash_file('sha256',$internal.'/manifest.json')),503);$pins=json_decode(file_get_contents($internal.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($pins);sort($names);abort_unless($names===['InternalRequisitions.php','RequisitionFailure.php','RequisitionPort.php'],503);foreach(['RequisitionPort.php','RequisitionFailure.php','InternalRequisitions.php']as$file){$pin=$pins[$file];abort_unless(preg_match('/^[a-f0-9]{64}$/D',$pin)&&!is_link($internal.'/'.$file)&&hash_equals($pin,hash_file('sha256',$internal.'/'.$file)),503);$class='Tempo\\Purchasing\\Internal\\'.basename($file,'.php');if(class_exists($class,false)||interface_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $internal.'/'.$file;}
|
||||
}
|
||||
if(!isset(self::$loaded[$bundle])){
|
||||
abort_unless(self::$loaded===[],503,'Uma só versão do workflow por processo.');
|
||||
spl_autoload_register(fn($class)=>self::loadClass($class,$bundle,$files));
|
||||
Gate::policy(\Tempo\Purchasing\Embedded\Models\GoodsReceipt::class,\Tempo\Purchasing\Embedded\Policies\GoodsReceiptPolicy::class);Gate::policy(\Tempo\Purchasing\Embedded\Models\Supplier::class,\Tempo\Purchasing\Embedded\Policies\SupplierPolicy::class);self::$loaded[$bundle]=true;
|
||||
app()->singleton(\Tempo\Purchasing\Embedded\Services\ReplenishmentRun::class);
|
||||
}
|
||||
$connection=$p['connection'];abort_unless(($connection['driver']??null)==='pgsql'&&($connection['host']??null)==='127.0.0.1'&&(int)($connection['port']??0)===55439
|
||||
&&($connection['database']??null)==='compras_receipts_test_'.$m[1]&&($connection['username']??null)==='payable_history_native_'.$m[1]
|
||||
&&empty($connection['url'])&&empty($connection['read'])&&empty($connection['write'])&&($connection['search_path']??null)==='public',403);
|
||||
config(['database.connections.receipt_native'=>$connection]);$native=DB::connection('receipt_native');
|
||||
$i=$native->selectOne("SELECT current_user role,current_database() db,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$i->elevated&&$i->role===$connection['username']&&$i->db===$connection['database']&&$i->cluster==='sgf_modular_regression_'.$run,403);
|
||||
return $host->transaction(function()use($host,$scope,$user,$right,$binding,$work,$p,$native){
|
||||
// Hold existing authority until the native transaction ends; no host business writes.
|
||||
abort_unless($host->table('users')->where('id',$user)->lockForUpdate()->first(),403);
|
||||
abort_unless($host->table('company_memberships')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']])->lockForUpdate()->first(),403);
|
||||
$view=str_starts_with($right,'purchasing_invoices.')?'purchasing_invoices.view':(str_starts_with($right,'purchasing_requests.')?'purchasing_requests.view':(str_starts_with($right,'purchasing_suppliers.')?'purchasing_suppliers.view':(str_starts_with($right,'purchasing_sourcing.')?'purchasing_sourcing.view':(str_starts_with($right,'purchasing_orders.')?'purchasing_orders.view':'purchasing_receipts.view'))));
|
||||
if(str_starts_with($right,'purchasing_obligations.'))$view='purchasing_obligations.view';
|
||||
foreach(['company.view'=>null,'unit.view'=>$scope['unit_id'],$view=>$scope['unit_id'],$right=>$scope['unit_id']]as$permission=>$unit)
|
||||
abort_unless($host->table('permission_grants')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>$unit,'permission'=>$permission])->lockForUpdate()->first(),403);
|
||||
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$environment=app()->environment();
|
||||
config(['auth.guards.receipt_native'=>['driver'=>'session','provider'=>'receipt_native'],'auth.providers.receipt_native'=>['driver'=>'eloquent','model'=>\Tempo\Purchasing\Embedded\Models\User::class]]);
|
||||
DB::setDefaultConnection('receipt_native');Schema::clearResolvedInstance('db.schema');Auth::shouldUse('receipt_native');app()->instance('env','testing');
|
||||
try{
|
||||
$actor=\Tempo\Purchasing\Embedded\Models\User::where('tenant_id',$binding['tenant_id'])->findOrFail($binding['actor_id']);Auth::guard('receipt_native')->setUser($actor);
|
||||
return $native->transaction(function()use($native,$actor,$binding,$work,$p){
|
||||
$native->table('tenants')->where('id',$actor->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$version=[$p['context_key'],$binding,$p['manifest_sha256']];$origin=['sgf_context_key'=>$p['context_key'],'workflow_manifest'=>$p['manifest_sha256']];
|
||||
if(isset($p['internal_manifest_sha256'])){$version[]=$p['internal_manifest_sha256'];$origin['internal_manifest']=$p['internal_manifest_sha256'];}
|
||||
if(isset($p['economic_manifest_sha256'])){$version[]=$p['economic_manifest_sha256'];$origin['economic_manifest']=$p['economic_manifest_sha256'];}
|
||||
return $work($actor,$binding,hash('sha256',json_encode($version,JSON_THROW_ON_ERROR)),$origin);
|
||||
});
|
||||
}finally{Auth::forgetGuards();Auth::shouldUse($guard);DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');app()->instance('env',$environment);}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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\{Requisition,AuditLog,CostCenter};
|
||||
use Tempo\Purchasing\Embedded\Services\{RequisitionWorkflow,OrganizationAccess};
|
||||
|
||||
/** Native requisition lifecycle in SGF, sharing the same reservation and order book. */
|
||||
final class RequestController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.requests_enabled')===true,404);}
|
||||
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
|
||||
private function state(?Requisition $r):?array{return $r?->load(['items','steps'])->toArray();}
|
||||
private function source(int $id,array $b,object $a):?Requisition{abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);if(!$id)return null;abort_unless(RequisitionAccess::allowed($id,$b),403);return Requisition::visibleTo($a)->findOrFail($id);}
|
||||
private function right(string $a):string{return ['save'=>'prepare','submit'=>'submit','decide'=>'approve'][$a];}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$v=[];foreach(['prepare','submit','approve']as$r)$v[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_requests.'.$r,$c->unitId());return $v;}
|
||||
private function historyPage(int $id,object $actor,string $binding,int $size=25,?string $token=null):array {
|
||||
$generation=$this->hash(request()->session()->get('company_context'));$cursor=null;
|
||||
if($token!==null){try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Cursor inválido. Actualize o histórico.');}
|
||||
abort_unless(($cut['request']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&($cut['size']??null)===$size&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time()&&is_string($cut['cursor']??null),409,'A consulta ou o contexto mudou. Actualize o histórico.');
|
||||
$cursor=\Illuminate\Pagination\Cursor::fromEncoded($cut['cursor']);abort_unless($cursor,409,'Cursor inválido. Actualize o histórico.');
|
||||
}
|
||||
$page=app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->cursorPaginate($size,['id','user_id','action','created_at','before','after'],'cursor',$cursor);
|
||||
$encode=fn($value)=>$value?Crypt::encryptString(json_encode(['request'=>$id,'actor'=>$actor->id,'binding'=>$binding,'generation'=>$generation,'size'=>$size,'observed'=>time(),'cursor'=>$value->encode()],JSON_THROW_ON_ERROR)):null;
|
||||
return ['data'=>$page->items(),'navigation'=>['next'=>$encode($page->nextCursor()),'previous'=>$encode($page->previousCursor()),'per_page'=>$size,'order'=>'sequence_desc']];
|
||||
}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $rt){
|
||||
$this->guard();abort_unless(config('receipt_qa.request_history_enabled')===true,404);abort_unless($id>0,404);$d=$r->validate(['per_page'=>'sometimes|integer|in:25,100','cursor'=>'sometimes|required|string|max:16384']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d){$this->source($id,$b,$a);return response()->json($this->historyPage($id,$a,$fingerprint,(int)($d['per_page']??25),$d['cursor']??null))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
private function dispatch(string $action,array $data,?Requisition $record,object $actor,array $binding,string $key):array{
|
||||
$workflow=app(RequisitionWorkflow::class);$before=$this->state($record);
|
||||
if($action==='save'){
|
||||
abort_if(array_diff(array_keys($data),['cost_center_id','priority','needed_by','justification','items']),422);
|
||||
Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate','items.*.purchase_type'=>'required|in:consumable,service,other'])->validate();
|
||||
$input=(new \Tempo\Purchasing\Embedded\Http\Requests\StoreRequisitionRequest)->setUserResolver(fn()=>$actor);
|
||||
$data=Validator::make($data,$input->rules())->validate();abort_unless(in_array((int)$data['cost_center_id'],$binding['centre_ids']??[],true),403);
|
||||
$record=$workflow->save($actor,$data+['operation_key'=>$key]+($record?['process_version'=>(int)$record->process_version]:[]),$record);
|
||||
}elseif($action==='submit'){
|
||||
abort_unless($record&&$data===[],422);$workflow->submit($actor,$record,$key,(int)$record->process_version);
|
||||
}else{
|
||||
abort_unless($record,422);abort_if(array_diff(array_keys($data),['decision','comment']),422);$data=Validator::make($data,['decision'=>'required|in:approved,rejected','comment'=>'required|string|min:10|max:2000'])->validate();$workflow->decide($actor,$record,$data['decision'],$data['comment'],$key,(int)$record->process_version);
|
||||
}
|
||||
$record->refresh();return ['result'=>['request_id'=>$record->id,'status'=>$record->status,'version'=>(int)$record->process_version],'impact'=>['operation'=>$action,'before'=>$before,'after'=>$this->state($record),'budget_authority'=>'Compras: mesmo livro da ordem; sem reserva no host SGF','reservation_on_submit'=>$action==='submit','release_on_rejection'=>$action==='decide'&&$data['decision']==='rejected']];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_requests.view',fn($a)=>$this->source(0,[],$a));return response()->view('request-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'nullable|in:draft,pending,approved,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_requests.view',function($a,$b)use($d,$rights){$this->source(0,$b,$a);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if($d['status']??'')$q->where('status',$d['status']);return response()->json(['requests'=>$q->orderByDesc('id')->paginate(15,['id','number','status','process_version'],'page',(int)($d['page']??1)),'centres'=>CostCenter::whereIn('id',$b['centre_ids']??[])->where('department_id',$a->department_id)->get(['id','code','name']),'new_revision'=>$this->hash(null),'can_prepare'=>$rights['prepare']&&app(OrganizationAccess::class)->canRequest($a)]);});}
|
||||
public function show(int $id,ReceiptRuntime $rt){$this->guard();abort_unless($id>0,404);$rights=$this->rights();return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$rights){$r=$this->source($id,$b,$a);$state=$this->state($r);$own=$r->requester_id===$a->id&&$r->status==='draft';$paged=config('receipt_qa.request_history_enabled')===true?$this->historyPage($id,$a,$fingerprint):null;return response()->json(['request'=>$state,'revision'=>$this->hash($state),'capabilities'=>['save'=>$rights['prepare']&&$own,'submit'=>$rights['submit']&&$own,'decide'=>$rights['approve']&&$r->canDecide($a)],'history'=>$paged?$paged['data']:app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at','before','after']),'history_navigation'=>$paged['navigation']??null]);});}
|
||||
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:save,submit,decide','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536||($id===0&&$d['action']!=='save'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_requests.'.$this->right($d['action']),function($a,$b,$f)use($r,$id,$d){$record=$this->source($id,$b,$a);abort_unless(hash_equals($this->hash($this->state($record)),$d['revision']),409,'A requisição mudou. Confira novamente.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$record,$a,$b,$d['operation_key']);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if(!$id){unset($out['impact']['after']['id'],$out['impact']['after']['number']);foreach($out['impact']['after']['items']as&$line)unset($line['id'],$line['requisition_id']);unset($line);}$cut=$d+['id'=>$id,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
|
||||
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']??'',['save','submit','decide'],true),409);return $rt->run('purchasing_requests.'.$this->right($cut['action']),function($a,$b,$f,$o)use($id,$cut){$record=$this->source($id,$b,$a);abort_unless(($cut['id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'A prévia ou o contexto mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.request.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 outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($record)),$cut['revision']),409,'A requisição mudou desde a conferência.');$before=$this->state($record);$out=$this->dispatch($cut['action'],$cut['data'],$record,$a,$b,$cut['operation_key']);$record=Requisition::findOrFail($out['result']['request_id']);app(RequisitionWorkflow::class)->audit($a,'sgf.request.command',$record,$before,$o+['request_scope'=>RequisitionAccess::scope(),'operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
use Tempo\Purchasing\Embedded\Models\Requisition;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
|
||||
/** One native record/book; admission is an explicit binding or an audited host origin. */
|
||||
final class RequisitionAccess
|
||||
{
|
||||
public static function scope():string{$c=app(CompanyContext::class);return hash('sha256',json_encode($c->identifiers()+['unit_id'=>$c->unitId()],JSON_THROW_ON_ERROR));}
|
||||
public static function ids(array $binding){return Requisition::query()->select('requisitions.id')->where(function($q)use($binding){$q->whereIn('requisitions.id',$binding['requisition_ids']??[]);if(config('receipt_qa.requests_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as ra')->whereColumn('ra.tenant_id','requisitions.tenant_id')->whereColumn('ra.requisition_id','requisitions.id')->where('ra.action','sgf.request.command')->where('ra.after->request_scope',self::scope()));});}
|
||||
public static function allowed(int $id,array $binding):bool{return self::ids($binding)->where('requisitions.id',$id)->exists();}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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\{Requisition,Quotation,PurchaseAward,Supplier,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,QuotationParticipation,ProposalComparison,PurchaseWorkflow,RequisitionWorkflow};
|
||||
|
||||
/** Transport and receipts around the original quotation/award commands; no pricing or scoring engine. */
|
||||
final class SourcingController
|
||||
{
|
||||
private const ROLES=['quote'=>['procurement'],'evaluate'=>['procurement','finance','department_head'],'select'=>['procurement'],'decide'=>['procurement','finance','department_head']];
|
||||
private const RIGHTS=['quote'=>'prepare','evaluate'=>'evaluate','select'=>'select','decide'=>'approve'];
|
||||
private function guard():void{abort_unless(config('receipt_qa.sourcing_enabled')===true,404);}
|
||||
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function source(int $id,array $binding,object $actor):Requisition{$this->viewer($actor);abort_unless(RequisitionAccess::allowed($id,$binding),403);return Requisition::visibleTo($actor)->findOrFail($id);}
|
||||
private function hash(mixed $value):string{return hash('sha256',Payables::canonical($value));}
|
||||
private function state(Requisition $request):array{
|
||||
return ['request'=>$request->load(['items','steps'])->toArray(),'quotes'=>Quotation::where('requisition_id',$request->id)->with(['items','supplier','evaluations'])->orderBy('id')->get()->toArray(),'award'=>PurchaseAward::where('requisition_id',$request->id)->with('approvals')->first()?->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
|
||||
}
|
||||
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','evaluate','select','approve']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$r,$c->unitId());return $out;}
|
||||
private function input(Request $r,array $data,object $actor):Request{$input=Request::create('/embedded/sourcing','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$input->setUserResolver(fn($guard=null)=>$actor);if($r->hasSession())$input->setLaravelSession($r->session());return $input;}
|
||||
private function supplierAccess(string $action,array $data,array $binding,string $context):?string {
|
||||
if($action!=='quote')return null;
|
||||
$d=Validator::make($data,['supplier_id'=>'required|integer|min:1','supplier_revision'=>'sometimes|required|string|size:64'])->validate();
|
||||
$supplier=SupplierAccess::query($binding,$context)->find($d['supplier_id']);abort_unless($supplier,404);
|
||||
$revision=$this->hash($supplier->toArray());
|
||||
if(isset($d['supplier_revision']))abort_unless(hash_equals($revision,$d['supplier_revision']),409,'O fornecedor mudou desde a selecção. Pesquise e confira novamente.');
|
||||
return $revision;
|
||||
}
|
||||
private function eligibleSuppliers(array $binding,string $context) {
|
||||
return SupplierAccess::query($binding,$context)->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today());
|
||||
}
|
||||
public function suppliers(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_sourcing.prepare',function($actor,$binding,$fingerprint,$origin)use($id,$d){
|
||||
$request=$this->source($id,$binding,$actor);abort_unless(app(QuotationParticipation::class)->canPrepare($actor,$request),403);
|
||||
$q=$this->eligibleSuppliers($binding,$origin['sgf_context_key']);
|
||||
if(isset($d['search'])&&$d['search']!=='')$q->where(fn($query)=>$query->whereRaw('position(lower(?) in lower(suppliers.name))>0',[$d['search']])->orWhereRaw('position(? in suppliers.nuit)>0',[$d['search']]));
|
||||
$page=$q->orderBy('suppliers.name')->orderBy('suppliers.id')->paginate((int)($d['per_page']??25),['suppliers.*'],'page',(int)($d['page']??1));
|
||||
$page->through(fn($s)=>['id'=>$s->id,'name'=>$s->name,'nuit'=>$s->nuit,'version'=>$s->version,'selection_revision'=>$this->hash($s->toArray())]);
|
||||
return response()->json($page)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
private function dispatch(string $action,array $data,Requisition $request,object $actor,Request $http):array{
|
||||
$keys=match($action){'quote'=>['supplier_id','supplier_revision','reference','delivery_days','valid_until','payment_terms','no_conflict','items'],'evaluate'=>['quotation_id','quality','history','payment','comment','no_conflict'],'select'=>['quotation_id','justification','delivery_date','delivery_location'],'decide'=>['award_id','decision','comment']};abort_if(array_diff(array_keys($data),$keys),422);
|
||||
$audit=app(RequisitionWorkflow::class);
|
||||
if($action==='quote'){
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->store($this->input($http,array_diff_key($data,['supplier_revision'=>true]),$actor),$request,$audit);
|
||||
$quote=Quotation::where('requisition_id',$request->id)->where('supplier_id',$data['supplier_id'])->with(['supplier','items'])->firstOrFail();
|
||||
return ['result'=>['quotation_id'=>$quote->id],'impact'=>['operation'=>'Registar proposta','supplier'=>$quote->supplier->name,'total_minor'=>$quote->total_minor,'currency'=>$quote->currency,'lines'=>$quote->items->map(fn($i)=>$i->only(['description','quantity','unit_price_minor','tax_minor','total_minor']))->all()]];
|
||||
}
|
||||
if($action==='evaluate'){
|
||||
$quote=Quotation::where('requisition_id',$request->id)->findOrFail($data['quotation_id']??0);$payload=$data;unset($payload['quotation_id']);
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->evaluate($this->input($http,$payload,$actor),$quote,$audit);
|
||||
return ['result'=>['quotation_id'=>$quote->id,'evaluator_id'=>$actor->id],'impact'=>['operation'=>'Registar avaliação individual','supplier'=>$quote->supplier->name,'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'comment'=>$data['comment']]];
|
||||
}
|
||||
if($action==='select'){
|
||||
$validated=Validator::make($data,['quotation_id'=>'required|integer','justification'=>'required|string|min:10|max:5000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'])->validate();
|
||||
$award=app(PurchaseWorkflow::class)->select($actor,$request,$validated);
|
||||
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Submeter adjudicação para aprovação distinta','supplier'=>Quotation::findOrFail($award->quotation_id)->supplier->name,'total_minor'=>$award->total_minor,'delivery_date'=>$validated['delivery_date'],'delivery_location'=>$award->delivery_location,'required_approvals'=>$award->approvals->pluck('role')->all()]];
|
||||
}
|
||||
$validated=Validator::make($data,['award_id'=>'required|integer','decision'=>'required|in:approved,rejected','comment'=>'nullable|required_if:decision,rejected|string|max:2000'])->validate();$award=PurchaseAward::where('requisition_id',$request->id)->findOrFail($validated['award_id']);
|
||||
app(PurchaseWorkflow::class)->decide($actor,$award,$validated['decision'],$validated['comment']??null);$award->refresh();
|
||||
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Decidir adjudicação','decision'=>$validated['decision'],'resulting_status'=>$award->status,'total_minor'=>$award->total_minor,'comment'=>$validated['comment']??null]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_sourcing.view',fn($actor)=>$this->viewer($actor));return response()->view('sourcing-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$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 $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($d){$this->viewer($actor);return response()->json(Requisition::visibleTo($actor)->whereIn('id',RequisitionAccess::ids($binding))->where('status','approved')->orderBy('id')->paginate(25,['id','number','status'],'page',(int)($d['page']??1)));});}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $runtime){$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 $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($id,$d){$this->source($id,$binding,$actor);return response()->json(app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->paginate(25,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$rights=$this->rights();return $runtime->run('purchasing_sourcing.view',function($actor,$binding,$fingerprint,$origin)use($id,$rights){$request=$this->source($id,$binding,$actor);$state=$this->state($request);$part=app(QuotationParticipation::class);$quotes=Quotation::where('requisition_id',$id)->get()->keyBy('id');foreach($state['comparison']['rows']as&$row)$row['can_evaluate']=$rights['evaluate']&&$part->canEvaluate($actor,$request,$quotes[$row['id']]);unset($row);$award=PurchaseAward::where('requisition_id',$id)->first();return response()->json($state+['revision'=>$this->hash($this->state($request)),'capabilities'=>['quote'=>$rights['prepare']&&$part->canPrepare($actor,$request),'select'=>$rights['select']&&$actor->hasFunctions(['procurement'])&&$actor->id!==$request->requester_id&&!$award&&$state['comparison']['ready'],'decide'=>$rights['approve']&&$award&&app(PurchaseWorkflow::class)->canDecide($actor,$award,$request)],'suppliers'=>SupplierAccess::query($binding,$origin['sgf_context_key'])->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today())->orderBy('id')->limit(25)->get(['id','name']),'history'=>app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at'])]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:quote,evaluate,select,decide','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data'],JSON_THROW_ON_ERROR))>65536,422);$d['operation_key']=strtolower($d['operation_key']);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$d['action']],function($actor,$binding,$fingerprint,$origin)use($id,$d,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$d['action']]);abort_unless(hash_equals($this->hash($this->state($request)),$d['revision']),409,'O processo mudou. Actualize e confira novamente.');
|
||||
// Exact native validation/pricing in a rollback-only savepoint; no preview record is committed.
|
||||
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$supplierRevision=$this->supplierAccess($d['action'],$d['data'],$binding,$origin['sgf_context_key']);$out=$this->dispatch($d['action'],$d['data'],$request,$actor,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
$cut=$d+['supplier_revision'=>$supplierRevision,'request_id'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['stock'=>false,'order'=>false,'payment'=>false],'operation_key'=>$d['operation_key']]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$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(isset(self::RIGHTS[$cut['action']??'']),409);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$cut['action']],function($actor,$binding,$fingerprint,$origin)use($id,$cut,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$cut['action']]);abort_unless(($cut['request_id']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou.');$hash=$this->hash([$cut['action'],$cut['data']]);$old=AuditLog::where('requisition_id',$id)->where('user_id',$actor->id)->where('action','sgf.sourcing.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){$receipt=$old->after;abort_unless(($receipt['request_hash']??null)===$hash&&($receipt['binding_revision']??null)===$fingerprint,409,'Chave já usada com outros dados.');return response()->json($receipt['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($request)),$cut['revision']),409,'O processo mudou desde a conferência.');$supplierRevision=$this->supplierAccess($cut['action'],$cut['data'],$binding,$origin['sgf_context_key']);if($cut['action']==='quote')abort_unless(is_string($cut['supplier_revision']??null)&&hash_equals($cut['supplier_revision'],$supplierRevision),409,'Fornecedor alterado; exige nova conferencia.');$out=$this->dispatch($cut['action'],$cut['data'],$request,$actor,$r);app(RequisitionWorkflow::class)->audit($actor,'sgf.sourcing.command',$request,null,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$fingerprint,'command'=>$cut['action'],'supplier_revision'=>$supplierRevision,'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{DB,Gate};
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder};
|
||||
|
||||
/** Resolve an actual movement to its authorised receipt; URL parent ids are never trusted. */
|
||||
final class StockReceiptTraceController
|
||||
{
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime,CompanyContext $ctx)
|
||||
{
|
||||
abort_unless(config('receipt_qa.stock_trace_enabled')===true&&config('receipt_qa.stock_read_enabled')===true,404);
|
||||
abort_if($r->query()!==[],422);
|
||||
$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();$host=DB::connection();
|
||||
return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$scope,$user,$host){
|
||||
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'permission'=>'stock.view'])->sharedLock()->first(),403);
|
||||
$tag=substr($host->getDatabaseName(),strlen('budget_core_sgf_'));
|
||||
$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$tag.'/stock-binding.json';
|
||||
abort_unless(is_file($file)&&!is_link($file),503,'Origem de stock indisponível.');
|
||||
try{$profile=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(503,'Origem de stock incompatível.');}
|
||||
$stock=$profile['bindings'][implode(':',array_values($scope)).':'.$user]??null;
|
||||
abort_unless($stock&&($stock['source_tenant_id']??null)===$binding['tenant_id']
|
||||
&&($profile['connection']['database']??null)===DB::connection()->getDatabaseName()
|
||||
&&($stock['schema']??null)==='public'&&($stock['append_only']??false)===true,403);
|
||||
$movement=DB::table('stock_movements')->where('tenant_id',$binding['tenant_id'])->where('id',$id)
|
||||
->whereIn('warehouse_id',array_values(array_intersect($stock['warehouse_ids']??[],$binding['warehouse_ids'])))
|
||||
->where('movement_type','receipt_in')->first(['id','goods_receipt_item_id','stock_item_id','warehouse_id','quantity_milli','snapshot']);
|
||||
abort_unless($movement,403,'Movimento fora da origem autorizada.');
|
||||
$line=DB::table('goods_receipt_items')->where('tenant_id',$binding['tenant_id'])->where('id',$movement->goods_receipt_item_id)->first();
|
||||
abort_unless($line,409,'A evidência de origem exige nova conferência.');
|
||||
$receipt=GoodsReceipt::findOrFail($line->goods_receipt_id);
|
||||
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
|
||||
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);
|
||||
try{$snapshot=json_decode($movement->snapshot,true,64,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(409,'A evidência de origem exige nova conferência.');}
|
||||
abort_unless($receipt->tenant_id===$binding['tenant_id']&&(int)$receipt->warehouse_id===(int)$movement->warehouse_id
|
||||
&&(int)$line->stock_item_id===(int)$movement->stock_item_id&&(string)$line->accepted_milli===(string)$movement->quantity_milli
|
||||
&&(int)$movement->quantity_milli>0&&($snapshot['receipt_id']??null)===$receipt->id,409,'O movimento e a recepção exigem nova conferência.');
|
||||
return response()->json(['movement_id'=>(int)$movement->id,'order_id'=>$order->id,'receipt_id'=>$receipt->id,
|
||||
'href'=>'/purchasing/receipts?order='.$order->id.'&receipt='.$receipt->id,'read_only'=>true])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Purchasing\Embedded\Models\Supplier;
|
||||
|
||||
/** Explicit fixture bindings and suppliers actually prepared in this company/unit. */
|
||||
final class SupplierAccess
|
||||
{
|
||||
public static function scope(string $context):string{return hash('sha256',substr($context,0,strrpos($context,':')));}
|
||||
public static function query(array $binding,string $context){
|
||||
return Supplier::query()->where(function($q)use($binding,$context){
|
||||
$q->whereIn('suppliers.id',$binding['supplier_ids']??[]);
|
||||
if(config('receipt_qa.suppliers_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as sa')->whereColumn('sa.tenant_id','suppliers.tenant_id')->where('sa.action','sgf.supplier.command')->where('sa.after->supplier_scope',self::scope($context))->whereRaw("sa.after->'result'->>'supplier_id'=suppliers.id::text"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Supplier,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,SupplierParticipation,RequisitionWorkflow};
|
||||
|
||||
/** QA transport around the original supplier controller and FormRequest. */
|
||||
final class SupplierController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.suppliers_enabled')===true,404);}
|
||||
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function hash(mixed $data):string{return hash('sha256',Payables::canonical([$data]));}
|
||||
private function source(int $id,array $b,object $a,string $context):?Supplier{$this->viewer($a);return $id===0?null:SupplierAccess::query($b,$context)->findOrFail($id);}
|
||||
private function revision(?Supplier $s):string{return $this->hash($s?->toArray());}
|
||||
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','review']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_suppliers.'.$r,$c->unitId());return $out;}
|
||||
private function right(string $action):string{return $action==='review'?'review':'prepare';}
|
||||
private function dispatch(string $action,array $data,?Supplier $supplier,object $actor,Request $http):array{
|
||||
$controller=new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierController;$workflow=app(RequisitionWorkflow::class);
|
||||
$class=$action==='review'?Request::class:\Tempo\Purchasing\Embedded\Http\Requests\SaveSupplierRequest::class;
|
||||
$input=$class::create('/embedded/suppliers','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);
|
||||
$input->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$input->setLaravelSession($http->session());
|
||||
if($action!=='review'){
|
||||
$input->setContainer(app())->setRedirector(app('redirect'));
|
||||
$route=new \Illuminate\Routing\Route(['POST'],'embedded/suppliers/{supplier}',fn()=>null);$route->bind($input);if($supplier)$route->setParameter('supplier',$supplier);else $route->forgetParameter('supplier');$input->setRouteResolver(fn()=>$route);
|
||||
abort_if(array_diff(array_keys($data),array_keys($input->rules())),422);$input->validateResolved();
|
||||
}else abort_if(array_diff(array_keys($data),['status','comment','version']),422);
|
||||
if($action==='create'){$controller->store($input,$workflow);$supplier=Supplier::where('nuit',$data['nuit'])->firstOrFail();}
|
||||
elseif($action==='update')$controller->update($input,$supplier,$workflow);
|
||||
else $controller->review($input,$supplier,$workflow);
|
||||
$supplier->refresh();return ['result'=>['supplier_id'=>$supplier->id,'status'=>$supplier->status,'version'=>$supplier->version],'impact'=>['operation'=>$action,'before'=>$action==='create'?null:$supplier->getOriginal(),'after'=>$supplier->toArray()]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_suppliers.view',fn($a)=>$this->viewer($a));return response()->view('supplier-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','search'=>'nullable|string|max:100','status'=>'nullable|in:pending,active,suspended']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($d,$rights){$this->viewer($a);$q=SupplierAccess::query($b,$o['sgf_context_key']);if($d['search']??'')$q->where(fn($w)=>$w->where('name','ilike','%'.$d['search'].'%')->orWhere('nuit','like','%'.$d['search'].'%'));if($d['status']??'')$q->where('status',$d['status']);$part=app(SupplierParticipation::class);return response()->json(['suppliers'=>$q->orderBy('name')->orderBy('id')->paginate(15,['id','name','nuit','status','version'],'page',(int)($d['page']??1)),'new_revision'=>$this->revision(null),'can_prepare'=>$rights['prepare']&&$part->canPrepare($a)]);});}
|
||||
public function show(int $supplier,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$rights=$this->rights();return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$rights){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$part=app(SupplierParticipation::class);return response()->json(['supplier'=>$s,'revision'=>$this->revision($s),'documents_enabled'=>config('receipt_qa.supplier_documents_enabled')===true&&\Illuminate\Support\Facades\Gate::allows('viewDocuments',$s),'geography_enabled'=>config('geography.enabled')===true,'capabilities'=>['prepare'=>$rights['prepare']&&$part->canPrepare($a),'review'=>$rights['review']&&$part->canReview($a,$s)]]);});}
|
||||
public function history(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);return response()->json(AuditLog::where('tenant_id',$s->tenant_id)->where(function($q)use($supplier){$q->where(fn($w)=>$w->whereIn('action',['supplier.created','supplier.updated','supplier.reviewed'])->where('after->id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_uploaded')->where('after->document->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_downloaded')->where('after->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.document')->where('after->result->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.command')->where('after->result->supplier_id',$supplier));})->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function preview(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:create,update,review','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>16384,422);abort_unless(($supplier===0)===($d['action']==='create'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_suppliers.'.$this->right($d['action']),function($a,$b,$f,$o)use($supplier,$r,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$d['action']==='review'?'finance':'procurement']);abort_unless(hash_equals($this->revision($s),$d['revision']),409,'A ficha mudou. Confira novamente.');$before=$s?->toArray();$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$s,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$out['impact']['before']=$before;if($d['action']==='create')unset($out['impact']['after']['id'],$out['impact']['after']['created_at'],$out['impact']['after']['updated_at']);$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
|
||||
public function confirm(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['create','update','review'],true),409);return $rt->run('purchasing_suppliers.'.$this->right($cut['action']),function($a,$b,$f,$o)use($supplier,$r,$cut){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$cut['action']==='review'?'finance':'procurement']);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou contexto mudou.');$hash=$this->hash([$supplier,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.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 outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->revision($s),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s?->toArray();$out=$this->dispatch($cut['action'],$cut['data'],$s,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.command',null,$before,$o+['supplier_scope'=>SupplierAccess::scope($o['sgf_context_key']),'operation_key'=>$cut['operation_key'],'binding_revision'=>$f,'request_hash'=>$hash,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/** Storage is confined to the already verified composition, never public or client storage. */
|
||||
final class SupplierDocumentFiles
|
||||
{
|
||||
public function root(bool $preview=false,string $resource='supplier'):string {
|
||||
abort_unless(in_array($resource,['supplier','receipt'],true),403);
|
||||
$profile=realpath((string)config('receipt_qa.profile'));abort_unless($profile,403);
|
||||
$base=str_replace('\\','/',dirname($profile));$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&preg_match('~^'.preg_quote('C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-','~').'[a-f0-9]{12}$~iD',$base),403);
|
||||
$container=$base.'/'.$resource.($preview?'-document-previews':'-documents');abort_if(is_link($container),403);
|
||||
if(!is_dir($container))mkdir($container,0700);
|
||||
abort_unless(realpath($container)&&str_starts_with(strtolower(realpath($container)),strtolower(realpath($base).DIRECTORY_SEPARATOR)),403);
|
||||
$root=$preview?$container.'/'.bin2hex(random_bytes(16)):$container;
|
||||
if(!is_dir($root))mkdir($root,0700);abort_unless(realpath($root)&&!is_link($root),403);return $root;
|
||||
}
|
||||
public function using(string $root,\Closure $work,string $disk='supplier_documents'):mixed {
|
||||
abort_unless(in_array($disk,['supplier_documents','receipt_documents'],true),403);
|
||||
$before=config('filesystems.disks.'.$disk);config(['filesystems.disks.'.$disk=>['driver'=>'local','root'=>$root,'throw'=>true,'serve'=>false]]);Storage::forgetDisk($disk);
|
||||
try{return $work(Storage::disk($disk));}finally{Storage::forgetDisk($disk);config(['filesystems.disks.'.$disk=>$before]);}
|
||||
}
|
||||
public function path(string $root,string $path,int $tenant,int $supplier):string {
|
||||
abort_unless(preg_match('~^'.$tenant.'/'.$supplier.'/[A-Za-z0-9]{40}\.(pdf|png|jpe?g)$~D',$path),409,'Referência de anexo inválida.');
|
||||
$file=realpath($root.'/'.$path);$base=realpath($root);abort_unless($file&&$base&&!is_link($root.'/'.$path)&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR)),409,'Anexo indisponível.');return $file;
|
||||
}
|
||||
public function discard(string $root,string $path,int $tenant,int $supplier,string $disk='supplier_documents'):void {
|
||||
$this->path($root,$path,$tenant,$supplier);$this->using($root,fn($storage)=>$storage->delete($path),$disk);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\{Request,UploadedFile};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Supplier,SupplierDocument,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,RequisitionWorkflow};
|
||||
|
||||
/** Transport around the original upload/review invalidation and private audited download. */
|
||||
final class SupplierDocumentsController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.supplier_documents_enabled')===true,404); }
|
||||
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function source(int $id,array $binding,string $context):Supplier { return SupplierAccess::query($binding,$context)->find($id)??abort(404); }
|
||||
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
|
||||
private function input(Request $http,object $actor,string $type):Request {
|
||||
$files=$http->file('file')?['file'=>$http->file('file')]:[];
|
||||
$r=Request::create('/embedded/supplier-documents','POST',['document_type'=>$type],[],$files,['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;
|
||||
}
|
||||
private function store(Request $http,object $actor,Supplier $supplier,string $type):SupplierDocument {
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->store($this->input($http,$actor,$type),$supplier,app(RequisitionWorkflow::class));return $supplier->documents()->orderByDesc('id')->firstOrFail();
|
||||
}
|
||||
private function document(Supplier $supplier,int $id):SupplierDocument { return $supplier->documents()->find($id)??abort(404); }
|
||||
private function integrity(SupplierDocument $document,string $root):void {
|
||||
$file=app(SupplierDocumentFiles::class)->path($root,$document->path,(int)$document->tenant_id,(int)$document->supplier_id);
|
||||
abort_unless(filesize($file)===(int)$document->size&&hash_equals($document->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
|
||||
}
|
||||
public function index(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$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 $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);return response()->json($s->documents()->orderByDesc('id')->paginate(15,['id','supplier_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'],'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
public function preview(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
|
||||
return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$d,$generation,$meta){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(hash_equals($this->hash([$s->toArray()]),$d['revision']),409,'A ficha mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true);$db=DB::connection();$level=$db->transactionLevel();$document=null;$db->beginTransaction();
|
||||
try{$files->using($stage,function()use($r,$a,$s,$d,&$document){$document=$this->store($r,$a,$s,$d['document_type']);});$impact=['document'=>$document->toArray(),'supplier_status'=>'pending','supplier_version'=>$s->fresh()->version,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at'],$impact['document']['updated_at']);}
|
||||
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document)$files->discard($stage,$document->path,(int)$s->tenant_id,$s->id);}
|
||||
$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
|
||||
}
|
||||
public function confirm(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$generation=$this->generation();$meta=$this->meta($r->file('file'));$created=null;
|
||||
try{return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$cut,$generation,$meta,&$created){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
|
||||
$hash=$this->hash([$supplier,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root();$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.document')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$this->integrity($this->document($s,$old->after['result']['document_id']),$root);return response()->json($old->after['result']+['replayed'=>true]);}
|
||||
abort_unless(hash_equals($this->hash([$s->toArray()]),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s->toArray();$document=$files->using($root,fn()=>$this->store($r,$a,$s,$cut['document_type']));$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$s->tenant_id,'supplier'=>$s->id];$this->integrity($document,$root);$result=['supplier_id'=>$s->id,'document_id'=>$document->id,'document'=>$document->toArray(),'supplier_version'=>$s->fresh()->version,'supplier_status'=>'pending'];
|
||||
app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.document',null,$before,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
|
||||
catch(\Throwable $e){
|
||||
// Delete only a newly created file after observing that its DB row did not commit.
|
||||
// If rollback/commit is uncertain or verification fails, retain it for recovery.
|
||||
if($created){try{$exists=DB::connection('receipt_native')->table('supplier_documents')->where(['tenant_id'=>$created['tenant'],'supplier_id'=>$created['supplier'],'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$created['supplier']);}catch(\Throwable){/* Preserve evidence if outcome cannot be verified. */}}throw $e;
|
||||
}
|
||||
}
|
||||
public function download(int $supplier,int $document,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$document,$r){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);$doc=$this->document($s,$document);$files=app(SupplierDocumentFiles::class);$root=$files->root();$this->integrity($doc,$root);return $files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->download($this->input($r,$a,$doc->document_type),$s,$doc,app(RequisitionWorkflow::class)));});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Modules\Purchasing;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\HistoricalPayments;
|
||||
use Tempo\Finance\Payables\HistoricalCapture;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads explicitly reconciled immutable receipts; never imports or decides payments. */
|
||||
@@ -34,7 +35,7 @@ final class SupplierHistoryReceipts
|
||||
}
|
||||
|
||||
/** Local read snapshots only; no command obtains its source through this batch. */
|
||||
public function page(User $user, array $documents): array
|
||||
public function page(User $user, array $documents, bool $validatedCapture = false): array
|
||||
{
|
||||
abort_unless($this->available(), 503);
|
||||
$adapter = app(SupplierPayables::class); $scope = $adapter->scope($user);
|
||||
@@ -43,14 +44,14 @@ final class SupplierHistoryReceipts
|
||||
->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id');
|
||||
$result = [];
|
||||
foreach ($documents as $id => $source) {
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id)); }
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id), $validatedCapture ? $scope : null); }
|
||||
catch (\DomainException $e) { $result[$id] = $e; }
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function decode(array $source, ?object $receipt): array
|
||||
private function decode(array $source, ?object $receipt, ?array $captureScope = null): array|HistoricalCapture
|
||||
{
|
||||
if (! $receipt) {
|
||||
throw new \DomainException('Histórico não reconciliado para esta origem e contexto.');
|
||||
@@ -66,11 +67,12 @@ final class SupplierHistoryReceipts
|
||||
&& is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) {
|
||||
throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.');
|
||||
}
|
||||
$capture = HistoricalPayments::capture($source, $payload['payments']);
|
||||
$value = $captureScope === null ? null : HistoricalCapture::fromPayments($captureScope, $source, $payload['payments']);
|
||||
$capture = $value ? $value->forSource($captureScope, $source) : HistoricalPayments::capture($source, $payload['payments']);
|
||||
if (($payload['history_hash'] ?? null) !== $capture['history_hash']) {
|
||||
throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.');
|
||||
}
|
||||
|
||||
return $capture['payments'];
|
||||
return $value ?? $capture['payments'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SupplierPayableReportController extends Controller
|
||||
{
|
||||
private function criteria(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['q' => 'sometimes|nullable|string|max:100', 'source' => 'sometimes|required|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($request->query()), array_keys($data)) !== [], 422);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function index(Request $request, SupplierPayables $service)
|
||||
{
|
||||
return response()->json($service->report($request->user(), $this->criteria($request)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $request, SupplierPayables $service)
|
||||
{
|
||||
return response()->view('purchasing.payable-report', ['report' => $service->report($request->user(), $this->criteria($request))])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ final class SupplierPayables
|
||||
public static function guard(): void
|
||||
{
|
||||
$c = DB::connection();
|
||||
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.');
|
||||
$composedQa=app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true);
|
||||
abort_unless(app()->environment('payable-testing')||$composedQa, 403, 'Obrigações apenas no ensaio isolado.');
|
||||
if($composedQa)\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
|
||||
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
|
||||
}
|
||||
@@ -81,7 +83,32 @@ final class SupplierPayables
|
||||
};
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source,
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false));
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false), fn () => $authorize('payments_view', false));
|
||||
}
|
||||
|
||||
/** One predicate for list windows and complete position reports. */
|
||||
private function sourceQuery(array $scope, array $options): array
|
||||
{
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless(mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
$sourceId = array_key_exists('source', $options) ? filter_var($options['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) : null;
|
||||
abort_if($sourceId === false || (isset($options['source']) && is_bool($options['source'])), 422, 'Origem inválida.');
|
||||
if ($sourceId !== null) {
|
||||
abort_unless((clone $query)->where('r.id', $sourceId)->exists(), 403, 'Origem não mapeada neste contexto.');
|
||||
$query->where('r.id', $sourceId);
|
||||
}
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
return [$query, $sourceId];
|
||||
}
|
||||
|
||||
public function listing(User $u, array $options = []): array
|
||||
@@ -93,17 +120,7 @@ final class SupplierPayables
|
||||
$page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25);
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
[$query, $sourceId] = $this->sourceQuery($scope, $options);
|
||||
$pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = [];
|
||||
foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); }
|
||||
$provider = app(SupplierHistoryReceipts::class);
|
||||
@@ -111,6 +128,7 @@ final class SupplierPayables
|
||||
$d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage);
|
||||
$d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
|
||||
$d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
|
||||
if ($sourceId !== null) { $d['listing_query']['source'] = $sourceId; }
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
$d['history_import_available'] = app(SupplierHistoryReceipts::class)->available();
|
||||
$d['history_decisions_available'] = false;
|
||||
@@ -122,4 +140,79 @@ final class SupplierPayables
|
||||
return $d;
|
||||
});
|
||||
}
|
||||
|
||||
/** Current recognised positions, aggregated through the existing engine in bounded windows. */
|
||||
public function report(User $u, array $criteria = []): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($criteria), ['q', 'source']) !== [], 422);
|
||||
abort_if(isset($criteria['q']) && !is_string($criteria['q']), 422);
|
||||
if (array_key_exists('source', $criteria)) {
|
||||
$id = filter_var($criteria['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
abort_if($id === false || is_bool($criteria['source']), 422);
|
||||
$criteria['source'] = $id;
|
||||
}
|
||||
$own = DB::transactionLevel() === 0;
|
||||
return DB::transaction(function () use ($u, $criteria, $own): array {
|
||||
if ($own) DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
else abort_unless(in_array(DB::selectOne('SHOW transaction_isolation')->transaction_isolation, ['repeatable read', 'serializable'], true), 409, 'O relatório exige uma fotografia coerente própria.');
|
||||
$scope = $this->scope($u);
|
||||
abort_unless($this->can($u, 'view'), 403);
|
||||
$criteria['q'] = trim((string) ($criteria['q'] ?? ''));
|
||||
$hash = hash_init('sha256');
|
||||
hash_update($hash, Payables::canonical(['scope' => $scope, 'criteria' => $criteria, 'basis' => 'current_recognised_positions', 'window' => 100])."\n");
|
||||
$total = ['count' => 0, 'principal_minor' => 0, 'unknown_balance_count' => 0, 'unknown_due_count' => 0];
|
||||
$known = ['count' => 0, 'principal_minor' => 0, 'historical_confirmed_minor' => 0, 'net_allocated_minor' => 0, 'balance_minor' => 0];
|
||||
[$query] = $this->sourceQuery($scope, $criteria + ['recognition' => 'recognised']);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless(is_string($person) && $person !== '', 403);
|
||||
$provider = app(SupplierHistoryReceipts::class); $historical = $provider->available();
|
||||
$read = function () use ($u, $scope, $person): void {
|
||||
$context = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $u->id && $context->userId() === $u->id, 403);
|
||||
abort_unless(($context->identifiers() + ['unit_id' => $context->unitId()]) === $scope, 409);
|
||||
abort_unless($this->can($u, 'view'), 403);
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409);
|
||||
};
|
||||
$last = null; $windows = 0;
|
||||
do {
|
||||
$window = clone $query;
|
||||
if ($last !== null) $window->where('r.id', '<', $last);
|
||||
$rows = $window->orderByDesc('r.id')->limit(100)->get(['r.*']);
|
||||
if ($rows->isEmpty()) break;
|
||||
$documents = [];
|
||||
foreach ($rows as $row) $documents[(int) $row->id] = $this->receiptDocument($row);
|
||||
$histories = $historical ? $provider->page($u, $documents, true) : [];
|
||||
$engine = new Payables(DB::connection(), $u->id, $person, $scope,
|
||||
static fn () => throw new \LogicException('Relatórios nunca executam comandos.'),
|
||||
fn (int $id) => $documents[$id] ?? abort(403),
|
||||
$historical ? static function (int $id) use ($histories) {
|
||||
$result = $histories[$id] ?? new \DomainException('Histórico fora da janela.');
|
||||
if ($result instanceof \DomainException) throw $result;
|
||||
return $result;
|
||||
} : null, $read);
|
||||
$data = $engine->positionsPage(array_keys($documents));
|
||||
foreach ($data['documents'] as $doc) hash_update($hash, Payables::canonical(['local_source_id' => $doc['local_id'], 'current_revision' => $doc['revision']])."\n");
|
||||
foreach ($data['obligations'] as $o) {
|
||||
abort_unless($o['currency'] === 'MZN', 409, 'Moeda incompatível no relatório; nenhuma soma entre moedas.');
|
||||
$total['count']++; $total['principal_minor'] += $o['principal_minor'];
|
||||
if ($o['due_on'] === null) $total['unknown_due_count']++;
|
||||
if ($o['source_conflict'] || $o['balance_minor'] === null) $total['unknown_balance_count']++;
|
||||
else {
|
||||
$known['count']++; $known['principal_minor'] += $o['principal_minor'];
|
||||
$known['historical_confirmed_minor'] += $o['confirmed_payments_minor'];
|
||||
$known['net_allocated_minor'] += $o['allocated_payments_minor'];
|
||||
$known['balance_minor'] += $o['balance_minor'];
|
||||
}
|
||||
hash_update($hash, Payables::canonical($o)."\n");
|
||||
}
|
||||
$windows++; $last = (int) $rows->last()->id;
|
||||
} while ($rows->count() === 100);
|
||||
abort_unless($known['principal_minor'] - $known['historical_confirmed_minor'] - $known['net_allocated_minor'] === $known['balance_minor'], 409, 'Componentes financeiros não reconciliados.');
|
||||
$total['balance_minor'] = $total['unknown_balance_count'] === 0 ? $known['balance_minor'] : null;
|
||||
return ['version' => 'SGF-PAYABLE-POSITION-REPORT-SIM-v1', 'synthetic_only' => true,
|
||||
'basis' => 'current_recognised_positions', 'captured_at_utc' => DB::selectOne("SELECT transaction_timestamp() AT TIME ZONE 'UTC' AS captured")->captured,
|
||||
'scope' => $scope, 'criteria' => $criteria, 'currency' => 'MZN', 'totals' => $total, 'known_positions' => $known,
|
||||
'positions_sha256' => hash_final($hash), 'window_size' => 100, 'windows' => $windows];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
private function options(Request $r): array
|
||||
public function listingOptions(Request $r): array
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
$d = $r->validate(['source' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100',
|
||||
'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
@@ -21,17 +21,21 @@ final class SupplierPayablesController extends Controller
|
||||
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user(), $this->options($r)))->header('Cache-Control', 'no-store');
|
||||
return response()->json($s->listing($r->user(), $this->listingOptions($r)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user(), $this->options($r));
|
||||
$data = $s->listing($r->user(), $this->listingOptions($r));
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
$flow=[];
|
||||
if(config('general_ledger.synthetic_enabled')===true&&isset($data['listing_query']['source'])&&app(\App\Modules\Identity\Services\Permissions::class)->has($r->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id'])){
|
||||
$flow=['ledger_flow_url'=>'/general-ledger/flow?source='.$data['listing_query']['source'],'ledger_flow_scope'=>$scope];
|
||||
}
|
||||
|
||||
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
return response(Workspace::render($data, $flow+['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'document_filter' => 'source', 'report_url' => '/supplier-payables/report', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, SupplierPayables $s)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Tempo\Finance\Payables\SettlementHttp;
|
||||
|
||||
final class SupplierPaymentsController extends SettlementHttp
|
||||
@@ -9,4 +10,27 @@ final class SupplierPaymentsController extends SettlementHttp
|
||||
protected const MODE = 'sgf';
|
||||
|
||||
protected const SERVICE = SupplierPayables::class;
|
||||
|
||||
private const PAYMENT_FILTERS = ['payments_page', 'allocation_proposals_page', 'allocations_page', 'payment_events_page', 'payment_per_page', 'payment_q', 'payment_state', 'payment'];
|
||||
protected function ledgerSettings(Request $request):array
|
||||
{
|
||||
if(config('general_ledger.cycle_enabled')!==true)return [];
|
||||
$scope=app(SupplierPayables::class)->scope($request->user());
|
||||
if(!app(\App\Modules\Identity\Services\Permissions::class)->has($request->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']))return [];
|
||||
return ['ledger_cycle_url'=>'/general-ledger/cycle','ledger_scope'=>$scope];
|
||||
}
|
||||
|
||||
protected function obligationOptions(Request $request): array
|
||||
{
|
||||
return app(SupplierPayablesController::class)->listingOptions($request->duplicate(array_diff_key($request->query(), array_flip(self::PAYMENT_FILTERS))));
|
||||
}
|
||||
|
||||
protected function settlementOptions(Request $request): ?array
|
||||
{
|
||||
return $request->validate(['payments_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'allocation_proposals_page' => 'sometimes|integer|min:1|max:100000', 'allocations_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'payment_events_page' => 'sometimes|integer|min:1|max:100000', 'payment_per_page' => 'sometimes|integer|in:25,100',
|
||||
'payment_q' => 'sometimes|nullable|string|max:100', 'payment_state' => 'sometimes|in:all,proposed,confirmed,rejected',
|
||||
'payment' => 'sometimes|required|integer|min:1']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/** Navigation only: every destination retains its own current native authority checks. */
|
||||
final class WorkspaceNavigation
|
||||
{
|
||||
public function links():array
|
||||
{
|
||||
if(config('receipt_qa.enabled')!==true||!app()->environment('budget-testing'))return [];
|
||||
$c=app(CompanyContext::class);$scope=$c->identifiers();if(!$c->unitId())return [];
|
||||
$definitions=[
|
||||
['requests','requests','Requisições','Preparar, submeter e obter aprovação distinta.'],
|
||||
['suppliers','suppliers','Fornecedores','Conferir cadastro e revisão antes das propostas.'],
|
||||
['sourcing','sourcing','Propostas e adjudicação','Comparar propostas e fundamentar a escolha; aprovação distinta.'],
|
||||
['orders','orders','Ordens','Emitir a partir da adjudicação aprovada.'],
|
||||
['enabled','receipts','Recepções','Conferir quantidades e qualidade da ordem emitida.'],
|
||||
['invoices','invoices','Facturas','Registar e validar o documento recebido.'],
|
||||
['obligations','obligations','Obrigações','Preparar fotografia e reconhecer por pessoa distinta.'],
|
||||
];$links=[];$permissions=app(Permissions::class);
|
||||
foreach($definitions as[$flag,$path,$label,$description]){
|
||||
$flag=$flag==='enabled'?$flag:$flag.'_enabled';$right='purchasing_'.$path.'.view';
|
||||
if(config('receipt_qa.'.$flag)===true&&$permissions->has($c->userId(),$scope['tenant_id'],$scope['company_id'],$right,$c->unitId()))$links[]=['url'=>'/purchasing/'.$path,'label'=>$label,'description'=>$description,'right'=>$right];
|
||||
}
|
||||
if(config('receipt_qa.dossiers_enabled')===true&&array_filter($links,fn($link)=>$link['right']==='purchasing_requests.view'))$links[]=['url'=>'/purchasing/dossiers','label'=>'Dossiês e evidência','description'=>'Consultar as etapas e o histórico autorizado da mesma requisição.','right'=>'purchasing_requests.view'];
|
||||
return $links;
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime)
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);$links=$this->links();abort_unless($links,403);
|
||||
$runtime->run($links[0]['right'],fn()=>null);
|
||||
return response()->view('purchasing-workspace',['links'=>$links]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user