$message]); } public function adoption(Requisition $request): ?RecommendationAdoption { return RecommendationAdoption::query()->where('requisition_id', $request->id)->latest('revision')->first(); } private function process(User $actor, int $id): Requisition { abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); $request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id); if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) { $this->invalid('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.'); } app(ConflictGuard::class)->assertParticipation($actor, $request); $quotes = Quotation::query()->where('requisition_id', $id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']); Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get(); QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get(); ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get(); return $request; } /** @param array $data */ public function policy(User $actor, array $data): RecommendationPolicy { if (! is_bool($data['enabled']) || ! is_array($data['roles']) || $data['roles'] === [] || count(array_unique($data['roles'])) !== count($data['roles']) || array_diff($data['roles'], ['finance', 'department_head']) !== [] || filter_var($data['required_count'], FILTER_VALIDATE_INT) === false || $data['required_count'] < 1 || $data['required_count'] > 50 || mb_strlen(trim($data['reason'])) < 10) { $this->invalid('Defina perfis, quantidade de conferentes e fundamento próprios da recomendação.'); } return DB::transaction(function () use ($actor, $data) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]); $latest = RecommendationPolicy::query()->latest('revision')->first(); if ((int) $data['previous_id'] !== ($latest->id ?? 0)) { $this->invalid('A política mudou. Actualize antes de criar outra versão.'); } $policy = RecommendationPolicy::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'enabled' => $data['enabled'], 'roles' => array_values($data['roles']), 'required_count' => $data['required_count'], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'recommendation.policy', null, null, $policy->toArray()); return $policy; }); } public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): RecommendationAdoption { return DB::transaction(function () use ($actor, $request, $policyId, $previousId, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]); $request = $this->process($actor, $request->id); if (! app(FormalAdmission::class)->adoption($request)) { $this->invalid('Adopte expressamente o regime de admissão antes da recomendação.'); } $previous = $this->adoption($request); $policy = RecommendationPolicy::query()->latest('revision')->first(); if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId || $previous?->recommendation_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) { $this->invalid('Seleccione a política activa, a adopção actual e um fundamento próprio.'); } $adoption = RecommendationAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'recommendation_policy_id' => $policy->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'snapshot' => ['roles' => $policy->roles, 'required_count' => $policy->required_count, 'policy_revision' => $policy->revision], 'reason' => $reason, 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'recommendation.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]); return $adoption; }); } /** @return array{hash:string,payload:array{admission_adoption:array|null,comparison_hash:string,ready:bool,top_ids:list,rows:list>,admissions:list>},ready:bool,top_ids:list} */ public function basis(Requisition $request): array { $comparison = app(ProposalComparison::class)->inspect($request); $admission = app(FormalAdmission::class); $adoption = $admission->adoption($request); $rows = []; $admissions = []; foreach ($comparison['rows'] as $row) { // Freeze the decision inputs without supplier contacts or private documents. $rows[] = [...array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'payment_terms', 'weights', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor', 'items', 'evaluations', 'qualified_evaluations'])), 'supplier_name' => $row['supplier']['name'] ?? 'Fornecedor indisponível']; $decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id ?? 0)->where('quotation_id', $row['id'])->latest('revision')->first(); $admissions[] = ['quotation_id' => $row['id'], 'basis' => $admission->basis($request, $row['id']), 'decision' => $decision?->toArray(), 'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id ?? 0)->orderBy('id')->get()->toArray()]; } $payload = ['admission_adoption' => $adoption?->toArray(), 'comparison_hash' => $comparison['fingerprint'], 'ready' => $comparison['ready'], 'top_ids' => $comparison['top_ids'], 'rows' => $rows, 'admissions' => $admissions]; return ['hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'payload' => $payload, 'ready' => $adoption !== null && $comparison['ready'], 'top_ids' => $comparison['top_ids']]; } public function status(RecommendationAttempt $attempt, RecommendationAdoption $adoption): string { $reviews = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->get(); if ($reviews->contains('verdict', 'returned')) { return 'returned'; } return $reviews->where('verdict', 'approved')->count() >= $adoption->snapshot['required_count'] ? 'confirmed' : 'pending'; } /** @param array $data */ public function propose(User $actor, Requisition $request, array $data): RecommendationAttempt { if (mb_strlen(trim($data['reason'])) < 10) { $this->invalid('Fundamente a recomendação, incluindo a escolha em caso de empate.'); } return DB::transaction(function () use ($actor, $request, $data) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]); $request = $this->process($actor, $request->id); abort_if($actor->id === $request->requester_id, 403); $adoption = $this->adoption($request); if (! $adoption) { $this->invalid('Adopte expressamente o regime de recomendação.'); } $quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail(); $basis = $this->basis($request); if (! $basis['ready'] || $basis['hash'] !== $data['basis_hash'] || ! in_array($quote->id, $basis['top_ids'], true)) { $this->invalid('Exige-se matriz pronta, admissões confirmadas e escolha fundamentada entre as maiores pontuações da base actual.'); } $previous = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); if ((int) $data['previous_id'] !== ($previous->id ?? 0)) { $this->invalid('A tentativa mudou. Actualize antes de registar outra revisão.'); } $attempt = RecommendationAttempt::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_adoption_id' => $adoption->id, 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name']), 'policy' => $adoption->snapshot], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'recommendation.proposed', $request, null, ['attempt' => $attempt->toArray(), 'previous_id' => $previous?->id]); return $attempt; }); } public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool { return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $attempt->tenant_id === $request->tenant_id && $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id); } public function canParticipate(User $actor, Requisition $request): bool { return $actor->tenant_id === $request->tenant_id && $request->status === 'approved' && ! app(OrganizationAccess::class)->maintenanceOnly($actor) && Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists() && ! PurchaseAward::query()->where('requisition_id', $request->id)->exists() && ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($actor, $request); } /** Same immutable basis check used before a decision is recorded. */ public function currentReviewBasis(Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool { if ($adoption->requisition_id !== $request->id || $attempt->recommendation_adoption_id !== $adoption->id) { return false; } $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); $basis = $this->basis($request); return $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash; } public function canReview(User $actor, Requisition $request, RecommendationAttempt $attempt): bool { $adoption = $this->adoption($request); return $adoption !== null && $this->eligible($actor, $request, $adoption, $attempt) && $this->canParticipate($actor, $request) && $this->currentReviewBasis($request, $adoption, $attempt) && $this->status($attempt, $adoption) === 'pending' && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->exists(); } public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void { if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) { $this->invalid('Registe uma conferência válida e fundamentada.'); } DB::transaction(function () use ($actor, $attempt, $verdict, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]); $original = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id); $request = $this->process($actor, $original->requisition_id); $adoption = $this->adoption($request); abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403); if (! $this->currentReviewBasis($request, $adoption, $attempt)) { $this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.'); } $existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->first(); if ($existing) { if ($existing->verdict !== $verdict || $existing->reason !== $reason) { $this->invalid('A conferência anterior é imutável.'); } return; } if ($this->status($attempt, $adoption) !== 'pending') { $this->invalid('A tentativa já foi concluída ou devolvida.'); } if ($verdict === 'approved') { foreach (RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('verdict', 'approved')->get() as $review) { $reviewer = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($review->reviewed_by); if (! $reviewer || ! $this->eligible($reviewer, $request, $adoption, $attempt)) { $this->invalid('Um conferente anterior deixou de ser elegível. Devolva e prepare outra tentativa.'); } } } $review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray()); }); } /** @return array|null */ public function selection(Requisition $request, int $quoteId): ?array { $adoption = $this->adoption($request); if (! $adoption) { return null; } $attempt = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); $basis = $this->basis($request); if (! $attempt || $attempt->quotation_id !== $quoteId || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash || $this->status($attempt, $adoption) !== 'confirmed') { $this->invalid('A adjudicação exige a proposta da recomendação confirmada na base actual.'); } return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->recommendation_policy_id, 'attempt_id' => $attempt->id, 'quotation_id' => $quoteId, 'basis_hash' => $attempt->basis_hash, 'review_ids' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->pluck('id')->all()]; } }