commit14
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Sourcing;
|
||||
|
||||
use Tempo\Purchasing\Economic\ProposalScoring;
|
||||
use Tempo\Purchasing\Economic\QuotationPricing;
|
||||
use Tempo\Purchasing\Internal\RequisitionFailure;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
|
||||
/** Host-independent sourcing transitions. No messaging, budget movements or payments. */
|
||||
final class Sourcing
|
||||
{
|
||||
public function comparison(array $state, array $policy, string $today): array
|
||||
{
|
||||
$rows=[];
|
||||
foreach($state['quotes'] as $id=>$q){
|
||||
$evaluations=array_values(array_filter($q['evaluations'],fn($e)=>$e['quote_revision']===$q['revision']&&($e['eligible']??false)));
|
||||
$rows[]=[...$q,'id'=>(int)$id,'eligible'=>($q['supplier_eligible']??false)&&$q['valid_until']>=$today,'weights'=>$policy['weights'],'evaluation_count'=>count($evaluations),'qualified_evaluations'=>$evaluations];
|
||||
}
|
||||
$ranking=(new ProposalScoring)->rank($rows,$policy['minimum_evaluators']);
|
||||
$eligible=array_filter($rows,fn($r)=>$r['eligible']);
|
||||
$ready=count($eligible)>=$policy['minimum_proposals'];
|
||||
foreach($eligible as $row)$ready=$ready&&$row['evaluation_count']>=$policy['minimum_evaluators'];
|
||||
return ['rows'=>$rows,'ready'=>$ready,'top_ids'=>$ranking['top_ids'],'scores'=>$ranking['scores'],'fingerprint'=>hash('sha256',json_encode(['rows'=>$rows,'policy'=>$policy],JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
|
||||
public function transition(array $state,array $request,int $actor,string $action,array $data,array $policy,string $today): array
|
||||
{
|
||||
$fail=fn(string $message,int $status=409)=>throw new RequisitionFailure($message,$status);
|
||||
if($request['status']!=='approved')$fail('A requisição de origem não está aprovada.');
|
||||
if(($state['confirmed']??null)!==null)$fail('Adjudicação confirmada: processo fechado.');
|
||||
if(in_array($action,['evaluate','propose','confirm'],true)&&$actor===$request['requester_id'])$fail('O requerente não pode avaliar, propor ou confirmar esta adjudicação.',403);
|
||||
switch($action){
|
||||
case 'select':
|
||||
$supplier=$data['supplier'];
|
||||
if(!$supplier['eligible'])$fail('Fornecedor não elegível.',422);
|
||||
if(isset($state['suppliers'][$supplier['id']]))$fail('Fornecedor já seleccionado.');
|
||||
$state['suppliers'][$supplier['id']]=$supplier;
|
||||
break;
|
||||
case 'quote':
|
||||
$supplier=(int)$data['supplier_id'];
|
||||
if(!isset($state['suppliers'][$supplier]))$fail('Seleccione um fornecedor autorizado.',422);
|
||||
$old=$state['quotes'][$supplier]??null;$lines=[];$subtotal=0;$tax=0;
|
||||
if(count($data['items'])!==count($request['data']['items']))$fail('A cotação deve cobrir exactamente as linhas da requisição.',422);
|
||||
foreach($request['data']['items'] as $index=>$source){
|
||||
$input=$data['items'][$index]??$fail('Linha em falta.',422);
|
||||
if(($input['line']??null)!==$index)$fail('Referência de linha inválida.',422);
|
||||
$pricing=(new QuotationPricing)->unit($input['unit_price'],$input['discount_type']??'none',$input['discount_value']??'0');
|
||||
$net=BigDecimal::of($source['quantity'])->multipliedBy($pricing['net_unit_minor'])->toScale(0,RoundingMode::HalfUp)->toInt();
|
||||
$vat=BigDecimal::of($net)->multipliedBy($source['tax_rate'])->dividedBy(100,0,RoundingMode::HalfUp)->toInt();
|
||||
$subtotal+=$net;$tax+=$vat;
|
||||
if($subtotal+$tax>100000000000000)$fail('Valor acima do limite técnico.',422);
|
||||
$lines[]=['line'=>$index,'source'=>$source,'pricing'=>$pricing,'subtotal_minor'=>$net,'tax_minor'=>$vat];
|
||||
}
|
||||
if($data['valid_until']<$today)$fail('Cotação expirada.',422);
|
||||
$state['quotes'][$supplier]=['supplier_id'=>$supplier,'revision'=>($old['revision']??0)+1,'reference'=>$data['reference'],'currency'=>'MZN','delivery_days'=>$data['delivery_days'],'payment_terms'=>$data['payment_terms'],'valid_until'=>$data['valid_until'],'author_id'=>$actor,'recorded_on'=>$today,'items'=>$lines,'subtotal_minor'=>$subtotal,'tax_minor'=>$tax,'total_minor'=>$subtotal+$tax,'evaluations'=>[],'supplier_eligible'=>true];
|
||||
break;
|
||||
case 'evaluate':
|
||||
$id=$data['supplier_id'];$q=$state['quotes'][$id]??$fail('Cotação inexistente.',404);
|
||||
if(!($q['supplier_eligible']??false)||$q['valid_until']<$today)$fail('Fornecedor inelegível ou cotação expirada.',422);
|
||||
if($q['revision']!==$data['quote_revision'])$fail('Versão da cotação alterada.');
|
||||
$state['quotes'][$id]['evaluations'][$actor]=['evaluator_id'=>$actor,'quote_revision'=>$q['revision'],'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'no_conflict'=>true,'eligible'=>true,'reason'=>$data['reason']];
|
||||
break;
|
||||
case 'propose':
|
||||
$comparison=$this->comparison($state,$policy,$today);
|
||||
if(!$comparison['ready'])$fail('São necessárias '.$policy['minimum_proposals'].' propostas elegíveis e '.$policy['minimum_evaluators'].' avaliações independentes por proposta.',422);
|
||||
if(!hash_equals($comparison['fingerprint'],$data['comparison_hash']))$fail('Comparação desactualizada.');
|
||||
if(!in_array($data['supplier_id'],$comparison['top_ids'],true))$fail('A selecção deve respeitar a classificação da comparação.',422);
|
||||
if(($data['delivery_date']??'')<$today||trim($data['delivery_location']??'')==='')$fail('Indique data e local de entrega previstos, como na adjudicação de Compras.',422);
|
||||
$state['proposals'][]=['supplier_id'=>$data['supplier_id'],'comparison_hash'=>$comparison['fingerprint'],'comparison_version'=>$state['version'],'comparison'=>$comparison,'proposed_by'=>$actor,'reason'=>$data['reason'],'delivery_date'=>$data['delivery_date'],'delivery_location'=>$data['delivery_location'],'proposed_on'=>$today];
|
||||
break;
|
||||
case 'confirm':
|
||||
$proposal=end($state['proposals']);
|
||||
if(!$proposal)$fail('Proposta de adjudicação em falta.',422);
|
||||
if($proposal['proposed_by']===$actor)$fail('A confirmação exige outra identidade.',403);
|
||||
if(($data['proposal_index']??null)!==array_key_last($state['proposals']))$fail('Proposta de adjudicação alterada.');
|
||||
$comparison=$this->comparison($state,$policy,$today);
|
||||
if(!$comparison['ready']||!hash_equals($proposal['comparison_hash'],$comparison['fingerprint'])||!hash_equals($proposal['comparison_hash'],$data['comparison_hash']))$fail('As cotações, avaliações ou elegibilidade alteraram-se; proponha novamente.');
|
||||
$state['confirmed']=['proposal'=>$proposal,'confirmed_by'=>$actor,'reason'=>$data['reason'],'confirmed_on'=>$today];
|
||||
break;
|
||||
default:$fail('Operação não suportada.',404);
|
||||
}
|
||||
if(in_array($action,['quote','evaluate'],true))$state['comparison_changed']=true;
|
||||
return $state;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user