diff --git a/app/Http/Controllers/ProcurementReviewReportController.php b/app/Http/Controllers/ProcurementReviewReportController.php new file mode 100644 index 0000000..873aa82 --- /dev/null +++ b/app/Http/Controllers/ProcurementReviewReportController.php @@ -0,0 +1,22 @@ +build($request->user(), $requisition->id); + Inertia::encryptHistory(); + $response = Inertia::render('purchases/review-report', ['report' => $report])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } +} diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php index 629a718..b8e7443 100644 --- a/app/Http/Controllers/PurchaseProcessController.php +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -37,6 +37,9 @@ class PurchaseProcessController extends Controller $base = '/requisicoes/'.$requisition->id; $stages = []; $documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]]; + if ($purchases) { + $documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio']; + } $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']; diff --git a/app/Models/QualificationAssessment.php b/app/Models/QualificationAssessment.php index 6eee267..6e0961e 100644 --- a/app/Models/QualificationAssessment.php +++ b/app/Models/QualificationAssessment.php @@ -6,6 +6,10 @@ use App\Models\Concerns\AppendOnlyInventoryRecord; use App\Models\Concerns\BelongsToTenant; use Illuminate\Database\Eloquent\Model; +/** + * @property array{analyst:array{id:int,name:string,purchasing_role:string},basis_hash:string} $snapshot + * @property list $answers + */ class QualificationAssessment extends Model { use AppendOnlyInventoryRecord, BelongsToTenant; diff --git a/app/Services/ProcurementReviewReport.php b/app/Services/ProcurementReviewReport.php new file mode 100644 index 0000000..e6b2e7c --- /dev/null +++ b/app/Services/ProcurementReviewReport.php @@ -0,0 +1,75 @@ + */ + public function build(User $actor, int $requisitionId): array + { + abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403); + if (DB::connection()->getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) { + throw new \LogicException('O relatório PostgreSQL exige uma transacção própria de leitura.'); + } + + return DB::transaction(function () use ($actor, $requisitionId) { + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'); + } + $actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id); + abort_unless(in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); + $request = Requisition::query()->visibleTo($actor)->with(['department:id,name', 'requester:id,name'])->findOrFail($requisitionId); + $comparison = app(ProposalComparison::class)->inspect($request); + $rows = []; + foreach ($comparison['rows'] as $row) { + $rows[] = [ + ...array_intersect_key($row, array_flip(['id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'weights', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])), + 'supplier' => $row['supplier']['name'] ?? 'Fornecedor indisponível', + 'is_top' => $comparison['ready'] && in_array($row['id'], $comparison['top_ids'], true), + ]; + } + $openingWorkflow = app(ProposalOpeningWorkflow::class); + $basis = $openingWorkflow->basis($openingWorkflow->proposals($request)); + $set = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first(); + $qualification = null; + if ($set) { + $latestIds = QualificationAssessment::query()->where('qualification_set_id', $set->id)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id'); + $records = QualificationAssessment::query()->whereIn('id', $latestIds)->orderBy('quotation_id')->orderBy('analyst_id')->get(); + $proposals = []; + foreach ($set->snapshot['proposals'] as $proposal) { + $analyses = []; + foreach ($records->where('quotation_id', $proposal['id']) as $record) { + $analyses[] = ['id' => $record->id, 'revision' => $record->revision, 'analyst' => $record->snapshot['analyst'], 'created_at' => $record->created_at, 'reason' => $record->reason, 'answers' => $record->answers]; + } + $proposals[] = ['id' => $proposal['id'], 'reference' => $proposal['reference'], 'supplier' => $proposal['supplier']['name'] ?? 'Fornecedor indisponível', 'analyses' => $analyses]; + } + $qualification = ['id' => $set->id, 'revision' => $set->revision, 'created_at' => $set->created_at, 'author' => $set->snapshot['author']['name'], 'reason' => $set->reason, 'criteria' => $set->criteria, 'is_current' => $set->basis_hash === $basis, 'proposals' => $proposals]; + } + $opening = ProposalOpening::query()->where('requisition_id', $request->id)->latest('revision')->first(); + $award = PurchaseAward::query()->where('requisition_id', $request->id)->first(); + $order = PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $request->id)->first(); + + return [ + 'generated_at' => now()->toIso8601String(), 'generated_by' => $actor->name, + 'company' => Tenant::query()->findOrFail($actor->tenant_id)->name, + 'requisition' => [...$request->only(['id', 'number', 'status', 'justification']), 'department' => $request->department?->name, 'requester' => $request->requester?->name], + 'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows], + 'qualification' => $qualification, + 'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null, + 'award' => $award?->only(['id', 'status', 'total_minor']), + 'order' => $order?->only(['id', 'number', 'currency', 'total_minor']), + ]; + }); + } +} diff --git a/docs/relatorio-concorrencia-postgresql.json b/docs/relatorio-concorrencia-postgresql.json new file mode 100644 index 0000000..e89d5f6 --- /dev/null +++ b/docs/relatorio-concorrencia-postgresql.json @@ -0,0 +1,27 @@ +{ + "tested_at_utc": "2026-10-28T17:36:51+00:00", + "separate_database": true, + "transport": "Independent report reader and concurrent PostgreSQL business connection", + "scenarios": [ + { + "name": "consistent_snapshot", + "passed": true, + "observed_advisory_waits": 1, + "business_audit_unchanged": true + }, + { + "name": "revoked_role", + "passed": true, + "observed_advisory_waits": 1, + "business_audit_unchanged": true + }, + { + "name": "changed_department", + "passed": true, + "observed_advisory_waits": 1, + "business_audit_unchanged": true + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index 4530a71..89d1088 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -1,5 +1,70 @@ # Validação técnica do SGC — 28 de Outubro de 2026 +## Incremento: relatório de conferência e impressão + +As propostas e a visão única do processo incluem o acesso a +`/requisicoes/{requisition}/relatorio`. O relatório reúne identificação do +pedido, matriz comparativa actual, última versão dos requisitos documentais, +último registo de cada analista por proposta e referências à acta, +adjudicação e ordem existentes. Não é uma recomendação formal nem uma nova +etapa obrigatória da compra. + +As pontuações, ponderações e impedimentos são devolvidos pelo método de +comparação existente, sem recalcular outro modelo ou classificar por menor +preço. Uma matriz incompleta não apresenta maior pontuação como recomendação; +empates da matriz pronta são identificados sem desempate automático. As +propostas são apresentadas na ordem de registo. Valores de adjudicação e +ordem preservados são mostrados separadamente dos preços actuais. + +Os resultados documentais pertencem à base preservada na versão dos +requisitos. Se essa base divergir da actual, o relatório assinala-o e mantém +nomes, referências e análises da versão original. Respostas por analisar e +propostas sem registos não são apresentadas como análise concluída. Não há +consolidação por maioria, quórum presumido, dispensa automática de requisito, +admissão/exclusão ou autorização financeira. + +Em PostgreSQL, o relatório é construído numa transacção própria de leitura, +`REPEATABLE READ, READ ONLY`, sem criar auditoria ou outros registos de +negócio. O operador é relido antes de consultar dados, respeitando empresa, +departamento e perfis autorizados para propostas. Não são devolvidos dados +bancários, contactos, beneficiários ou documentação privada dos fornecedores. +A resposta usa `private, no-store` e histórico Inertia cifrado. + +A impressão usa os dados já carregados no ecrã, sem nova consulta ao servidor +ao imprimir. Apresenta instante da consulta, fuso Africa/Johannesburg (UTC+2), +utilizador e identificação do processo. Actualizar a página é uma consulta +nova. O relatório não fica guardado como versão assinada, decisão ou prova +de validação de evidências. A impressão está preparada em A4 horizontal, +com cabeçalhos de tabela repetíveis, mas a revisão visual continua pendente. + +Resultado: **461 testes aprovados, 2 ignorados, 3698 asserções**. Sete testes +novos verificam ausência de decisões inventadas e de escritas, dados antigos +separados dos actuais, registos independentes por analista, restrições de +perfil/empresa/departamento, revalidação do operador, minimização de dados e +empates/valores preservados de ordens efectivamente emitidas pelo circuito. +PHPStan, Pint, TypeScript, build e lint dos ficheiros alterados passaram. + +Três verificações PostgreSQL passaram numa base temporária exclusiva: +consistência da consulta enquanto outra ligação corrige análise, altera +proposta e cria requisitos novos; perfil revogado depois de carregar o +operador; departamento alterado antes de iniciar a leitura. Foi observada a +espera concorrente por bloqueio consultivo. O primeiro cenário verificou +explicitamente `repeatable read` e `read_only=on` e devolveu apenas o estado +original, sem misturar versões posteriores. Não foram criados eventos de +negócio pelo leitor e a base temporária foi eliminada. Ver +`relatorio-concorrencia-postgresql.json`. + +Não houve migração nem escrita na base de negócio. As alterações locais de +`compatibilidade.md`, `desenvolvimento.md` e `orientacoes-continuidade.md` +foram preservadas e excluídas desta entrega. A formatação global desse +primeiro documento permanece fora do âmbito. Revisão visual, decisão formal +de admissão/exclusão, recomendação formal e cancelamento de ordens emitidas +continuam pendentes nos respectivos âmbitos. + +O navegador voltou a devolver `The browser access host is unavailable` nesta +verificação. Testes HTTP, compilação e a opção de imprimir não substituem a +revisão visual pública, autenticada e de impressão. + ## Incremento: qualificação documental validada O responsável validou o primeiro âmbito documental de diff --git a/resources/js/pages/purchases/review-report.tsx b/resources/js/pages/purchases/review-report.tsx new file mode 100644 index 0000000..a96dac9 --- /dev/null +++ b/resources/js/pages/purchases/review-report.tsx @@ -0,0 +1,596 @@ +import { Head, Link } from '@inertiajs/react'; +import { Printer } from 'lucide-react'; +import { PageTitle, Panel, Status } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; +import { date, roleLabels, statusLabels } 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 Analysis = { + id: number; + revision: number; + analyst: { name: string; purchasing_role: string }; + created_at: string; + reason: string; + answers: Answer[]; +}; +type ComparisonRow = { + id: number; + reference: string; + supplier: string; + currency: string; + subtotal_minor: number; + tax_minor: number; + total_minor: number; + delivery_days: number; + valid_until: string; + evaluation_count: number; + score_minor: number | null; + eligible: boolean; + ineligibility_reasons: string[]; + is_top: boolean; + weights: Record; +}; +type Report = { + generated_at: string; + generated_by: string; + company: string; + requisition: { + id: number; + number: string; + status: string; + justification: string; + department: string; + requester: string; + }; + comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] }; + qualification: { + id: number; + revision: number; + created_at: string; + author: string; + reason: string; + criteria: Criterion[]; + is_current: boolean; + proposals: { + id: number; + reference: string; + supplier: string; + analyses: Analysis[]; + }[]; + } | null; + opening: { + id: number; + revision: number; + reference: string; + source_changed: boolean; + } | null; + award: { id: number; status: string; total_minor: number } | null; + order: { + id: number; + number: string; + currency: string; + total_minor: number; + } | null; +}; +const money = (minor: number, currency: string) => + `${(minor / 100).toLocaleString('pt-PT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; +const results: Record = { + pending: 'Por analisar', + met: 'Cumprido', + not_met: 'Não cumprido', + not_applicable: 'Não aplicável', +}; +const weights: Record = { + price: 'Preço', + quality: 'Qualidade', + delivery: 'Entrega', + history: 'Histórico', + payment: 'Pagamento', +}; +const categories: Record = { + legal: 'Jurídica', + financial: 'Económico-financeira', + fiscal: 'Fiscal', + technical: 'Técnica', + administrative: 'Administrativa', +}; + +export default function ReviewReport({ report }: { report: Report }) { + const r = report.requisition; + const qualification = report.qualification; + const generated = new Date(report.generated_at).toLocaleString('pt-PT', { + timeZone: 'Africa/Johannesburg', + }); + return ( + <> + + +
+
+

+ SGC · Sistema de Gestão de Compras +

+

{report.company}

+
+ + + +
+ } + /> +
+

+ Consultado em {generated} · Africa/Johannesburg (UTC+2), + por {report.generated_by}. +

+

+ A impressão usa os dados desta consulta. Actualizar a + página gera uma nova consulta; este relatório não é uma + versão assinada ou guardada como decisão. +

+
+
+ Relatório de consulta, sem recomendação de vencedor, + admissão ou exclusão de concorrentes, certificação de + evidências ou autorização financeira. A comparação actual e + a qualificação documental são apresentadas separadamente. +
+ + {r.status !== 'approved' && ( +

+ O pedido não está em estado aprovado. As pontuações + abaixo são uma consulta da matriz e não autorizam + novas decisões ou emissão. +

+ )} +
+

+ {r.justification} +

+ +
+
+ +

+ {report.comparison.ready + ? 'A comparação está pronta segundo o método existente. Isto não confirma orçamento, aprovações ou autorização para emitir uma ordem.' + : 'A matriz ainda não reúne as condições do circuito actual. Não se apresenta uma proposta recomendada.'} +

+ {report.comparison.rows.length ? ( + <> +
+ + + + {[ + 'Concorrente / proposta', + 'Sem IVA', + 'IVA', + 'Total', + 'Avaliações e pontuação', + 'Verificações do circuito actual', + ].map((label) => ( + + ))} + + + + {report.comparison.rows.map((row) => ( + + + + + + + + + ))} + +
+ {label} +
+

+ {row.supplier} +

+

+ {row.reference} +

+

+ Entrega:{' '} + {row.delivery_days} dias + · Validade:{' '} + {date(row.valid_until)} +

+
+ {money( + row.subtotal_minor, + row.currency, + )} + + {money( + row.tax_minor, + row.currency, + )} + + {money( + row.total_minor, + row.currency, + )} + +

+ {row.evaluation_count}{' '} + avaliações válidas +

+

+ Pontuação:{' '} + {row.score_minor === + null + ? 'Não calculada' + : ( + row.score_minor / + 100 + ).toFixed(2)} +

+ {row.is_top && ( +

+ {report.comparison + .top_count > 1 + ? 'Maior pontuação em empate; sem desempate automático.' + : 'Maior pontuação da matriz; não é uma adjudicação.'} +

+ )} +
+ {row.ineligibility_reasons + .length ? ( +
    + {row.ineligibility_reasons.map( + (reason) => ( +
  • + {reason} +
  • + ), + )} +
+ ) : ( +

+ Sem impedimentos + assinalados por esta + matriz. Não equivale + a admissão formal. +

+ )} +
+
+
+ {report.comparison.rows.map((row) => ( +

+ Ponderações de {row.reference}:{' '} + {Object.entries(row.weights) + .map( + ([key, value]) => + `${weights[key] ?? key} ${value}%`, + ) + .join(' · ')} + . +

+ ))} +
+ + ) : ( +

+ Sem propostas registadas. +

+ )} +
+ + {qualification ? ( + <> +

+ Requisitos v{qualification.revision} ·{' '} + {qualification.is_current + ? 'Base documental actual' + : 'Base documental histórica ou desactualizada'} +

+

+ Registados por {qualification.author} em{' '} + {date(qualification.created_at)}. +

+

+ {qualification.reason} +

+ {!qualification.is_current && ( +

+ Os resultados abaixo referem-se às propostas + preservadas nesta versão. Não os aplique à + comparação actual; nomes e referências + abaixo pertencem à base documental original. +

+ )} +
    + {qualification.criteria.map((criterion) => ( +
  • + + {criterion.label} + +

    + {categories[criterion.category]} ·{' '} + {criterion.required + ? 'Declarado obrigatório segundo o processo' + : 'Sem obrigatoriedade declarada'} +

    +
  • + ))} +
+
+ {qualification.proposals.map((proposal) => ( +
+

+ {proposal.supplier} ·{' '} + {proposal.reference} +

+ {proposal.analyses.length ? ( + proposal.analyses.map( + (analysis) => ( +
+

+ { + analysis.analyst + .name + }{' '} + ·{' '} + { + roleLabels[ + analysis + .analyst + .purchasing_role + ] + }{' '} + · Análise v + {analysis.revision} +

+

+ {date( + analysis.created_at, + )}{' '} + ·{' '} + { + analysis.answers.filter( + (answer) => + answer.result === + 'pending', + ).length + }{' '} + por analisar ·{' '} + { + analysis.answers.filter( + (answer) => + answer.result === + 'not_met', + ).length + }{' '} + não cumpridos +

+

+ {analysis.reason} +

+ + + + {[ + 'Requisito', + 'Resultado', + 'Referência da evidência', + 'Fundamento', + ].map( + ( + label, + ) => ( + + ), + )} + + + + {analysis.answers.map( + ( + answer, + ) => ( + + + + + + + ), + )} + +
+ { + label + } +
+ { + qualification.criteria.find( + ( + criterion, + ) => + criterion.id === + answer.criterion_id, + ) + ?.label + } + + { + results[ + answer + .result + ] + } + + {answer.evidence || + 'Não indicada'} + + {answer.reason || + 'Sem fundamento registado; por analisar.'} +
+
+ ), + ) + ) : ( +

+ Sem registos de analistas para + esta proposta nesta versão. Não + se presume uma análise concluída + ou uma dispensa. +

+ )} +
+ ))} +
+

+ Não há consolidação por maioria ou quórum. Não + aplicável não dispensa automaticamente um + requisito declarado obrigatório. +

+ + Consultar requisitos e histórico completo + + + ) : ( +

+ Sem qualificação documental registada. A ausência do + registo não constitui admissão ou exclusão de + concorrentes. +

+ )} +
+ +
+ {report.opening ? ( +
+

+ Acta {report.opening.reference} · Versão{' '} + {report.opening.revision}. +

+ {report.opening.source_changed && ( +

+ Há diferenças ou propostas não incluídas + face aos dados actuais. A acta conserva + a sua base original. +

+ )} + + Consultar actas + +
+ ) : ( +

+ Sem acta de abertura registada neste módulo + opcional. +

+ )} + {report.award ? ( +

+ Adjudicação #{report.award.id} ·{' '} + {statusLabels[report.award.status] ?? + report.award.status}{' '} + · Valor preservado:{' '} + {money(report.award.total_minor, 'MZN')}. +

+ ) : ( +

Sem adjudicação registada.

+ )} + {report.order ? ( +

+ Ordem {report.order.number} · Valor preservado:{' '} + {money( + report.order.total_minor, + report.order.currency, + )} + . +

+ ) : ( +

Sem ordem emitida.

+ )} +

+ Valores de documentos emitidos ou da adjudicação são + os seus registos preservados; não são recalculados + por este relatório. +

+
+
+
+ SGC · Sistema de Gestão de Compras · {r.number} · Consulta + de {generated}, Africa/Johannesburg (UTC+2). Decisão formal + e autorização financeira seguem os seus circuitos próprios. +
+ + + ); +} +ReviewReport.layout = { + breadcrumbs: [ + { title: 'Requisições', href: '/requisicoes' }, + { title: 'Relatório de conferência', href: '#' }, + ], +}; diff --git a/resources/js/pages/quotations/show.tsx b/resources/js/pages/quotations/show.tsx index 41c5084..8e04c8c 100644 --- a/resources/js/pages/quotations/show.tsx +++ b/resources/js/pages/quotations/show.tsx @@ -122,6 +122,12 @@ export default function Comparison({ > Qualificação documental + + Relatório de conferência e impressão + group(function () { Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store'); Route::middleware(EnsureCompanyIsConfigured::class)->group(function () { + Route::get('requisicoes/{requisition}/relatorio', [ProcurementReviewReportController::class, 'show'])->name('purchases.report'); 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']); diff --git a/tests/Concurrency/review-report-worker.php b/tests/Concurrency/review-report-worker.php new file mode 100644 index 0000000..2702abf --- /dev/null +++ b/tests/Concurrency/review-report-worker.php @@ -0,0 +1,50 @@ +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_review_report']); +$actor = User::query()->findOrFail($argv[2]); +auth()->guard()->setUser($actor); +$lock = (int) $argv[4]; +$proof = null; +if ($argv[5] === 'consistent_snapshot') { + $paused = false; + DB::listen(function (QueryExecuted $query) use ($lock, &$proof, &$paused) { + if (! $paused && str_contains($query->sql, 'from "qualification_sets"')) { + $paused = true; + $proof = DB::selectOne("select current_setting('transaction_isolation') as isolation, current_setting('transaction_read_only') as read_only"); + DB::select('select pg_advisory_xact_lock(?)', [$lock]); + } + }); +} else { + // Cache the authenticated actor before the parent revokes role/department. + DB::select('select pg_advisory_lock(?)', [$lock]); + DB::select('select pg_advisory_unlock(?)', [$lock]); +} +try { + $report = app(ProcurementReviewReport::class)->build($actor, (int) $argv[3]); + $status = 200; +} catch (HttpExceptionInterface $exception) { + $status = $exception->getStatusCode(); + $report = null; +} catch (ModelNotFoundException) { + $status = 404; + $report = null; +} +echo json_encode(['status' => $status, 'report' => $report, 'proof' => $proof], JSON_THROW_ON_ERROR); diff --git a/tests/Concurrency/run-review-report.php b/tests/Concurrency/run-review-report.php new file mode 100644 index 0000000..b596a4e --- /dev/null +++ b/tests/Concurrency/run-review-report.php @@ -0,0 +1,131 @@ + '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; +$lockHeld = false; +$process = null; +$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent report reader and concurrent PostgreSQL business connection', '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 (['consistent_snapshot', 'revoked_role', 'changed_department'] as $scenario) { + $f = qualificationFixture(); + $workflow = app(DocumentaryQualificationWorkflow::class); + $set = $workflow->define($f['issuer'], $f['r'], $f['definition']); + $actor = $f['analyst']; + auth()->guard()->setUser($actor); + $first = $workflow->assess($actor, $set, $f['assessment']); + if ($scenario === 'changed_department') { + $actor = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + } + $lock = random_int(1, 1000000000); + DB::select('select pg_advisory_lock(?)', [$lock]); + $lockHeld = true; + $process = new Process([PHP_BINARY, __DIR__.'/review-report-worker.php', $database, (string) $actor->id, (string) $f['r']->id, (string) $lock, $scenario], base_path(), $workerEnv); + $process->setTimeout(40); + $process->start(); + $statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name = 'sgc_review_report' AND wait_event_type = 'Lock' AND wait_event = 'advisory'"); + $deadline = microtime(true) + 20; + do { + $statement->execute([$database]); + $waits = (int) $statement->fetchColumn(); + if ($waits === 1) { + break; + } + if (! $process->isRunning()) { + throw new RuntimeException('Leitor terminou antes do bloqueio: '.$process->getErrorOutput()); + } + usleep(20000); + } while (microtime(true) < $deadline); + if ($waits !== 1) { + throw new RuntimeException('Leitura concorrente não demonstrada.'); + } + if ($scenario === 'consistent_snapshot') { + $answers = $f['assessment']['answers']; + $answers[0]['result'] = 'not_met'; + $workflow->assess($f['analyst'], $set, [...$f['assessment'], 'previous_id' => $first->id, 'answers' => $answers]); + $f['quote']->update(['reference' => 'REFERENCIA-CONCORRENTE']); + auth()->guard()->setUser($f['issuer']); + $opening = app(ProposalOpeningWorkflow::class); + $workflow->define($f['issuer'], $f['r'], [...$f['definition'], 'previous_id' => $set->id, 'basis_hash' => $opening->basis($opening->proposals($f['r']))]); + } elseif ($scenario === 'revoked_role') { + DB::table('users')->where('id', $actor->id)->update(['purchasing_role' => 'requester']); + } else { + $other = Department::query()->create(['tenant_id' => $f['tenant']->id, 'name' => 'Departamento posterior fictício']); + DB::table('users')->where('id', $actor->id)->update(['department_id' => $other->id]); + } + $audits = AuditLog::query()->count(); + DB::select('select pg_advisory_unlock(?)', [$lock]); + $lockHeld = false; + $process->wait(); + if (! $process->isSuccessful()) { + throw new RuntimeException('Falha do leitor: '.$process->getErrorOutput()); + } + $result = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + if (AuditLog::query()->count() !== $audits) { + throw new RuntimeException('O relatório criou eventos de negócio.'); + } + if ($scenario === 'consistent_snapshot') { + $data = $result['report']; + if ($result['status'] !== 200 || $result['proof']['isolation'] !== 'repeatable read' || $result['proof']['read_only'] !== 'on' || $data['comparison']['rows'][0]['reference'] !== 'PROP-TEST' || $data['qualification']['revision'] !== 1 || $data['qualification']['proposals'][0]['analyses'][0]['revision'] !== 1 || $data['qualification']['proposals'][0]['analyses'][0]['answers'][0]['result'] !== 'met' || QualificationSet::query()->count() !== 2 || QualificationAssessment::query()->max('revision') !== 2) { + throw new RuntimeException('Relatório misturou momentos de consulta.'); + } + } elseif ($result['status'] !== ($scenario === 'revoked_role' ? 403 : 404) || $result['report'] !== null) { + throw new RuntimeException('Acesso revogado expôs o relatório.'); + } + $report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'observed_advisory_waits' => $waits, 'business_audit_unchanged' => true]; + } + $report['passed'] = true; +} finally { + if ($process && $process->isRunning()) { + $process->stop(); + } + if ($lockHeld) { + DB::select('select pg_advisory_unlock(?)', [$lock]); + } + 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/relatorio-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; diff --git a/tests/Feature/ProcurementReviewReportTest.php b/tests/Feature/ProcurementReviewReportTest.php new file mode 100644 index 0000000..69db215 --- /dev/null +++ b/tests/Feature/ProcurementReviewReportTest.php @@ -0,0 +1,93 @@ +f = qualificationFixture(); + $this->url = '/requisicoes/'.$this->f['r']->id.'/relatorio'; + $this->actingAs($this->f['issuer']); +}); + +function documentaryReportRecords(object $test): QualificationSet +{ + $workflow = app(DocumentaryQualificationWorkflow::class); + $test->actingAs($test->f['issuer']); + $set = $workflow->define($test->f['issuer'], $test->f['r'], $test->f['definition']); + $test->actingAs($test->f['analyst']); + $workflow->assess($test->f['analyst'], $set, $test->f['assessment']); + + return $set; +} + +test('review report returns a private read-only consultation without inventing missing decisions', function () { + $before = AuditLog::query()->count(); + $this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private') + ->assertInertia(fn (Assert $page) => $page->component('purchases/review-report') + ->where('report.requisition.number', $this->f['r']->number) + ->where('report.comparison.ready', false)->where('report.comparison.top_count', 0) + ->where('report.comparison.rows.0.score_minor', null)->where('report.comparison.rows.0.is_top', false) + ->where('report.qualification', null)->where('report.award', null)->where('report.order', null) + ->missing('report.recommended_supplier_id')->missing('report.bank_snapshot')); + expect(AuditLog::query()->count())->toBe($before); +}); + +test('review report separates current comparison from outdated documentary identities and results', function () { + $set = documentaryReportRecords($this); + $this->f['quote']->update(['reference' => 'REFERENCIA-POSTERIOR']); + $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('report.comparison.rows.0.reference', 'REFERENCIA-POSTERIOR') + ->where('report.qualification.is_current', false)->where('report.qualification.revision', $set->revision) + ->where('report.qualification.proposals.0.reference', 'PROP-TEST') + ->where('report.qualification.proposals.0.analyses.0.answers.1.result', 'pending')); +}); + +test('review report retains the latest record of each analyst without majority or silent replacement', function () { + $set = documentaryReportRecords($this); + $first = QualificationAssessment::query()->sole(); + $workflow = app(DocumentaryQualificationWorkflow::class); + $workflow->assess($this->f['analyst'], $set, [...$this->f['assessment'], 'previous_id' => $first->id, 'reason' => 'Correcção fictícia para conferência']); + $this->actingAs($this->f['other_analyst']); + $workflow->assess($this->f['other_analyst'], $set, $this->f['assessment']); + $before = AuditLog::query()->count(); + $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('report.qualification.is_current', true) + ->has('report.qualification.proposals.0.analyses', 2) + ->where('report.qualification.proposals.0.analyses.0.revision', 2) + ->where('report.qualification.proposals.0.analyses.1.revision', 1)); + expect(QualificationAssessment::query()->count())->toBe(3)->and(AuditLog::query()->count())->toBe($before); +}); + +test('review report enforces financial-reader roles companies departments and guest protection', function () { + $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(); + $head->forceFill(['department_id' => $this->f['r']->department_id])->save(); + $this->actingAs($head)->get($this->url)->assertOk(); + $other = Tenant::query()->create(['name' => 'Outra empresa fictícia']); + $this->actingAs(User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]))->get($this->url)->assertNotFound(); + auth()->logout(); + $this->get($this->url)->assertRedirect('/login'); +}); + +test('review report reloads cached actor access before reading business records', function () { + $this->actingAs($this->f['analyst']); + DB::table('users')->where('id', $this->f['analyst']->id)->update(['purchasing_role' => 'requester']); + $this->get($this->url)->assertForbidden(); +}); + +test('review report omits unneeded supplier and evaluator details', function () { + $report = app(ProcurementReviewReport::class)->build($this->f['issuer'], $this->f['r']->id); + expect($report['comparison']['rows'][0]['supplier'])->toBeString() + ->and($report['comparison']['rows'][0])->not->toHaveKeys(['evaluations', 'qualified_evaluations', 'items', 'beneficiaries', 'document_references', 'bank_snapshot']); +}); diff --git a/tests/Feature/PurchaseAwardOrderTest.php b/tests/Feature/PurchaseAwardOrderTest.php index ef7558e..607c0ce 100644 --- a/tests/Feature/PurchaseAwardOrderTest.php +++ b/tests/Feature/PurchaseAwardOrderTest.php @@ -28,6 +28,7 @@ use App\Models\Warehouse; use App\Services\BudgetWorkflow; use App\Services\ConflictGuard; use App\Services\EquipmentWorkflow; +use App\Services\ProcurementReviewReport; use App\Services\ProposalComparison; use App\Services\ProposalOpeningWorkflow; use App\Services\PurchaseControlPolicyWorkflow; @@ -627,3 +628,19 @@ test('documentary noncompliance does not silently exclude an otherwise eligible $order = issuePurchaseOrder($this, selectPurchaseAward($this)); expect($order->total_minor)->toBe(10000)->and(PurchaseOrder::query()->count())->toBe(1); }); + +test('review report reflects tied source scores and existing frozen orders without deciding a winner', function () { + foreach ($this->quotes as $quote) { + $quote->items()->update(['unit_price_minor' => 10000, 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]); + $quote->update(['subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]); + } + $this->actingAs($this->buyer); + $comparison = app(ProposalComparison::class)->inspect($this->r); + $report = app(ProcurementReviewReport::class)->build($this->buyer, $this->r->id); + expect($report['comparison']['ready'])->toBeTrue()->and($report['comparison']['top_count'])->toBe(3) + ->and(array_column($report['comparison']['rows'], 'score_minor'))->toBe(array_column($comparison['rows'], 'score_minor')) + ->and($report)->not->toHaveKey('recommended_supplier_id'); + $order = issuePurchaseOrder($this, selectPurchaseAward($this)); + $report = app(ProcurementReviewReport::class)->build($this->buyer, $this->r->id); + expect($report['order']['number'])->toBe($order->number)->and($report['order']['total_minor'])->toBe(10000); +});