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;
});
}
}
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('qualification_sets', function (Blueprint $table) {
$table->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.');
}
};
+12 -9
View File
@@ -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
@@ -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
}
+16
View File
@@ -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
}
+71
View File
@@ -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
+2
View File
@@ -48,6 +48,8 @@ const labels: Record<string, string> = {
issued: 'Emitida',
};
const events: Record<string, string> = {
'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',
@@ -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<Set, 'id' | 'revision' | 'reason' | 'created_at'>[];
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<string, string> = {
legal: 'Jurídica',
financial: 'Económico-financeira',
fiscal: 'Fiscal',
technical: 'Técnica',
administrative: 'Administrativa',
};
const results: Record<Answer['result'], string> = {
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<Pick<Criterion, 'label' | 'category' | 'required'>>,
) =>
form.setData(
'criteria',
form.data.criteria.map((criterion, position) =>
position === index ? { ...criterion, ...values } : criterion,
),
);
return (
<form
onSubmit={(event) => {
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) && (
<div className="rounded-lg border border-amber-300 p-4 text-sm">
<p>
Os requisitos ou as propostas mudaram. O texto
introduzido foi mantido; confira a base actual antes de
criar uma nova versão.
</p>
<Button
type="button"
variant="outline"
className="mt-3"
onClick={() => {
form.setData('previous_id', latestId);
form.setData('basis_hash', basisHash);
}}
>
Usar a base actual para conferência
</Button>
</div>
)}
<p className="text-sm text-muted-foreground">
Esta versão ficará ligada às {proposals.length} propostas
actualmente registadas:{' '}
{proposals.map((proposal) => proposal.reference).join(', ')}.
</p>
{form.data.criteria.map((criterion, index) => (
<div
key={index}
className="grid gap-3 rounded-xl border p-4 sm:grid-cols-2"
>
<Label>
Requisito
<Input
required
minLength={3}
maxLength={500}
value={criterion.label}
onChange={(event) =>
change(index, { label: event.target.value })
}
/>
</Label>
<Label>
Categoria
<select
className={selectClass}
value={criterion.category}
onChange={(event) =>
change(index, { category: event.target.value })
}
>
{Object.entries(categories).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</Label>
<Label className="flex items-center gap-2">
<input
type="checkbox"
checked={criterion.required}
onChange={(event) =>
change(index, {
required: event.target.checked,
})
}
/>
Obrigatório segundo a regra deste processo
</Label>
{form.data.criteria.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() =>
form.setData(
'criteria',
form.data.criteria.filter(
(_, position) => position !== index,
),
)
}
>
Retirar requisito
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
disabled={form.data.criteria.length >= 30}
onClick={() =>
form.setData('criteria', [
...form.data.criteria,
{
label: '',
category: 'administrative',
required: false,
},
])
}
>
Adicionar requisito
</Button>
<Label>
Fundamento dos requisitos ou da alteração
<textarea
required
minLength={10}
maxLength={4000}
className="min-h-24 rounded-md border bg-background p-3 text-sm"
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing}>
Guardar nova versão e iniciar análise
</Button>
</form>
);
}
function AssessForm({
selected,
records,
actorId,
}: {
selected: Set;
records: Assessment[];
actorId: number;
}) {
const blank = (): Answer[] =>
selected.criteria.map((criterion) => ({
criterion_id: criterion.id,
result: 'pending',
evidence: '',
reason: '',
}));
const form = useForm({
previous_id: 0,
quotation_id: '',
reason: '',
answers: blank(),
});
const choose = (id: string) => {
const current = records.find(
(record) =>
record.quotation_id === Number(id) &&
record.analyst_id === actorId,
);
form.setData({
previous_id: current?.id ?? 0,
quotation_id: id,
reason: '',
answers: current
? current.answers.map((answer) => ({ ...answer }))
: blank(),
});
};
const latestMine = records.find(
(record) =>
record.quotation_id === Number(form.data.quotation_id) &&
record.analyst_id === actorId,
);
const change = (id: number, values: Partial<Answer>) =>
form.setData(
'answers',
form.data.answers.map((answer) =>
answer.criterion_id === id ? { ...answer, ...values } : answer,
),
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/qualificacoes/${selected.id}/analises`, {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}}
className="space-y-4"
>
{form.data.quotation_id &&
(latestMine?.id ?? 0) !== form.data.previous_id && (
<div className="rounded-lg border border-amber-300 p-4 text-sm">
<p>
A sua análise já tem outra versão. Confira o último
registo abaixo antes de guardar a correcção. O texto
introduzido foi mantido.
</p>
<Button
type="button"
variant="outline"
className="mt-3"
onClick={() =>
form.setData('previous_id', latestMine?.id ?? 0)
}
>
Usar a última versão como antecedente
</Button>
</div>
)}
<Label>
Proposta
<select
required
className={selectClass}
value={form.data.quotation_id}
onChange={(event) => choose(event.target.value)}
>
<option value="">Seleccionar proposta</option>
{selected.snapshot.proposals.map((proposal) => (
<option key={proposal.id} value={proposal.id}>
{proposal.supplier?.name ??
'Fornecedor indisponível'}{' '}
· {proposal.reference}
</option>
))}
</select>
</Label>
{form.data.quotation_id &&
selected.criteria.map((criterion) => {
const answer = form.data.answers.find(
(entry) => entry.criterion_id === criterion.id,
)!;
return (
<fieldset
key={criterion.id}
className="space-y-3 rounded-xl border p-4"
>
<legend className="px-2 text-sm font-semibold">
{criterion.label}
</legend>
<p className="text-xs text-muted-foreground">
{categories[criterion.category]} ·{' '}
{criterion.required
? 'Declarado obrigatório'
: 'Sem obrigatoriedade declarada'}
</p>
<Label>
Resultado
<select
className={selectClass}
value={answer.result}
onChange={(event) =>
change(criterion.id, {
result: event.target
.value as Answer['result'],
})
}
>
{Object.entries(results).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</Label>
<Label>
Referência de evidência
<Input
required={
answer.result === 'met' ||
answer.result === 'not_met'
}
maxLength={1000}
value={answer.evidence}
onChange={(event) =>
change(criterion.id, {
evidence: event.target.value,
})
}
/>
</Label>
<Label>
Fundamento ou justificação
<textarea
required={answer.result !== 'pending'}
minLength={
answer.result !== 'pending'
? 10
: undefined
}
maxLength={4000}
className="min-h-20 rounded-md border bg-background p-3 text-sm"
value={answer.reason}
onChange={(event) =>
change(criterion.id, {
reason: event.target.value,
})
}
/>
</Label>
</fieldset>
);
})}
<Label>
Fundamento do registo ou correcção
<textarea
required
minLength={10}
maxLength={4000}
className="min-h-20 rounded-md border bg-background p-3 text-sm"
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing || !form.data.quotation_id}>
Guardar a minha análise por versão
</Button>
<p className="text-xs text-muted-foreground">
Outro analista conserva o seu próprio registo. Uma correcção
cria uma nova versão da sua análise.
</p>
</form>
);
}
function AssessmentDetails({
record,
selected,
}: {
record: Assessment;
selected: Set;
}) {
const proposal = selected.snapshot.proposals.find(
(entry) => entry.id === record.quotation_id,
);
return (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer text-sm font-medium">
{proposal?.supplier?.name ?? 'Proposta'} · {proposal?.reference}{' '}
· {record.snapshot.analyst.name} · Análise v{record.revision}
</summary>
<p className="mt-3 text-xs text-muted-foreground">
{roleLabels[record.snapshot.analyst.purchasing_role]} ·{' '}
{date(record.created_at)}
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">{record.reason}</p>
<p className="mt-3 text-xs text-muted-foreground">
{
record.answers.filter(
(answer) => answer.result === 'pending',
).length
}{' '}
por analisar ·{' '}
{
record.answers.filter(
(answer) => answer.result === 'not_met',
).length
}{' '}
não cumpridos. Resumo apenas deste analista, sem decisão formal.
</p>
<ul className="mt-4 space-y-4">
{record.answers.map((answer) => (
<li
key={answer.criterion_id}
className="border-t pt-3 text-sm"
>
<p className="font-medium">
{
selected.criteria.find(
(criterion) =>
criterion.id === answer.criterion_id,
)?.label
}{' '}
· {results[answer.result]}
</p>
<p className="mt-2 whitespace-pre-wrap">
{answer.reason ||
'Sem fundamento registado (por analisar).'}
</p>
<p className="mt-2 text-xs whitespace-pre-wrap text-muted-foreground">
Referência: {answer.evidence || 'Não indicada'}
</p>
</li>
))}
</ul>
</details>
);
}
export default function QualificationPage(props: Props) {
const {
requisition,
sets,
selected,
latestId,
isCurrent,
records,
history,
actorId,
canDefine,
canAssess,
closedForChanges,
} = props;
const { errors } = usePage().props;
const base = `/requisicoes/${requisition.id}/qualificacao`;
return (
<>
<Head title={`Qualificação documental · ${requisition.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow={requisition.number}
title="Qualificação documental"
description={requisition.justification}
action={
<Button asChild variant="outline">
<Link
href={`/requisicoes/${requisition.id}/processo`}
>
Ver processo
</Link>
</Button>
}
/>
<FormErrors errors={errors} />
{closedForChanges && (
<p className="rounded-lg border p-4 text-sm text-muted-foreground">
O estado do pedido ou a existência de adjudicação
impedem novos registos. Os requisitos e análises
anteriores continuam disponíveis para consulta.
</p>
)}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Requisitos e resultados documentais para conferência. Não
admitem ou excluem concorrentes, certificam evidências ou
alteram a comparação ponderada e a adjudicação. Não
aplicável exige fundamento e não dispensa automaticamente um
requisito obrigatório.
</div>
<Link
className="text-sm underline"
href={`/requisicoes/${requisition.id}/cotacoes`}
>
Consultar propostas e comparação actual
</Link>
{canDefine && (
<Panel
title={
latestId
? 'Preparar nova versão dos requisitos'
: 'Definir requisitos do processo'
}
description="A versão será preservada com a base actual das propostas. Não existem requisitos ou valores mínimos predefinidos."
>
<DefineForm {...props} />
</Panel>
)}
{sets.length > 0 && (
<Label className="max-w-md">
Versão dos requisitos
<select
className={selectClass}
value={selected?.id ?? ''}
onChange={(event) =>
router.get(
base,
{ version: event.target.value },
{ preserveScroll: true },
)
}
>
{sets.map((set) => (
<option key={set.id} value={set.id}>
Requisitos v{set.revision}
{set.id === latestId
? ' · Mais recente'
: ' · Histórica'}
</option>
))}
</select>
</Label>
)}
{selected ? (
<>
<Panel
title={`Requisitos v${selected.revision} · ${isCurrent ? 'Base actual' : 'Histórico ou base desactualizada'}`}
description={`Registados por ${selected.snapshot.author.name} · ${date(selected.created_at)}`}
>
<p className="text-sm whitespace-pre-wrap">
{selected.reason}
</p>
<ul className="mt-4 space-y-3">
{selected.criteria.map((criterion) => (
<li
key={criterion.id}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">
{criterion.label}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{categories[criterion.category]} ·{' '}
{criterion.required
? 'Declarado obrigatório segundo o processo'
: 'Sem obrigatoriedade declarada'}
</p>
</li>
))}
</ul>
{!isCurrent && (
<p className="mt-4 text-sm text-amber-800 dark:text-amber-300">
Esta base não aceita novas análises. As
versões anteriores foram preservadas e não
certificam os requisitos ou propostas
actuais.
</p>
)}
</Panel>
{canAssess && (
<Panel
title="A minha análise documental"
description="O analista deve ser distinto do solicitante. Resultados preenchidos exigem fundamento e, quando aplicável, referência de evidência."
>
<AssessForm
key={selected.id}
selected={selected}
records={records}
actorId={actorId}
/>
</Panel>
)}
<Panel
title="Último registo de cada analista por proposta"
description="Os registos não são consolidados por maioria ou quórum e não constituem decisão formal de admissão."
>
{records.length ? (
<div className="space-y-3">
{records.map((record) => (
<AssessmentDetails
key={record.id}
record={record}
selected={selected}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Sem análises registadas nesta versão.
</p>
)}
</Panel>
<Panel title="Histórico das análises desta versão">
<div className="space-y-3">
{history.data.map((record) => (
<AssessmentDetails
key={record.id}
record={record}
selected={selected}
/>
))}
</div>
<nav
aria-label="Histórico das análises"
className="mt-4 flex justify-between gap-3"
>
{history.prev_page_url ? (
<Button asChild variant="outline">
<Link href={history.prev_page_url}>
Mais recentes
</Link>
</Button>
) : (
<span />
)}
{history.next_page_url && (
<Button asChild variant="outline">
<Link href={history.next_page_url}>
Anteriores
</Link>
</Button>
)}
</nav>
</Panel>
</>
) : (
<Panel title="Sem requisitos documentais registados">
<p className="text-sm text-muted-foreground">
Compras pode definir a análise documental após a
aprovação do pedido e o registo de propostas, antes
da adjudicação.
</p>
</Panel>
)}
</div>
</>
);
}
QualificationPage.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Qualificação documental', href: '#' },
],
};
+6
View File
@@ -116,6 +116,12 @@ export default function Comparison({
>
Sessão de abertura e actas
</Link>
<Link
className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/qualificacao`}
>
Qualificação documental
</Link>
<Link
className="text-sm text-teal-700"
href={`/requisicoes/${requisition.id}`}
+4
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\BudgetController;
use App\Http\Controllers\BudgetReinforcementController;
use App\Http\Controllers\CompanyController;
use App\Http\Controllers\ConflictDeclarationController;
use App\Http\Controllers\DocumentaryQualificationController;
use App\Http\Controllers\EquipmentController;
use App\Http\Controllers\ExternalPaymentController;
use App\Http\Controllers\GoodsReceiptController;
@@ -33,6 +34,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::get('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'show'])->name('qualifications.show');
Route::post('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'define']);
Route::post('qualificacoes/{set}/analises', [DocumentaryQualificationController::class, 'assess']);
Route::get('requisicoes/{requisition}/abertura', [ProposalOpeningController::class, 'show'])->name('openings.show');
Route::post('requisicoes/{requisition}/abertura', [ProposalOpeningController::class, 'store']);
Route::get('actas/{opening}/imprimir', [ProposalOpeningController::class, 'print'])->name('openings.print');
@@ -0,0 +1,40 @@
<?php
use App\Models\QualificationSet;
use App\Models\Requisition;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
require __DIR__.'/../../vendor/autoload.php';
$database = $argv[1] ?? '';
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
throw new LogicException('Base de teste inválida.');
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
throw new LogicException('Worker não isolado.');
}
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_qualification_'.$argv[4]]);
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
$actor = User::query()->findOrFail($argv[2]);
auth()->guard()->setUser($actor);
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
try {
$workflow = app(DocumentaryQualificationWorkflow::class);
if ($payload['action'] === 'define') {
$workflow->define($actor, Requisition::query()->findOrFail($argv[3]), $payload['data']);
} else {
$workflow->assess($actor, QualificationSet::query()->findOrFail($argv[3]), $payload['data']);
}
$status = 200;
} catch (ValidationException) {
$status = 422;
} catch (HttpExceptionInterface $exception) {
$status = $exception->getStatusCode();
}
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
+143
View File
@@ -0,0 +1,143 @@
<?php
use App\Models\AuditLog;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Services\DocumentaryQualificationWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require __DIR__.'/../Support/QualificationFixture.php';
$original = DB::connection();
if ($original->getDriverName() !== 'pgsql') {
throw new LogicException('Exige PostgreSQL.');
}
$config = $original->getConfig();
$admin = $original->getPdo();
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
$created = false;
$processes = [];
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
try {
$admin->exec('CREATE DATABASE "'.$database.'"');
$created = true;
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
DB::purge('pgsql');
DB::setDefaultConnection('pgsql');
Schema::clearResolvedInstance('db.schema');
app()->forgetInstance('db.schema');
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
throw new LogicException('Migração fora do isolamento recusada.');
}
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
foreach (['competing_requirements', 'same_analyst', 'distinct_analysts', 'changed_basis', 'revoked_role', 'requirements_versus_assessment'] as $scenario) {
$f = qualificationFixture();
$workflow = app(DocumentaryQualificationWorkflow::class);
$set = null;
if ($scenario !== 'competing_requirements') {
$set = $workflow->define($f['issuer'], $f['r'], $f['definition']);
}
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $f['tenant']->id)->lockForUpdate()->first();
$processes = [];
foreach (['A', 'B'] as $index => $suffix) {
$define = $scenario === 'competing_requirements' || ($scenario === 'requirements_versus_assessment' && $index === 0);
$actor = $define ? $f['issuer'] : ($scenario === 'distinct_analysts' && $index === 1 ? $f['other_analyst'] : $f['analyst']);
$target = $define ? $f['r']->id : $set->id;
$data = $define ? [...$f['definition'], 'previous_id' => $set->id ?? 0] : $f['assessment'];
$process = new Process([PHP_BINARY, __DIR__.'/qualification-worker.php', $database, (string) $actor->id, (string) $target, $suffix], base_path(), $workerEnv);
$process->setInput(json_encode(['action' => $define ? 'define' : 'assess', 'data' => $data], JSON_THROW_ON_ERROR));
$process->setTimeout(40);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_qualification_A','sgc_qualification_B') AND wait_event_type = 'Lock'");
$deadline = microtime(true) + 20;
do {
$statement->execute([$database]);
$waits = (int) $statement->fetchColumn();
if ($waits === 2) {
break;
}
foreach ($processes as $process) {
if (! $process->isRunning()) {
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
}
}
usleep(20000);
} while (microtime(true) < $deadline);
if ($scenario === 'revoked_role') {
$connection->table('users')->where('id', $f['analyst']->id)->update(['purchasing_role' => 'requester']);
}
if ($scenario === 'changed_basis') {
$connection->table('quotations')->where('id', $f['quote']->id)->update(['reference' => 'Proposta alterada durante a espera']);
}
$connection->commit();
if ($waits !== 2) {
throw new RuntimeException('Sem sobreposição de bloqueios.');
}
$results = [];
foreach ($processes as $process) {
$process->wait();
if (! $process->isSuccessful()) {
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
}
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
}
$statuses = array_column($results, 'status');
sort($statuses);
$expected = match ($scenario) {
'distinct_analysts' => [200, 200], 'changed_basis' => [422, 422], 'revoked_role' => [403, 403], default => [200, 422]
};
$sets = QualificationSet::query()->count();
$assessments = QualificationAssessment::query()->count();
if ($scenario === 'requirements_versus_assessment') {
if (! in_array($statuses, [[200, 200], [200, 422]], true) || $sets !== 2 || $assessments !== ($statuses === [200, 200] ? 1 : 0) || QualificationAssessment::query()->where('qualification_set_id', QualificationSet::query()->max('id'))->exists()) {
throw new RuntimeException('Análise misturada com requisitos novos.');
}
} elseif ($statuses !== $expected || $sets !== 1 || $assessments !== match ($scenario) {
'same_analyst' => 1, 'distinct_analysts' => 2, default => 0
}) {
throw new RuntimeException('Resultado concorrente inesperado.');
}
if (count(array_unique(array_column($results, 'backend_pid'))) !== 2 || AuditLog::query()->where('action', 'qualification.defined')->count() !== $sets || AuditLog::query()->where('action', 'qualification.assessed')->count() !== $assessments) {
throw new RuntimeException('Auditoria ou ligações inconsistentes.');
}
if ($scenario === 'distinct_analysts' && QualificationAssessment::query()->distinct()->count('analyst_id') !== 2) {
throw new RuntimeException('Registos de analistas substituídos.');
}
if (QualificationAssessment::query()->where('revision', '!=', 1)->exists()) {
throw new RuntimeException('Versões inesperadas.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'requirement_versions' => $sets, 'analyst_records' => $assessments];
}
$report['passed'] = true;
} finally {
foreach ($processes as $process) {
if ($process->isRunning()) {
$process->stop();
}
}
if (isset($connection) && $connection->transactionLevel() > 0) {
$connection->rollBack();
}
DB::disconnect('pgsql');
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
$admin->exec('DROP DATABASE "'.$database.'"');
$report['test_database_dropped'] = true;
}
}
file_put_contents(base_path('docs/qualificacao-concorrencia-postgresql.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
@@ -0,0 +1,159 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\Department;
use App\Models\PurchaseAward;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\ProposalComparison;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/QualificationFixture.php';
beforeEach(function () {
$this->f = qualificationFixture();
$this->url = '/requisicoes/'.$this->f['r']->id.'/qualificacao';
$this->actingAs($this->f['issuer']);
});
function defineDocumentaryQualification(object $test, array $changes = []): QualificationSet
{
$test->actingAs($test->f['issuer'])->post($test->url, [...$test->f['definition'], ...$changes])->assertSessionHasNoErrors();
return QualificationSet::query()->latest('revision')->firstOrFail();
}
function recordDocumentaryAssessment(object $test, QualificationSet $set, array $changes = [], ?User $actor = null): QualificationAssessment
{
$test->actingAs($actor ?? $test->f['analyst'])->post('/qualificacoes/'.$set->id.'/analises', [...$test->f['assessment'], ...$changes])->assertSessionHasNoErrors();
return QualificationAssessment::query()->latest('id')->firstOrFail();
}
test('documentary qualification freezes process criteria without financial decisions', function () {
$before = app(ProposalComparison::class)->inspect($this->f['r']);
$set = defineDocumentaryQualification($this);
$answers = $this->f['assessment']['answers'];
$answers[0]['result'] = 'not_met';
recordDocumentaryAssessment($this, $set, ['answers' => $answers]);
expect($set->criteria[0]['id'])->toBe(1)->and($set->snapshot['proposals'][0]['id'])->toBe($this->f['quote']->id)
->and(BudgetEntry::query()->count())->toBe(0)->and(PurchaseAward::query()->count())->toBe(0)
->and(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
->assertInertia(fn (Assert $page) => $page->component('purchases/qualification')->where('isCurrent', true)->has('records', 1));
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('kind', 'Qualificação documental')));
});
test('documentary criteria versions and analyst corrections never overwrite earlier records', function () {
$set = defineDocumentaryQualification($this);
$first = recordDocumentaryAssessment($this, $set);
$second = recordDocumentaryAssessment($this, $set, ['previous_id' => $first->id, 'reason' => 'Correcção fictícia identificada']);
expect($second->revision)->toBe(2)->and($first->refresh()->reason)->toBe('Análise exclusivamente fictícia');
$new = defineDocumentaryQualification($this, ['previous_id' => $set->id, 'reason' => 'Nova versão sem apagar requisitos']);
expect($new->revision)->toBe(2)->and(QualificationAssessment::query()->where('qualification_set_id', $new->id)->count())->toBe(0);
$this->actingAs($this->f['analyst'])->post('/qualificacoes/'.$set->id.'/analises', [...$this->f['assessment'], 'previous_id' => $second->id])->assertSessionHasErrors('qualification');
$this->get($this->url.'?version='.$set->id)->assertInertia(fn (Assert $page) => $page->where('isCurrent', false)->where('canAssess', false)->where('records.0.id', $second->id)->has('history.data', 2));
});
test('different documentary analysts retain independent records and cannot replace another analyst', function () {
$set = defineDocumentaryQualification($this);
$first = recordDocumentaryAssessment($this, $set);
$this->actingAs($this->f['other_analyst'])->post('/qualificacoes/'.$set->id.'/analises', [...$this->f['assessment'], 'previous_id' => $first->id])->assertSessionHasErrors('previous_id');
$other = recordDocumentaryAssessment($this, $set, [], $this->f['other_analyst']);
expect($other->revision)->toBe(1)->and($other->analyst_id)->not->toBe($first->analyst_id);
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->has('records', 2)->has('history.data', 2));
});
test('documentary results require exact criteria and appropriate evidence and reasons', function () {
$set = defineDocumentaryQualification($this);
$data = $this->f['assessment'];
$data['answers'][0]['evidence'] = '';
$this->actingAs($this->f['analyst'])->post('/qualificacoes/'.$set->id.'/analises', $data)->assertSessionHasErrors('answers');
$data['answers'][0]['result'] = 'not_applicable';
$this->post('/qualificacoes/'.$set->id.'/analises', $data)->assertSessionHasNoErrors();
$data['previous_id'] = QualificationAssessment::query()->sole()->id;
$data['answers'][0]['reason'] = '';
$this->post('/qualificacoes/'.$set->id.'/analises', $data)->assertSessionHasErrors('answers');
$data['answers'][0]['criterion_id'] = 99;
$this->post('/qualificacoes/'.$set->id.'/analises', $data)->assertSessionHasErrors('answers');
$this->post('/qualificacoes/'.$set->id.'/analises', [...$this->f['assessment'], 'analyst_id' => $this->f['other_analyst']->id])->assertSessionHasErrors('analyst_id');
});
test('documentary requirements reject duplicates forged snapshots and stale versions', function () {
$data = $this->f['definition'];
$data['criteria'][1]['label'] = mb_strtoupper($data['criteria'][0]['label']);
$this->post($this->url, $data)->assertSessionHasErrors('criteria');
$this->post($this->url, [...$this->f['definition'], 'snapshot' => ['approved' => true]])->assertSessionHasErrors('snapshot');
defineDocumentaryQualification($this);
$this->post($this->url, $this->f['definition'])->assertSessionHasErrors('previous_id');
expect(QualificationSet::query()->count())->toBe(1);
});
test('changed documentary proposal bases block new analysis and require fresh requirements', function () {
$set = defineDocumentaryQualification($this);
recordDocumentaryAssessment($this, $set);
$this->f['quote']->update(['reference' => 'Referência posterior fictícia']);
$this->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertSessionHasErrors('qualification');
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('isCurrent', false)->where('canAssess', false));
$this->actingAs($this->f['issuer'])->post($this->url, [...$this->f['definition'], 'previous_id' => $set->id])->assertSessionHasErrors('basis_hash');
$opening = app(ProposalOpeningWorkflow::class);
$fresh = defineDocumentaryQualification($this, ['previous_id' => $set->id, 'basis_hash' => $opening->basis($opening->proposals($this->f['r']))]);
expect($fresh->snapshot['proposals'][0]['reference'])->toBe('Referência posterior fictícia')->and($set->refresh()->snapshot['proposals'][0]['reference'])->toBe('PROP-TEST');
});
test('documentary requester and revoked analysts cannot write while admin only reads', function () {
$set = defineDocumentaryQualification($this);
$this->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertForbidden();
$this->actingAs($this->f['analyst']);
DB::table('users')->where('id', $this->f['analyst']->id)->update(['purchasing_role' => 'requester']);
$this->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertForbidden();
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($admin)->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canDefine', false)->where('canAssess', false));
$this->post($this->url, [...$this->f['definition'], 'previous_id' => $set->id])->assertForbidden();
});
test('documentary qualification enforces company department and guest access', function () {
$set = defineDocumentaryQualification($this);
$this->actingAs($this->f['receiver'])->get($this->url)->assertForbidden();
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($head)->get($this->url)->assertNotFound();
$other = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$this->actingAs(User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Finance]))->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertNotFound();
auth()->logout();
$this->get($this->url)->assertRedirect('/login');
});
test('documentary qualification stops new records after award without removing history', function () {
$set = defineDocumentaryQualification($this);
recordDocumentaryAssessment($this, $set);
PurchaseAward::query()->create($this->f['award_attributes']);
$this->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertSessionHasErrors('qualification');
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canDefine', false)->where('canAssess', false)->has('records', 1));
});
test('documentary qualification versions are immutable and audit failure rolls back an assessment', function () {
$set = defineDocumentaryQualification($this);
$this->actingAs($this->f['analyst']);
AuditLog::creating(function (AuditLog $event) {
if ($event->action === 'qualification.assessed') {
throw new RuntimeException('Falha fictícia de auditoria');
}
});
try {
expect(fn () => app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $set, $this->f['assessment']))->toThrow(RuntimeException::class);
} finally {
AuditLog::flushEventListeners();
}
expect(QualificationAssessment::query()->count())->toBe(0);
$assessment = recordDocumentaryAssessment($this, $set);
expect(fn () => $assessment->update(['reason' => 'Alterar passado']))->toThrow(LogicException::class);
expect(fn () => $set->delete())->toThrow(LogicException::class);
});
+14
View File
@@ -15,6 +15,7 @@ use App\Models\PurchaseAward;
use App\Models\PurchaseControlPolicy;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\QualificationSet;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\SparePart;
@@ -27,6 +28,8 @@ use App\Models\Warehouse;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\ProposalComparison;
use App\Services\ProposalOpeningWorkflow;
use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow;
use App\Services\RequisitionWorkflow;
@@ -613,3 +616,14 @@ test('cancellation and reservation release roll back together if audit fails', f
->and(BudgetEntry::query()->where('kind', 'release')->count())->toBe(0)
->and((int) BudgetEntry::query()->sum('reserved_minor'))->toBeGreaterThan(0);
});
test('documentary noncompliance does not silently exclude an otherwise eligible award', function () {
$opening = app(ProposalOpeningWorkflow::class);
$comparison = app(ProposalComparison::class)->inspect($this->r);
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/qualificacao', ['previous_id' => 0, 'basis_hash' => $opening->basis($opening->proposals($this->r)), 'reason' => 'Requisito documental fictício do processo', 'criteria' => [['label' => 'Requisito documental para conferência', 'category' => 'administrative', 'required' => true]]])->assertSessionHasNoErrors();
$set = QualificationSet::query()->sole();
$this->actingAs($this->finance)->post('/qualificacoes/'.$set->id.'/analises', ['previous_id' => 0, 'quotation_id' => $this->quotes[0]->id, 'reason' => 'Registo documental sem decisão formal', 'answers' => [['criterion_id' => 1, 'result' => 'not_met', 'evidence' => 'Referência de teste fictícia', 'reason' => 'Não conformidade registada para apreciação']]])->assertSessionHasNoErrors();
expect(app(ProposalComparison::class)->inspect($this->r))->toBe($comparison);
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
expect($order->total_minor)->toBe(10000)->and(PurchaseOrder::query()->count())->toBe(1);
});
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\User;
require_once __DIR__.'/ProposalOpeningFixture.php';
/** The underlying fixture refuses non-isolated databases. */
function qualificationFixture(): array
{
$f = proposalOpeningFixture();
$analyst = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$otherAnalyst = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$definition = ['previous_id' => 0, 'basis_hash' => $f['data']['basis_hash'], 'reason' => 'Requisitos exclusivamente fictícios', 'criteria' => [['label' => 'Documento definido no processo', 'category' => 'administrative', 'required' => true], ['label' => 'Conformidade com a especificação', 'category' => 'technical', 'required' => false]]];
$assessment = ['previous_id' => 0, 'quotation_id' => $f['quote']->id, 'reason' => 'Análise exclusivamente fictícia', 'answers' => [['criterion_id' => 1, 'result' => 'met', 'evidence' => 'Referência fictícia 1', 'reason' => 'Documento conferido no teste isolado'], ['criterion_id' => 2, 'result' => 'pending', 'evidence' => '', 'reason' => '']]];
return [...$f, 'analyst' => $analyst, 'other_analyst' => $otherAnalyst, 'definition' => $definition, 'assessment' => $assessment];
}
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000018.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = DB::connection();
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
}
$pdo = $connection->getPdo();
$migration = '2026_10_28_000018_create_documentary_qualification';
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
throw new LogicException('Migração já aplicada; não repetir.');
}
$config = $connection->getConfig();
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
if (! is_file($bin.$tool)) {
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
}
}
$envHash = hash_file('sha256', base_path('.env'));
$directory = storage_path('app/private/backups/documentary-qualification-000018-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Não foi possível criar backup privado.');
}
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
$columns = [];
$fingerprints = function (PDO $db, array $tables) use ($quote, &$columns): array {
$result = [];
foreach ($tables as $table) {
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM (SELECT '.implode(', ', array_map($quote, $columns[$table])).' FROM public.'.$quote($table).') t')->fetchAll(PDO::FETCH_COLUMN);
sort($rows, SORT_STRING);
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
}
return $result;
};
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$statement = $pdo->prepare("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position");
$statement->execute([$table]);
$columns[$table] = $statement->fetchAll(PDO::FETCH_COLUMN);
}
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
$restoreCreated = false;
try {
// pg_dump and the manifest read exactly the same committed snapshot.
$pdo->beginTransaction();
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
$backupState = $fingerprints($pdo, $tables);
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
$dump->setTimeout(120);
$dump->run();
if (! $dump->isSuccessful()) {
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
}
$pdo->commit();
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
$restoreCreated = true;
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
$restore->setTimeout(120);
$restore->run();
if (! $restore->isSuccessful()) {
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
}
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
$restored = DB::connection('budget_restore')->getPdo();
if ($fingerprints($restored, $tables) !== $backupState) {
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
}
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'columns' => $columns, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
DB::disconnect('budget_restore');
unset($restored);
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
$restoreCreated = false;
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
$before = $fingerprints($pdo, $businessTables);
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
if ($exit !== 0) {
throw new RuntimeException('Migração falhou; consultar backup privado.');
}
$after = $fingerprints($pdo, $businessTables);
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
}
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'qualification_sets_count' => (int) $pdo->query('SELECT count(*) FROM qualification_sets')->fetchColumn(), 'qualification_assessments_count' => (int) $pdo->query('SELECT count(*) FROM qualification_assessments')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/qualificacao-migracao-000018.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
DB::disconnect('budget_restore');
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
}
}