101 lines
10 KiB
PHP
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;
|
|
});}
|
|
}
|