$message]); } public function adoption(Requisition $request): ?AdmissionAdoption { return AdmissionAdoption::query()->where('requisition_id', $request->id)->latest('revision')->first(); } private function process(User $actor, int $id): Requisition { $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('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.'); } 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(); return $request; } /** @param array $data */ public function policy(User $actor, array $data): AdmissionPolicy { 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 expressamente perfis, quantidade de conferentes e fundamento da política.'); } return DB::transaction(function () use ($actor, $data) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]); $latest = AdmissionPolicy::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 = AdmissionPolicy::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, 'admission.policy', null, null, $policy->toArray()); return $policy; }); } public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): AdmissionAdoption { 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); $previous = $this->adoption($request); $policy = AdmissionPolicy::query()->latest('revision')->first(); if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId) { $this->invalid('Seleccione a política activa e a adopção actual. Não se altera um regime implicitamente.'); } if ($previous?->admission_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) { $this->invalid('A adopção exige outra versão de política e fundamento próprio.'); } $adoption = AdmissionAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'admission_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, 'admission.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]); return $adoption; }); } /** @return array{set_id:int,payload:array,hash:string,clear:bool}|null */ public function basis(Requisition $request, int $quoteId): ?array { $set = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first(); if (! $set) { return null; } $opening = app(ProposalOpeningWorkflow::class); $quotes = $opening->proposals($request); if ($set->basis_hash !== $opening->basis($quotes)) { return null; } $quote = collect($quotes)->firstWhere('id', $quoteId); if (! $quote) { return null; } $ids = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('quotation_id', $quoteId)->selectRaw('MAX(id)')->groupBy('analyst_id'); $records = QualificationAssessment::query()->whereIn('id', $ids)->orderBy('analyst_id')->get(); $clear = $records->isNotEmpty(); foreach ($set->criteria as $criterion) { $results = []; foreach ($records as $record) { $answer = collect($record->answers)->firstWhere('criterion_id', $criterion['id']); if (! $answer || ! in_array($answer['result'], ['met', 'not_met', 'not_applicable'], true) || ($criterion['required'] && $answer['result'] !== 'met')) { $clear = false; } if ($answer) { $results[] = $answer['result']; } } if (count(array_unique($results)) !== 1) { $clear = false; } } $payload = ['set_id' => $set->id, 'criteria' => $set->criteria, 'quotes_hash' => $set->basis_hash, 'quote' => $quote, 'analyses' => $records->map(fn ($record) => $record->only(['id', 'analyst_id', 'revision', 'answers', 'snapshot']))->all()]; return ['set_id' => $set->id, 'payload' => $payload, 'hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'clear' => $clear]; } public function status(AdmissionDecision $decision, AdmissionAdoption $adoption): string { $reviews = AdmissionReview::query()->where('admission_decision_id', $decision->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): AdmissionDecision { if (! in_array($data['outcome'], ['admit', 'exclude', 'clarify'], true) || mb_strlen(trim($data['reason'])) < 10) { $this->invalid('Seleccione uma decisão e fundamente a tentativa.'); } 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('O processo ainda não adoptou expressamente o regime.'); } $quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail(); app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor); $basis = $this->basis($request, $quote->id); if (! $basis || $basis['hash'] !== $data['basis_hash']) { $this->invalid('Confira requisitos e análises actuais antes de propor uma decisão.'); } if ($data['outcome'] === 'admit' && ! $basis['clear']) { $this->invalid('A admissão exige análise sem pendências, divergências ou requisitos obrigatórios não resolvidos.'); } $previous = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quote->id)->latest('revision')->first(); if ((int) $data['previous_id'] !== ($previous->id ?? 0)) { $this->invalid('A tentativa mudou. Actualize a página para preservar o antecedente correcto.'); } $decision = AdmissionDecision::query()->create(['tenant_id' => $actor->tenant_id, 'admission_adoption_id' => $adoption->id, 'qualification_set_id' => $basis['set_id'], 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'outcome' => $data['outcome'], 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name'])], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'admission.proposed', $request, null, ['decision' => $decision->toArray(), 'previous_id' => $previous?->id]); return $decision; }); } public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool { return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id); } public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void { if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) { $this->invalid('Seleccione uma conferência válida e fundamente-a.'); } DB::transaction(function () use ($actor, $decision, $verdict, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]); $original = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id); $request = $this->process($actor, $original->requisition_id); $adoption = $this->adoption($request); abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403); $latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first(); $basis = $this->basis($request, $decision->quotation_id); if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) { $this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.'); } $existing = AdmissionReview::query()->where('admission_decision_id', $decision->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($decision, $adoption) !== 'pending') { $this->invalid('Esta tentativa já foi concluída ou devolvida.'); } if ($verdict === 'approved') { foreach (AdmissionReview::query()->where('admission_decision_id', $decision->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, $decision)) { $this->invalid('Um conferente anterior deixou de ser elegível antes da conclusão. Devolva e prepare outra tentativa.'); } } } $review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray()); }); } /** @return array|null */ public function selection(Requisition $request, int $quoteId): ?array { $adoption = $this->adoption($request); if (! $adoption) { return null; } $decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quoteId)->latest('revision')->first(); $basis = $this->basis($request, $quoteId); if (! $decision || $decision->outcome !== 'admit' || ! $basis || ! $basis['clear'] || $basis['hash'] !== $decision->basis_hash || $this->status($decision, $adoption) !== 'confirmed') { $this->invalid('A proposta exige admissão confirmada na base actual do regime adoptado.'); } return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->admission_policy_id, 'decision_id' => $decision->id, 'qualification_set_id' => $decision->qualification_set_id, 'basis_hash' => $decision->basis_hash, 'review_ids' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->pluck('id')->all()]; } public function admitted(Requisition $request, int $quoteId): bool { try { $this->selection($request, $quoteId); return true; } catch (ValidationException) { return false; } } }