177 lines
8.4 KiB
PHP
177 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\ProposalEvaluation;
|
|
use App\Models\Quotation;
|
|
use App\Models\Requisition;
|
|
use App\Models\User;
|
|
use Brick\Math\BigDecimal;
|
|
use Brick\Math\BigRational;
|
|
use Brick\Math\Exception\MathException;
|
|
use Brick\Math\RoundingMode;
|
|
|
|
class ProposalComparison
|
|
{
|
|
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
|
public function inspect(Requisition $requisition): array
|
|
{
|
|
$quotes = Quotation::query()->where('requisition_id', $requisition->id)
|
|
->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get();
|
|
$guard = app(ConflictGuard::class);
|
|
$referenceWeights = $quotes->first()?->weights;
|
|
$rows = [];
|
|
foreach ($quotes as $quote) {
|
|
$reasons = [];
|
|
if (! $quote->supplier?->eligible() || $quote->valid_until < today()) {
|
|
$reasons[] = 'Fornecedor não elegível ou proposta expirada.';
|
|
}
|
|
if (! $this->validWeights($quote->weights) || $quote->weights != $referenceWeights) {
|
|
$reasons[] = 'As ponderações das propostas não são comparáveis.';
|
|
}
|
|
if (! $this->validAmounts($quote, $requisition)) {
|
|
$reasons[] = 'Moeda, linhas ou valores inconsistentes com a requisição.';
|
|
}
|
|
if ($guard->processBlocked($requisition, $quote->supplier_id)) {
|
|
$reasons[] = 'Conflito de interesses de um interveniente.';
|
|
}
|
|
$evaluations = $quote->evaluations->filter(fn ($evaluation) => $this->eligibleEvaluator($evaluation, $requisition))->values();
|
|
$rows[] = [
|
|
...$quote->toArray(), 'id' => $quote->id, 'supplier_id' => $quote->supplier_id,
|
|
'weights' => $quote->weights, 'subtotal_minor' => $quote->subtotal_minor, 'delivery_days' => $quote->delivery_days,
|
|
'items' => $quote->items->toArray(), 'evaluations' => $quote->evaluations->toArray(),
|
|
'eligible' => $reasons === [], 'ineligibility_reasons' => $reasons,
|
|
'qualified_evaluations' => $evaluations->toArray(), 'evaluation_count' => $evaluations->count(),
|
|
'score' => null, 'score_minor' => null,
|
|
];
|
|
}
|
|
$eligible = array_values(array_filter($rows, fn ($row) => $row['eligible']));
|
|
$minimum = null;
|
|
$days = null;
|
|
foreach ($eligible as $row) {
|
|
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
|
|
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
|
|
}
|
|
$best = null;
|
|
$topIds = [];
|
|
foreach ($rows as &$row) {
|
|
if ($row['eligible'] && $row['evaluation_count'] >= 2) {
|
|
$weights = $row['weights'];
|
|
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
|
|
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
|
|
foreach (['quality', 'history', 'payment'] as $criterion) {
|
|
$sum = BigRational::zero();
|
|
foreach ($row['qualified_evaluations'] as $evaluation) {
|
|
$sum = $sum->plus($evaluation[$criterion]);
|
|
}
|
|
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
|
|
}
|
|
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
|
|
$row['score_minor'] = $minor;
|
|
$row['score'] = $minor / 100;
|
|
if ($best === null || $minor > $best) {
|
|
$best = $minor;
|
|
$topIds = [$row['id']];
|
|
} elseif ($minor === $best) {
|
|
$topIds[] = $row['id'];
|
|
}
|
|
}
|
|
}
|
|
unset($row);
|
|
$basis = [];
|
|
foreach ($rows as $row) {
|
|
$basis[] = [
|
|
'quotation' => array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'delivery_days', 'valid_until', 'payment_terms', 'subtotal_minor', 'tax_minor', 'total_minor', 'currency', 'weights'])),
|
|
'items' => $row['items'], 'evaluations' => $row['evaluations'],
|
|
'qualified_ids' => array_column($row['qualified_evaluations'], 'id'),
|
|
'eligible' => $row['eligible'], 'score_minor' => $row['score_minor'],
|
|
];
|
|
}
|
|
$ready = count($eligible) >= 3 && count(array_unique(array_column($eligible, 'supplier_id'))) >= 3;
|
|
foreach ($eligible as $row) {
|
|
$ready = $ready && $row['evaluation_count'] >= 2;
|
|
}
|
|
|
|
return ['rows' => $rows, 'ready' => $ready, 'top_ids' => $topIds, 'fingerprint' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))];
|
|
}
|
|
|
|
public function eligibleEvaluator(ProposalEvaluation $evaluation, Requisition $requisition): bool
|
|
{
|
|
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($evaluation->evaluator_id);
|
|
if (! $user || ! $evaluation->no_conflict || $user->id === $requisition->requester_id) {
|
|
return false;
|
|
}
|
|
if (! $this->eligibleRole($user, $requisition)) {
|
|
return false;
|
|
}
|
|
foreach (['quality', 'history', 'payment'] as $criterion) {
|
|
if (! is_numeric($evaluation->$criterion) || $evaluation->$criterion < 0 || $evaluation->$criterion > 10) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function eligibleRole(User $user, Requisition $requisition): bool
|
|
{
|
|
return $user->tenant_id === $requisition->tenant_id &&
|
|
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
|
|
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
|
|
}
|
|
|
|
/** @param array<string, mixed> $weights */
|
|
private function validWeights(array $weights): bool
|
|
{
|
|
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
|
|
if (count($weights) !== count($keys) || array_sum($weights) !== 100) {
|
|
return false;
|
|
}
|
|
foreach ($keys as $key) {
|
|
if (! isset($weights[$key]) || ! is_int($weights[$key]) || $weights[$key] < 0 || $weights[$key] > 100) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function validAmounts(Quotation $quote, Requisition $requisition): bool
|
|
{
|
|
if ($quote->currency !== 'MZN' || $quote->delivery_days < 1 || $quote->subtotal_minor <= 0 || $quote->total_minor > 100000000000000) {
|
|
return false;
|
|
}
|
|
$requested = $requisition->items()->get()->keyBy('id');
|
|
if ($requested->isEmpty() || $requested->count() !== $quote->items->count()) {
|
|
return false;
|
|
}
|
|
$subtotal = 0;
|
|
$tax = 0;
|
|
$seen = [];
|
|
try {
|
|
foreach ($quote->items as $item) {
|
|
$original = $requested->get($item->requisition_item_id);
|
|
if (! $original || in_array($item->requisition_item_id, $seen) || $item->unit_price_minor < 1 ||
|
|
$item->quantity <= 0 || $item->tax_rate < 0 || $item->tax_rate > 100 ||
|
|
! BigDecimal::of($item->quantity)->isEqualTo($original->quantity) ||
|
|
! BigDecimal::of($item->tax_rate)->isEqualTo($original->tax_rate)) {
|
|
return false;
|
|
}
|
|
$seen[] = $item->requisition_item_id;
|
|
$amount = BigDecimal::of($item->quantity)->multipliedBy($item->unit_price_minor)->toScale(0, RoundingMode::HalfUp)->toInt();
|
|
$vat = BigDecimal::of($amount)->multipliedBy($item->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
|
if ($amount !== $item->subtotal_minor || $vat !== $item->tax_minor || $amount + $vat !== $item->total_minor) {
|
|
return false;
|
|
}
|
|
$subtotal += $amount;
|
|
$tax += $vat;
|
|
}
|
|
} catch (MathException) {
|
|
return false;
|
|
}
|
|
|
|
return $subtotal === $quote->subtotal_minor && $tax === $quote->tax_minor && $subtotal + $tax === $quote->total_minor;
|
|
}
|
|
}
|