Files
SGF/app/Modules/Purchasing/SourcingController.php
2026-11-04 09:37:20 +02:00

78 lines
14 KiB
PHP

<?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);});}
}