Files
2026-11-02 20:27:11 +02:00

101 lines
10 KiB
PHP

<?php
namespace Tempo\Purchasing\SgfSourcing;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Tempo\Purchasing\SgfCandidate\Port;
use Tempo\Purchasing\Sourcing\Sourcing;
use Tempo\Purchasing\Internal\RequisitionFailure;
final class Workspace
{
private Port $port;
public function __construct(private User $actor){$this->port=new Port($actor);}
private function q(string $table){return DB::table('ss_'.$table)->where($this->port->scope());}
private function auth(string $action):void{
$this->port->authorize('view',null);
if($action==='view')return;
$right=match($action){'select','quote'=>'prepare','evaluate'=>'evaluate','propose'=>'propose','confirm'=>'confirm',default=>abort(404)};
$s=$this->port->scope();app(Permissions::class)->require($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);
}
private function policy(array $state=[]):array{return $state['policy']??json_decode(file_get_contents(__DIR__.'/comparison-defaults.json'),true,32,JSON_THROW_ON_ERROR);}
private function state(int $id):array{
$row=$this->q('processes')->where('request_id',$id)->lockForUpdate()->first();
$state=$row?json_decode($row->payload,true,64,JSON_THROW_ON_ERROR):['version'=>0,'suppliers'=>[],'quotes'=>[],'proposals'=>[],'confirmed'=>null];
$s=$this->port->scope();
foreach($state['quotes'] as $key=>&$quote){
$supplier=$this->q('suppliers')->where('id',$key)->first();
$payload=$supplier?json_decode($supplier->payload,true):[];
$quote['supplier_eligible']=($payload['active']??false)&&!($payload['blocked']??true)&&($payload['review_until']??'')>=date('Y-m-d');
foreach($quote['evaluations'] as &$evaluation){
$id=$evaluation['evaluator_id'];
$evaluation['eligible']=DB::table('company_memberships')->where(['user_id'=>$id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists()
&&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'unit.view',$s['unit_id'])
&&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.evaluate',$s['unit_id']);
}unset($evaluation);
}unset($quote);
return $state;
}
public function read(int $id):array{return $this->port->atomic(function()use($id){
abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
$this->auth('view');$r=$this->port->load($id);$this->port->authorize('view',$r);$s=$this->state($id);
return ['request'=>$r,'state'=>$s,'policy'=>$this->policy($s),'comparison'=>(new Sourcing)->comparison($s,$this->policy($s),date('Y-m-d')),'suppliers'=>$this->q('suppliers')->orderBy('id')->get(),'history'=>$this->q('events')->where('request_id',$id)->orderBy('version')->get(),'rights'=>$this->rights()];
});}
private function rights():array{
$s=$this->port->scope();$result=[];foreach(['prepare','evaluate','propose','confirm'] as $right)$result[$right]=app(Permissions::class)->has($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);return $result;
}
public function command(int $id,string $action,array $input):array{return $this->port->atomic(function()use($id,$action,$input){
abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
$this->auth($action);$r=$this->port->load($id);$this->port->authorize('view',$r);
$meta=Validator::make($input,['operation_key'=>'required|uuid','version'=>'required|integer|min:0'])->validate();
$meta['version']=(int)$meta['version'];
$hash=hash('sha256',json_encode([$id,$action,$input],JSON_THROW_ON_ERROR));
$old=$this->q('commands')->where('actor_id',$this->actor->id)->where('operation_key',$meta['operation_key'])->first();
if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com outros dados.');return json_decode($old->result,true);}
$state=$this->state($id);abort_unless($state['version']===$meta['version'],409,'Versão do processo alterada.');
$origin=['request_id'=>$r['id'],'origin_key'=>$r['origin_key'],'request_version'=>$r['version'],'budget_origin_id'=>$r['budget_origin_id'],'snapshot_hash'=>hash('sha256',json_encode([$r['data'],$r['approval_snapshot'],$r['reservation']],JSON_THROW_ON_ERROR))];
if($state['version']===0){$state['origin']=$origin;$state['policy']=$this->policy();}else abort_unless(($state['origin']??null)==$origin,409,'A origem mudou; reconcilie o processo sem criar outra reserva.');
$rules=match($action){
'select'=>['supplier_id'=>'required|integer|min:1'],
'quote'=>['supplier_id'=>'required|integer|min:1','reference'=>'required|string|max:100','currency'=>'required|in:MZN','valid_until'=>'required|date_format:Y-m-d','delivery_days'=>'required|integer|between:1,3650','payment_terms'=>'required|string|min:3|max:500','items'=>'required|array|min:1|max:100','items.*.line'=>'required|integer|min:0','items.*.unit_price'=>'required|string|regex:/^\d{1,10}(\.\d{1,2})?$/','items.*.discount_type'=>'sometimes|in:none,percent,amount','items.*.discount_value'=>'sometimes|string|regex:/^\d{1,10}(\.\d{1,2})?$/'],
'evaluate'=>['supplier_id'=>'required|integer|min:1','quote_revision'=>'required|integer|min:1','quality'=>'required|numeric|between:0,10','history'=>'required|numeric|between:0,10','payment'=>'required|numeric|between:0,10','no_conflict'=>'accepted','reason'=>'required|string|min:10|max:2000'],
'propose'=>['supplier_id'=>'required|integer|min:1','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'],
'confirm'=>['proposal_index'=>'required|integer|min:0','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000'],default=>abort(404)};
$data=Validator::make($input,$rules)->validate();
foreach(['supplier_id','quote_revision','proposal_index','delivery_days'] as $field)if(isset($data[$field]))$data[$field]=(int)$data[$field];
if(isset($data['items']))foreach($data['items'] as &$line)$line['line']=(int)$line['line'];unset($line);
if($action==='select'){
$supplier=$this->q('suppliers')->where('id',$data['supplier_id'])->lockForUpdate()->first();abort_unless($supplier,404);
$payload=json_decode($supplier->payload,true);
$data['supplier']=['id'=>$supplier->id,'reference'=>$supplier->reference,'snapshot'=>$payload,'eligible'=>$payload['active']&&!$payload['blocked']&&$payload['review_until']>=date('Y-m-d')];
}
if(in_array($action,['propose','confirm'],true)){
$supplier=$action==='propose'?$data['supplier_id']:(end($state['proposals'])['supplier_id']??null);
$quote=$state['quotes'][$supplier]??null;abort_unless($quote,422,'Cotação em falta.');
// Do not invent an automatic budget revision or create another reserve.
$budget=DB::table('cb_budgets')->where($this->port->scope())->where('id',$r['reservation']['budget_id']??0)->lockForUpdate()->first();
$entries=DB::table('cb_budget_entries')->where($this->port->scope())->where('requisition_id',$r['budget_origin_id']);
$basis=$budget?($budget->includes_tax?$quote['total_minor']:$quote['subtotal_minor']):null;
abort_unless($budget&&$budget->approved_by&&$basis===(int)(clone $entries)->sum('reserved_minor')&&(int)(clone $entries)->sum('committed_minor')===0,409,'A alteração do valor exige adaptação orçamental ainda não integrada neste percurso. Nenhum movimento foi criado.');
$rules=$r['approval_snapshot'];$approvalAmount=$rules['approval_includes_tax']?$quote['total_minor']:$quote['subtotal_minor'];
abort_unless($approvalAmount<=$rules['first_limit_minor'],409,'A cadeia de adjudicação acima do primeiro limite ainda requer integração dos níveis existentes de Compras.');
if($action==='confirm'){
$members=$this->port->setup()['members'];
abort_unless(count(array_filter($members,fn($member)=>$member['user_id']===$this->actor->id&&$member['department_ref']===$r['department_ref']))===1,403,'Confirmação exige atribuição ao departamento da origem.');
}
}
$next=(new Sourcing)->transition($state,$r,$this->actor->id,$action,$data,$this->policy($state),date('Y-m-d'));
$next['version']=$state['version']+1;$now=now();$scope=$this->port->scope();
$values=['version'=>$next['version'],'payload'=>json_encode($next,JSON_THROW_ON_ERROR)];
if($state['version']===0)$this->q('processes')->insert($scope+['request_id'=>$id,...$values,'created_at'=>$now]);else $this->q('processes')->where('request_id',$id)->update($values);
$this->q('events')->insert($scope+['request_id'=>$id,'version'=>$next['version'],'actor_id'=>$this->actor->id,'action'=>$action,'payload'=>json_encode(['before_version'=>$state['version'],'input'=>$data,'state'=>$next],JSON_THROW_ON_ERROR),'created_at'=>$now]);
$failFile=storage_path('framework/sourcing-failure.fixture');clearstatcache(true,$failFile);
if(app()->environment('testing')&&preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',DB::connection()->getDatabaseName())&&is_file($failFile))throw new \RuntimeException('Injected sourcing persistence failure');
$result=['request_id'=>$id,'version'=>$next['version'],'confirmed'=>$next['confirmed']!==null];
$this->q('commands')->insert($scope+['actor_id'=>$this->actor->id,'operation_key'=>$meta['operation_key'],'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>$now]);
return $result;
});}
}