commit de compras

This commit is contained in:
2026-11-04 09:37:20 +02:00
parent fcf31e8d48
commit 0b05a1192c
741 changed files with 114127 additions and 116 deletions
@@ -0,0 +1,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);});}
}