$message]); } /** Call after the company/actor locks; coordinates with award and quote transactions. */ private function process(User $actor, int $requisitionId): Requisition { $request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($requisitionId); if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $request->id)->exists()) { $this->invalid('qualification', 'Registe a análise num pedido aprovado, antes da adjudicação. As versões anteriores continuam disponíveis.'); } app(ConflictGuard::class)->assertParticipation($actor, $request); $quotes = Quotation::query()->where('requisition_id', $request->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 define(User $actor, Requisition $request, array $data): QualificationSet { 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); $latest = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first(); if ((int) $data['previous_id'] !== ($latest->id ?? 0)) { $this->invalid('previous_id', 'Os requisitos mudaram. Actualize a página antes de registar outra versão.'); } $opening = app(ProposalOpeningWorkflow::class); $proposals = $opening->proposals($request); if ($proposals === [] || $opening->basis($proposals) !== $data['basis_hash']) { $this->invalid('basis_hash', 'As propostas mudaram ou ainda não existem. Confira a base actual.'); } $criteria = []; $labels = []; foreach (array_values($data['criteria']) as $index => $criterion) { $label = trim($criterion['label']); $key = mb_strtolower($label); if (mb_strlen($label) < 3 || in_array($key, $labels, true) || ! in_array($criterion['category'], ['legal', 'financial', 'fiscal', 'technical', 'administrative'])) { $this->invalid('criteria', 'Defina requisitos distintos, com descrição e categoria válidas.'); } $labels[] = $key; $criteria[] = ['id' => $index + 1, 'label' => $label, 'category' => $criterion['category'], 'required' => (bool) $criterion['required']]; } if ($criteria === []) { $this->invalid('criteria', 'Defina pelo menos um requisito.'); } $set = QualificationSet::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'criteria' => $criteria, 'snapshot' => ['requisition' => $request->only(['id', 'number', 'justification']), 'author' => $actor->only(['id', 'name']), 'proposals' => $proposals], 'basis_hash' => $data['basis_hash'], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'qualification.defined', $request, null, ['set_id' => $set->id, 'revision' => $set->revision, 'previous_id' => $latest?->id]); return $set; }); } /** @param array $data */ public function assess(User $actor, QualificationSet $set, array $data): QualificationAssessment { return DB::transaction(function () use ($actor, $set, $data) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]); $request = $this->process($actor, $set->requisition_id); abort_if($actor->id === $request->requester_id, 403); $set = QualificationSet::query()->lockForUpdate()->findOrFail($set->id); $latestSet = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->firstOrFail(); $opening = app(ProposalOpeningWorkflow::class); if ($set->id !== $latestSet->id || $set->basis_hash !== $opening->basis($opening->proposals($request))) { $this->invalid('qualification', 'Os requisitos ou a base mudaram. As análises anteriores são históricas; registe uma versão actual dos requisitos.'); } $quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail(); app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor); $latest = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('quotation_id', $quote->id)->where('analyst_id', $actor->id)->latest('revision')->first(); if ((int) $data['previous_id'] !== ($latest->id ?? 0)) { $this->invalid('previous_id', 'A sua análise mudou. Não é permitido substituir versões ou análises de outro utilizador.'); } $expected = array_column($set->criteria, 'id'); $received = array_column($data['answers'], 'criterion_id'); sort($expected); sort($received); if ($expected !== $received) { $this->invalid('answers', 'Registe uma resposta para cada requisito desta versão, sem repetições.'); } $answers = []; foreach ($data['answers'] as $answer) { $result = $answer['result']; $reason = trim($answer['reason'] ?? ''); $evidence = trim($answer['evidence'] ?? ''); if (! in_array($result, ['pending', 'met', 'not_met', 'not_applicable']) || ($result !== 'pending' && mb_strlen($reason) < 10) || (in_array($result, ['met', 'not_met']) && mb_strlen($evidence) < 3)) { $this->invalid('answers', 'Resultados preenchidos exigem fundamento; cumprido e não cumprido exigem também referência de evidência.'); } $answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason]; } $assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => $actor->only(['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]); return $assessment; }); } }