>, ready: bool, top_ids: list, fingerprint: string, policy: array{revision: int, configured: bool, minimum_proposals: int, minimum_evaluators: int, weights: array}} */ 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); $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) { $reasons = []; if ($adopted && ! app(FormalAdmission::class)->admitted($requisition, $quote->id)) { $reasons[] = 'O regime adoptado exige admissão confirmada na base actual.'; } 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, '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(), 'score' => null, 'score_minor' => null, ]; } $eligible = array_values(array_filter($rows, fn ($row) => $row['eligible'])); $ranking = (new ProposalScoring)->rank($rows, $policy['minimum_evaluators']); $topIds = $ranking['top_ids']; foreach ($rows as &$row) { if (isset($ranking['scores'][$row['id']])) { $minor = $ranking['scores'][$row['id']]; $row['score_minor'] = $minor; $row['score'] = $minor / 100; } } 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'], ...($row['commercial_terms'] === null ? [] : ['commercial_terms' => $row['commercial_terms']]), ]; } 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'] >= $policy['minimum_evaluators']; } 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 { $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 && ($user->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]) || ($user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id)); } /** @param array $weights */ private function validWeights(array $weights): bool { $keys = ['price', 'quality', 'delivery', 'history', 'payment']; if (count($weights) !== count($keys)) { return false; } foreach ($keys as $key) { if (! isset($weights[$key]) || ! is_int($weights[$key]) || $weights[$key] < 0 || $weights[$key] > 100) { return false; } } return array_sum($weights) === 100; } 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 { $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 || $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; 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) { return false; } $subtotal += $amount; $tax += $vat; } } catch (MathException|ValidationException) { return false; } return $subtotal === $quote->subtotal_minor && $tax === $quote->tax_minor && $subtotal + $tax === $quote->total_minor; } }