@@ -11,16 +11,18 @@ use Brick\Math\BigDecimal;
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\Exception\MathException;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProposalComparison
|
||||
{
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string, policy: array{revision: int, configured: bool, minimum_proposals: int, minimum_evaluators: int, weights: array<string,int>}} */
|
||||
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;
|
||||
$policy = app(ComparisonPolicy::class)->forRequest($requisition);
|
||||
$referenceWeights = $requisition->comparison_policy === null ? $quotes->first()?->weights : $policy['weights'];
|
||||
$adopted = app(FormalAdmission::class)->adoption($requisition) !== null;
|
||||
$rows = [];
|
||||
foreach ($quotes as $quote) {
|
||||
@@ -44,6 +46,7 @@ class ProposalComparison
|
||||
$rows[] = [
|
||||
...$quote->toArray(), 'id' => $quote->id, 'supplier_id' => $quote->supplier_id,
|
||||
'weights' => $quote->weights, 'subtotal_minor' => $quote->subtotal_minor, 'delivery_days' => $quote->delivery_days,
|
||||
'commercial_terms' => $quote->commercial_terms,
|
||||
'items' => $quote->items->toArray(), 'evaluations' => $quote->evaluations->toArray(),
|
||||
'eligible' => $reasons === [], 'ineligibility_reasons' => $reasons,
|
||||
'qualified_evaluations' => $evaluations->toArray(), 'evaluation_count' => $evaluations->count(),
|
||||
@@ -60,7 +63,7 @@ class ProposalComparison
|
||||
$best = null;
|
||||
$topIds = [];
|
||||
foreach ($rows as &$row) {
|
||||
if ($row['eligible'] && $row['evaluation_count'] >= 2) {
|
||||
if ($row['eligible'] && $row['evaluation_count'] >= $policy['minimum_evaluators']) {
|
||||
$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']));
|
||||
@@ -90,14 +93,18 @@ class ProposalComparison
|
||||
'items' => $row['items'], 'evaluations' => $row['evaluations'],
|
||||
'qualified_ids' => array_column($row['qualified_evaluations'], 'id'),
|
||||
'eligible' => $row['eligible'], 'score_minor' => $row['score_minor'],
|
||||
...($row['commercial_terms'] === null ? [] : ['commercial_terms' => $row['commercial_terms']]),
|
||||
];
|
||||
}
|
||||
$ready = count($eligible) >= 3 && count(array_unique(array_column($eligible, 'supplier_id'))) >= 3;
|
||||
if ($requisition->comparison_policy !== null) {
|
||||
$basis['comparison_policy'] = $policy;
|
||||
}
|
||||
$ready = count($eligible) >= $policy['minimum_proposals'] && count(array_unique(array_column($eligible, 'supplier_id'))) >= $policy['minimum_proposals'];
|
||||
foreach ($eligible as $row) {
|
||||
$ready = $ready && $row['evaluation_count'] >= 2;
|
||||
$ready = $ready && $row['evaluation_count'] >= $policy['minimum_evaluators'];
|
||||
}
|
||||
|
||||
return ['rows' => $rows, 'ready' => $ready, 'top_ids' => $topIds, 'fingerprint' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))];
|
||||
return ['rows' => $rows, 'ready' => $ready, 'top_ids' => $topIds, 'fingerprint' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR)), 'policy' => $policy];
|
||||
}
|
||||
|
||||
public function eligibleEvaluator(ProposalEvaluation $evaluation, Requisition $requisition): bool
|
||||
@@ -154,6 +161,11 @@ class ProposalComparison
|
||||
$tax = 0;
|
||||
$seen = [];
|
||||
try {
|
||||
$terms = $quote->commercial_terms;
|
||||
if ($terms !== null && (($terms['version'] ?? null) !== 1 || ($terms['discount_basis'] ?? null) !== 'unit_before_vat' ||
|
||||
($terms['rounding'] ?? null) !== 'net_unit_half_up_cent' || ! is_array($terms['lines'] ?? null) || count($terms['lines']) !== $quote->items->count())) {
|
||||
return false;
|
||||
}
|
||||
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 ||
|
||||
@@ -163,6 +175,18 @@ class ProposalComparison
|
||||
return false;
|
||||
}
|
||||
$seen[] = $item->requisition_item_id;
|
||||
if ($terms !== null) {
|
||||
$matches = array_values(array_filter($terms['lines'], fn ($term) => ($term['requisition_item_id'] ?? null) === $item->requisition_item_id));
|
||||
if (count($matches) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$term = $matches[0];
|
||||
$pricing = app(QuotationPricing::class)->unit((string) BigDecimal::of((string) ($term['gross_unit_minor'] ?? 0))->dividedBy(100, 2),
|
||||
(string) ($term['discount_type'] ?? ''), (string) ($term['discount_value'] ?? '0'));
|
||||
if ($pricing['net_unit_minor'] !== $item->unit_price_minor || $pricing !== array_intersect_key($term, $pricing)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$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) {
|
||||
@@ -171,7 +195,7 @@ class ProposalComparison
|
||||
$subtotal += $amount;
|
||||
$tax += $vat;
|
||||
}
|
||||
} catch (MathException) {
|
||||
} catch (MathException|ValidationException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user