From 9138379432db5518752fc8aa86dfc08a503f3994 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 19:28:05 +0200 Subject: [PATCH] Implementa qualificacao documental com requisitos e analises por versao --- .../DocumentaryQualificationController.php | 87 +++ .../Controllers/PurchaseProcessController.php | 9 +- app/Models/QualificationAssessment.php | 21 + app/Models/QualificationSet.php | 25 + app/Services/ConflictGuard.php | 1 + .../DocumentaryQualificationWorkflow.php | 119 +++ ...00018_create_documentary_qualification.php | 43 ++ docs/proposta-qualificacao-propostas.md | 21 +- .../qualificacao-concorrencia-postgresql.json | 51 ++ docs/qualificacao-migracao-000018.json | 16 + docs/validacao-tecnica.md | 71 ++ resources/js/pages/purchases/process.tsx | 2 + .../js/pages/purchases/qualification.tsx | 727 ++++++++++++++++++ resources/js/pages/quotations/show.tsx | 6 + routes/web.php | 4 + tests/Concurrency/qualification-worker.php | 40 + tests/Concurrency/run-qualification.php | 143 ++++ .../Feature/DocumentaryQualificationTest.php | 159 ++++ tests/Feature/PurchaseAwardOrderTest.php | 14 + tests/Support/QualificationFixture.php | 18 + .../Support/apply-qualification-migration.php | 111 +++ 21 files changed, 1677 insertions(+), 11 deletions(-) create mode 100644 app/Http/Controllers/DocumentaryQualificationController.php create mode 100644 app/Models/QualificationAssessment.php create mode 100644 app/Models/QualificationSet.php create mode 100644 app/Services/DocumentaryQualificationWorkflow.php create mode 100644 database/migrations/2026_10_28_000018_create_documentary_qualification.php create mode 100644 docs/qualificacao-concorrencia-postgresql.json create mode 100644 docs/qualificacao-migracao-000018.json create mode 100644 resources/js/pages/purchases/qualification.tsx create mode 100644 tests/Concurrency/qualification-worker.php create mode 100644 tests/Concurrency/run-qualification.php create mode 100644 tests/Feature/DocumentaryQualificationTest.php create mode 100644 tests/Support/QualificationFixture.php create mode 100644 tests/Support/apply-qualification-migration.php diff --git a/app/Http/Controllers/DocumentaryQualificationController.php b/app/Http/Controllers/DocumentaryQualificationController.php new file mode 100644 index 0000000..e0d6bab --- /dev/null +++ b/app/Http/Controllers/DocumentaryQualificationController.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php index d4f7320..629a718 100644 --- a/app/Http/Controllers/PurchaseProcessController.php +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -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'); diff --git a/app/Models/QualificationAssessment.php b/app/Models/QualificationAssessment.php new file mode 100644 index 0000000..6eee267 --- /dev/null +++ b/app/Models/QualificationAssessment.php @@ -0,0 +1,21 @@ + 'array', 'snapshot' => 'array']; + } +} diff --git a/app/Models/QualificationSet.php b/app/Models/QualificationSet.php new file mode 100644 index 0000000..29c3026 --- /dev/null +++ b/app/Models/QualificationSet.php @@ -0,0 +1,25 @@ + $criteria + * @property array $snapshot + */ +class QualificationSet extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['criteria' => 'array', 'snapshot' => 'array']; + } +} diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php index e4430ca..b71d144 100644 --- a/app/Services/ConflictGuard.php +++ b/app/Services/ConflictGuard.php @@ -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', diff --git a/app/Services/DocumentaryQualificationWorkflow.php b/app/Services/DocumentaryQualificationWorkflow.php new file mode 100644 index 0000000..563936a --- /dev/null +++ b/app/Services/DocumentaryQualificationWorkflow.php @@ -0,0 +1,119 @@ + $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; + }); + } +} diff --git a/database/migrations/2026_10_28_000018_create_documentary_qualification.php b/database/migrations/2026_10_28_000018_create_documentary_qualification.php new file mode 100644 index 0000000..45af213 --- /dev/null +++ b/database/migrations/2026_10_28_000018_create_documentary_qualification.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('requisition_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->json('criteria'); + $table->json('snapshot'); + $table->string('basis_hash', 64); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['requisition_id', 'revision']); + }); + Schema::create('qualification_assessments', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('qualification_set_id')->constrained()->restrictOnDelete(); + $table->foreignId('quotation_id')->constrained()->restrictOnDelete(); + $table->foreignId('analyst_id')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->json('answers'); + $table->json('snapshot'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['qualification_set_id', 'quotation_id', 'analyst_id', 'revision'], 'qualification_assessment_version_unique'); + }); + } + + public function down(): void + { + throw new LogicException('Preservar requisitos, análises e versões documentais.'); + } +}; diff --git a/docs/proposta-qualificacao-propostas.md b/docs/proposta-qualificacao-propostas.md index c480fa4..3d4e51a 100644 --- a/docs/proposta-qualificacao-propostas.md +++ b/docs/proposta-qualificacao-propostas.md @@ -1,7 +1,9 @@ # Proposta de âmbito para qualificação das propostas -Estado: demonstração para validação do produto. Não há implementação de -qualificação na base de negócio nem regras empresariais activadas. +Estado: âmbito documental validado pelo responsável nesta conversa e +implementado em `/requisicoes/{requisition}/qualificacao`. A demonstração +continua disponível separadamente. Não foram activadas regras empresariais +de admissão, exclusão ou adjudicação automática. Data: 28 de Outubro de 2026. ## Demonstração disponível @@ -18,13 +20,13 @@ Os dois requisitos iniciais são exemplos genéricos. Não se adoptaram certificados, valores de facturação, capital, crédito ou prazos dos modelos históricos como requisitos universais para empresas aderentes. -## Primeiro incremento proposto para a aplicação +## Âmbito documental validado 1. Compras define os requisitos documentais do processo, com categoria, descrição e indicação da obrigatoriedade segundo a regra desse processo. Categorias: jurídica, económico-financeira, fiscal, técnica e administrativa. A categoria não cria um limite financeiro nem uma exigência documental. - Para este primeiro âmbito, propõe-se definição por Compras e análise por + Neste primeiro âmbito, a definição é feita por Compras e a análise por Compras, Finanças ou chefia do departamento autorizada, com analista distinto do solicitante e sujeito aos conflitos existentes. Administração consulta. 2. Ao iniciar a análise, guardar a versão dos requisitos e a base das propostas @@ -65,19 +67,20 @@ Não se propõe presumir um júri ou quórum igual para todas as empresas. presunção de que os critérios foram divulgados antes de receber propostas. - Cancelamento de ordens emitidas, estornos, devoluções ou reversões financeiras. -## Validação solicitada +## Validação recebida -Validar o primeiro incremento documental: **requisitos e resultados por +Foi validado o primeiro incremento documental: **requisitos e resultados por processo, com evidências, fundamento e versões preservadas, sem exclusão ou -adjudicação automática**. A demonstração permite experimentar o preenchimento -antes dessa decisão. A implementação com dados de negócio fica pendente. +adjudicação automática**. A implementação preserva versões e permite registos +independentes por analista. Não cria automaticamente requisitos, análises ou +políticas para empresas reais. A orientação de continuidade actual pede «definir com a empresa o próximo âmbito pendente antes de o implementar» em `docs/orientacoes-continuidade.md`. As alterações locais desse ficheiro e de `docs/compatibilidade.md` e `docs/desenvolvimento.md` foram preservadas, sem as incluir nesta entrega. -## Verificações da preparação +## Histórico das verificações da preparação A rota respondeu HTTP 200 com componente `preview/qualification`, utilizador nulo e apenas propriedades gerais da consulta pública. Três testes existentes diff --git a/docs/qualificacao-concorrencia-postgresql.json b/docs/qualificacao-concorrencia-postgresql.json new file mode 100644 index 0000000..413790e --- /dev/null +++ b/docs/qualificacao-concorrencia-postgresql.json @@ -0,0 +1,51 @@ +{ + "tested_at_utc": "2026-10-28T17:23:12+00:00", + "separate_database": true, + "transport": "Independent PHP service transactions and PostgreSQL connections", + "scenarios": [ + { + "name": "competing_requirements", + "passed": true, + "overlapping_lock_waits": 2, + "requirement_versions": 1, + "analyst_records": 0 + }, + { + "name": "same_analyst", + "passed": true, + "overlapping_lock_waits": 2, + "requirement_versions": 1, + "analyst_records": 1 + }, + { + "name": "distinct_analysts", + "passed": true, + "overlapping_lock_waits": 2, + "requirement_versions": 1, + "analyst_records": 2 + }, + { + "name": "changed_basis", + "passed": true, + "overlapping_lock_waits": 2, + "requirement_versions": 1, + "analyst_records": 0 + }, + { + "name": "revoked_role", + "passed": true, + "overlapping_lock_waits": 2, + "requirement_versions": 1, + "analyst_records": 0 + }, + { + "name": "requirements_versus_assessment", + "passed": true, + "overlapping_lock_waits": 2, + "requirement_versions": 2, + "analyst_records": 1 + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/qualificacao-migracao-000018.json b/docs/qualificacao-migracao-000018.json new file mode 100644 index 0000000..997161a --- /dev/null +++ b/docs/qualificacao-migracao-000018.json @@ -0,0 +1,16 @@ +{ + "verified_at_utc": "2026-10-28T17:24:18+00:00", + "migration": "2026_10_28_000018_create_documentary_qualification", + "batch": 19, + "backup_created": true, + "backup_bytes": 296378, + "restore_verified": true, + "restored_tables": 55, + "restore_database_dropped": true, + "business_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "qualification_sets_count": 0, + "qualification_assessments_count": 0, + "operational_validation": false +} diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index 91a7901..4530a71 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -1,5 +1,76 @@ # Validação técnica do SGC — 28 de Outubro de 2026 +## Incremento: qualificação documental validada + +O responsável validou o primeiro âmbito documental de +`proposta-qualificacao-propostas.md`. Nas propostas existe o acesso +«Qualificação documental», em `/requisicoes/{requisition}/qualificacao`. +Compras define requisitos por processo com descrição, categoria e indicação +de obrigatoriedade segundo a regra desse processo. Não há certificados, +montantes mínimos ou requisitos predefinidos importados dos exemplos. + +Ao guardar os requisitos fica preservada uma versão imutável e a base das +propostas. Uma alteração cria outra versão, conservando os dados anteriores. +Compras, Finanças e chefia autorizada analisam, desde que o analista seja +distinto do solicitante e cumpra o âmbito departamental e os conflitos +existentes. Administração consulta. Cada utilizador conserva o seu próprio +registo por proposta e versão dos requisitos; não substitui o de outro analista. + +Cada registo contém uma resposta para todos os requisitos: por analisar, +cumprido, não cumprido ou não aplicável. Resultados preenchidos exigem +fundamento; cumprido/não cumprido exigem também referência de evidência. +Não aplicável exige justificação e não dispensa automaticamente um requisito +obrigatório. Uma correcção acrescenta uma versão da análise e exige o +identificador do último registo do próprio analista. + +Bases alteradas ou requisitos ultrapassados não aceitam novas análises. +Os registos antigos continuam consultáveis com indicação histórica, sem +copiar respostas para outra base. Os requisitos e a base são conferidos +novamente dentro de uma transacção que bloqueia primeiro a empresa e +revalida o operador. Após adjudicação, ou fora do estado aprovado do pedido, +os registos anteriores permanecem em consulta, sem novas escritas. + +O módulo não decide admissão/exclusão e não usa os resultados para alterar +elegibilidade, pontuações, ordenação ou emissão. O método de comparação +permanece intacto; continuam aplicáveis os conflitos existentes. Um teste +de integração regista «não cumprido» num requisito obrigatório e confirma +que a matriz é idêntica e que uma adjudicação antes elegível segue o circuito +normal até à emissão, sem criar uma regra automática adicional. + +Consulta exige os mesmos perfis e visibilidade das propostas, respeitando +empresa e departamento. Solicitantes e Armazém não ganham acesso. Respostas +usam `private, no-store` e histórico Inertia cifrado. O histórico é paginado +e o último registo de cada analista/proposta é obtido separadamente, sem +confundir uma página histórica com a versão actual de trabalho. A visão única +inclui ligação e eventos conforme o perfil. + +A migração aditiva 000018 foi aplicada no batch 19, após backup privado de +296378 bytes e restauro verificado de 55 tabelas. Dados anteriores, restrições +e `.env` foram preservados; as tabelas de requisitos e análises ficaram +vazias na base de negócio. Ver `qualificacao-migracao-000018.json`. Backup e +manifesto permanecem fora do Git. + +Seis cenários PostgreSQL passaram com duas ligações e duas esperas simultâneas +de bloqueio: requisitos concorrentes, duas escritas do mesmo analista, +analistas distintos, base alterada, perfil revogado e requisitos novos em +disputa com uma análise da versão anterior. Não houve substituição de +analistas, mistura de bases ou duplicação de versões. A base exclusiva de +testes foi eliminada. Ver `qualificacao-concorrencia-postgresql.json`. + +As alterações locais em `compatibilidade.md`, `desenvolvimento.md` e +`orientacoes-continuidade.md` foram preservadas e não fazem parte deste +incremento. A revisão visual permanece pendente: o navegador devolveu +`The browser access host is unavailable`. Admissão/exclusão formal, circuitos +de conferência/quórum, novos métodos de avaliação, certificação de evidências +e cancelamento de ordens emitidas continuam sem activação neste âmbito. + +Resultado deste incremento: **454 testes aprovados, 2 ignorados e 3601 +asserções**. São 11 testes novos: dez específicos do módulo e um de integração +com adjudicação/emissão. PHPStan, Pint, TypeScript, build e lint/formatação +dos ficheiros alterados passaram. A formatação global continua a assinalar +`compatibilidade.md`, já modificado fora desta entrega; não foi alterado nem +incluído no commit. Os dois testes ignorados continuam condicionados a MFA. + ## Incremento: sessão de abertura e actas por versão Nas propostas existe o acesso «Sessão de abertura e actas», em diff --git a/resources/js/pages/purchases/process.tsx b/resources/js/pages/purchases/process.tsx index 341d1c3..9bf3b6c 100644 --- a/resources/js/pages/purchases/process.tsx +++ b/resources/js/pages/purchases/process.tsx @@ -48,6 +48,8 @@ const labels: Record = { issued: 'Emitida', }; const events: Record = { + 'qualification.defined': 'Versão dos requisitos documentais registada', + 'qualification.assessed': 'Versão da análise documental registada', 'opening.recorded': 'Versão da acta de abertura registada', 'requisition.created': 'Requisição criada', 'requisition.updated': 'Requisição actualizada', diff --git a/resources/js/pages/purchases/qualification.tsx b/resources/js/pages/purchases/qualification.tsx new file mode 100644 index 0000000..b5a7eaf --- /dev/null +++ b/resources/js/pages/purchases/qualification.tsx @@ -0,0 +1,727 @@ +import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; +import { FormErrors, PageTitle, Panel } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { date, roleLabels } from '@/types/procurement'; + +type Criterion = { + id: number; + label: string; + category: string; + required: boolean; +}; +type Answer = { + criterion_id: number; + result: 'pending' | 'met' | 'not_met' | 'not_applicable'; + evidence: string; + reason: string; +}; +type Proposal = { + id: number; + reference: string; + supplier: { name: string } | null; +}; +type Set = { + id: number; + revision: number; + reason: string; + created_at: string; + criteria: Criterion[]; + snapshot: { author: { name: string }; proposals: Proposal[] }; +}; +type Assessment = { + id: number; + quotation_id: number; + analyst_id: number; + revision: number; + answers: Answer[]; + reason: string; + created_at: string; + snapshot: { analyst: { name: string; purchasing_role: string } }; +}; +type Props = { + requisition: { id: number; number: string; justification: string }; + sets: Pick[]; + selected: Set | null; + latestId: number; + latestCriteria: Criterion[]; + basisHash: string; + isCurrent: boolean; + proposals: Proposal[]; + records: Assessment[]; + history: { + data: Assessment[]; + prev_page_url: string | null; + next_page_url: string | null; + }; + actorId: number; + canDefine: boolean; + canAssess: boolean; + closedForChanges: boolean; +}; +const categories: Record = { + legal: 'Jurídica', + financial: 'Económico-financeira', + fiscal: 'Fiscal', + technical: 'Técnica', + administrative: 'Administrativa', +}; +const results: Record = { + pending: 'Por analisar', + met: 'Cumprido', + not_met: 'Não cumprido', + not_applicable: 'Não aplicável', +}; +const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm'; + +function DefineForm({ + requisition, + latestId, + latestCriteria, + basisHash, + proposals, +}: Pick< + Props, + 'requisition' | 'latestId' | 'latestCriteria' | 'basisHash' | 'proposals' +>) { + const form = useForm({ + previous_id: latestId, + basis_hash: basisHash, + reason: '', + criteria: latestCriteria.length + ? latestCriteria.map(({ label, category, required }) => ({ + label, + category, + required, + })) + : [{ label: '', category: 'administrative', required: false }], + }); + const change = ( + index: number, + values: Partial>, + ) => + form.setData( + 'criteria', + form.data.criteria.map((criterion, position) => + position === index ? { ...criterion, ...values } : criterion, + ), + ); + return ( +
{ + event.preventDefault(); + form.post(`/requisicoes/${requisition.id}/qualificacao`, { + preserveScroll: true, + onSuccess: () => form.setData('reason', ''), + }); + }} + className="space-y-4" + > + {(form.data.previous_id !== latestId || + form.data.basis_hash !== basisHash) && ( +
+

+ Os requisitos ou as propostas mudaram. O texto + introduzido foi mantido; confira a base actual antes de + criar uma nova versão. +

+ +
+ )} +

+ Esta versão ficará ligada às {proposals.length} propostas + actualmente registadas:{' '} + {proposals.map((proposal) => proposal.reference).join(', ')}. +

+ {form.data.criteria.map((criterion, index) => ( +
+ + + + {form.data.criteria.length > 1 && ( + + )} +
+ ))} + +