Implementa qualificacao documental com requisitos e analises por versao

This commit is contained in:
2026-10-28 19:28:05 +02:00
parent b7a52e9e00
commit 9138379432
21 changed files with 1677 additions and 11 deletions
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\PurchaseAward;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Requisition;
use App\Services\ConflictGuard;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\ProposalComparison;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class DocumentaryQualificationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $opening): Response
{
$this->access($request, $requisition->id);
$filters = $request->validate(['version' => 'nullable|integer|min:1']);
$sets = QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->get(['id', 'revision', 'reason', 'created_at']);
$latest = $sets->first() ? QualificationSet::query()->findOrFail($sets->first()->id) : null;
$selected = isset($filters['version']) ? QualificationSet::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filters['version']) : $latest;
$proposals = $opening->proposals($requisition);
$basis = $opening->basis($proposals);
$current = $selected && $selected->id === $latest?->id && $selected->basis_hash === $basis;
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$query = QualificationAssessment::query()->where('qualification_set_id', $selected->id ?? 0);
$recordIds = (clone $query)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id');
$records = QualificationAssessment::query()->whereIn('id', $recordIds)->orderBy('quotation_id')->orderBy('analyst_id')->get();
Inertia::encryptHistory();
$response = Inertia::render('purchases/qualification', [
'requisition' => $requisition->only(['id', 'number', 'justification']), 'sets' => $sets, 'selected' => $selected,
'latestId' => $latest->id ?? 0, 'latestCriteria' => $latest->criteria ?? [], 'basisHash' => $basis, 'isCurrent' => (bool) $current,
'proposals' => $proposals,
'closedForChanges' => $closed,
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
'canDefine' => $open && $proposals !== [] && $request->user()->purchasing_role === PurchasingRole::Procurement,
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function define(Request $request, Requisition $requisition, DocumentaryQualificationWorkflow $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate([
'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000',
'criteria' => 'required|array|min:1|max:30', 'criteria.*' => 'required|array:label,category,required',
'criteria.*.label' => 'required|string|min:3|max:500', 'criteria.*.category' => 'required|in:legal,financial,fiscal,technical,administrative', 'criteria.*.required' => 'required|boolean',
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
]);
$workflow->define($request->user(), $requisition, $data);
return to_route('qualifications.show', $requisition);
}
public function assess(Request $request, QualificationSet $set, DocumentaryQualificationWorkflow $workflow): RedirectResponse
{
$this->access($request, $set->requisition_id);
$data = $request->validate([
'previous_id' => 'required|integer|min:0', 'quotation_id' => 'required|integer', 'reason' => 'required|string|min:10|max:4000',
'answers' => 'required|array|min:1|max:30', 'answers.*' => 'required|array:criterion_id,result,evidence,reason',
'answers.*.criterion_id' => 'required|integer|distinct', 'answers.*.result' => 'required|in:pending,met,not_met,not_applicable',
'answers.*.evidence' => 'nullable|string|max:1000', 'answers.*.reason' => 'nullable|string|max:4000',
'snapshot' => 'prohibited', 'analyst_id' => 'prohibited', 'tenant_id' => 'prohibited', 'revision' => 'prohibited',
]);
$data['answers'] = array_map(fn ($answer) => [...$answer, 'criterion_id' => (int) $answer['criterion_id']], $data['answers']);
$workflow->assess($request->user(), $set, $data);
return to_route('qualifications.show', $set->requisition_id);
}
}
@@ -14,6 +14,7 @@ use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchasePlanItem;
use App\Models\PurchaseRevision;
use App\Models\QualificationSet;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\TechnicalReview;
@@ -36,6 +37,10 @@ class PurchaseProcessController extends Controller
$base = '/requisicoes/'.$requisition->id;
$stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($qualification) {
$documents[] = ['label' => 'Qualificação documental · Requisitos v'.$qualification->revision, 'kind' => 'Qualificação documental', 'href' => $base.'/qualificacao'];
}
$opening = $purchases ? ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($opening) {
$documents[] = ['label' => 'Acta de abertura · Versão '.$opening->revision, 'kind' => 'Abertura', 'href' => $base.'/abertura'];
@@ -103,7 +108,7 @@ class PurchaseProcessController extends Controller
}
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) {
$actions = [...$actions, 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
$actions = [...$actions, 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
}
if ($inventory) {
$actions[] = 'receipt.confirmed';
@@ -118,7 +123,7 @@ class PurchaseProcessController extends Controller
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
'future' => ['Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
'future' => ['Decisão formal de admissão ou exclusão', 'Relatório de recomendação em etapas'],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class QualificationAssessment extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['answers' => 'array', 'snapshot' => 'array'];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property list<array{id:int,label:string,category:string,required:bool}> $criteria
* @property array<string,mixed> $snapshot
*/
class QualificationSet extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['criteria' => 'array', 'snapshot' => 'array'];
}
}
+1
View File
@@ -85,6 +85,7 @@ class ConflictGuard
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
'opening.recorded',
'qualification.defined', 'qualification.assessed',
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected',
@@ -0,0 +1,119 @@
<?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' => $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;
});
}
}