Files
compras/app/Services/DocumentaryQualificationWorkflow.php
T
nala 48347e2d3a
tests / ci (push) Canceled after 0s
commit5
2026-10-30 10:55:18 +02:00

120 lines
7.8 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\PurchaseAward;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Quotation;
use App\Models\QuotationItem;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class DocumentaryQualificationWorkflow
{
private function invalid(string $field, string $message): never
{
throw ValidationException::withMessages([$field => $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<string,mixed> $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<string,mixed> $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' => app(OrganizationAccess::class)->actorSnapshot($actor, ['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;
});
}
}