150 lines
9.3 KiB
PHP
150 lines
9.3 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
|
|
{
|
|
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
|
$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;
|
|
}
|
|
|
|
private 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()
|
|
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
|
}
|
|
|
|
public function canDefine(User $actor, Requisition $request): bool
|
|
{
|
|
return $actor->hasFunctions([PurchasingRole::Procurement]) && $this->canParticipate($actor, $request)
|
|
&& Quotation::query()->where('requisition_id', $request->id)->exists();
|
|
}
|
|
|
|
public function currentSet(Requisition $request, QualificationSet $set): bool
|
|
{
|
|
$latest = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
|
$opening = app(ProposalOpeningWorkflow::class);
|
|
|
|
return $set->tenant_id === $request->tenant_id && $set->requisition_id === $request->id && $latest?->id === $set->id
|
|
&& $set->basis_hash === $opening->basis($opening->proposals($request));
|
|
}
|
|
|
|
public function canAssess(User $actor, Requisition $request, QualificationSet $set): bool
|
|
{
|
|
return $actor->id !== $request->requester_id && app(ProposalComparison::class)->eligibleRole($actor, $request)
|
|
&& $this->canParticipate($actor, $request) && $this->currentSet($request, $set);
|
|
}
|
|
|
|
/** @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_unless(app(ProposalComparison::class)->eligibleRole($actor, $request), 403);
|
|
abort_if($actor->id === $request->requester_id, 403);
|
|
$set = QualificationSet::query()->lockForUpdate()->findOrFail($set->id);
|
|
if (! $this->currentSet($request, $set)) {
|
|
$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;
|
|
});
|
|
}
|
|
}
|