Reune comparacao e qualificacao em relatorio de conferencia imprimivel

This commit is contained in:
2026-10-28 19:41:45 +02:00
parent 9138379432
commit 2751c6468d
13 changed files with 1091 additions and 0 deletions
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Models\Requisition;
use App\Services\ProcurementReviewReport;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementReviewReportController extends Controller
{
public function show(Request $request, Requisition $requisition, ProcurementReviewReport $builder): Response
{
$report = $builder->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;
}
}
@@ -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'];
+4
View File
@@ -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<array{criterion_id:int,result:string,evidence:string,reason:string}> $answers
*/
class QualificationAssessment extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ProcurementReviewReport
{
/** @return array<string,mixed> */
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']),
];
});
}
}
@@ -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
}
+65
View File
@@ -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
@@ -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<string, number>;
};
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<Answer['result'], string> = {
pending: 'Por analisar',
met: 'Cumprido',
not_met: 'Não cumprido',
not_applicable: 'Não aplicável',
};
const weights: Record<string, string> = {
price: 'Preço',
quality: 'Qualidade',
delivery: 'Entrega',
history: 'Histórico',
payment: 'Pagamento',
};
const categories: Record<string, string> = {
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 (
<>
<Head title={`Relatório de conferência · ${r.number}`} />
<style>{`@media print { @page { size: A4 landscape; margin: 14mm; } .review-report { max-width: none; padding: 0; font-size: 11px; gap: 18px; } .review-report section { box-shadow: none; } .review-report table { font-size: 10px; table-layout: fixed; width: 100%; } .review-report th, .review-report td { overflow-wrap: anywhere; } .review-report thead { display: table-header-group; } .review-report tr { break-inside: avoid; } .review-report h2, .review-report h3 { break-after: avoid; } }`}</style>
<div className="review-report mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<header className="hidden border-b-2 border-[#ed1c24] pb-3 print:block">
<p className="font-semibold">
SGC · Sistema de Gestão de Compras
</p>
<p>{report.company}</p>
</header>
<PageTitle
eyebrow={r.number}
title="Relatório de conferência"
description={`${report.company} · ${r.department} · Solicitante: ${r.requester}`}
action={
<div className="flex gap-3 print:hidden">
<Button asChild variant="outline">
<Link href={`/requisicoes/${r.id}/processo`}>
Ver processo
</Link>
</Button>
<Button onClick={() => window.print()}>
<Printer className="size-4" />
Imprimir esta consulta
</Button>
</div>
}
/>
<div className="rounded-xl border p-4 text-sm">
<p>
Consultado em {generated} · Africa/Johannesburg (UTC+2),
por {report.generated_by}.
</p>
<p className="mt-2 text-muted-foreground">
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.
</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
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.
</div>
<Panel title="Identificação do pedido">
{r.status !== 'approved' && (
<p className="mb-3 text-sm text-muted-foreground">
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.
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-4xl text-sm whitespace-pre-wrap">
{r.justification}
</p>
<Status value={r.status} />
</div>
</Panel>
<Panel
title="Comparação actual das propostas"
description="Dados e pontuações devolvidos pelo método de comparação existente. A ordem abaixo é a de registo, sem nova classificação."
>
<p className="mb-4 text-sm font-medium">
{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.'}
</p>
{report.comparison.rows.length ? (
<>
<div className="overflow-x-auto print:overflow-visible">
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr>
{[
'Concorrente / proposta',
'Sem IVA',
'IVA',
'Total',
'Avaliações e pontuação',
'Verificações do circuito actual',
].map((label) => (
<th
key={label}
className="border bg-muted/40 p-3 font-medium"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{report.comparison.rows.map((row) => (
<tr key={row.id}>
<td className="border p-3 align-top">
<p className="font-semibold">
{row.supplier}
</p>
<p className="mt-1">
{row.reference}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Entrega:{' '}
{row.delivery_days} dias
· Validade:{' '}
{date(row.valid_until)}
</p>
</td>
<td className="border p-3 align-top">
{money(
row.subtotal_minor,
row.currency,
)}
</td>
<td className="border p-3 align-top">
{money(
row.tax_minor,
row.currency,
)}
</td>
<td className="border p-3 align-top">
{money(
row.total_minor,
row.currency,
)}
</td>
<td className="border p-3 align-top">
<p>
{row.evaluation_count}{' '}
avaliações válidas
</p>
<p className="mt-2">
Pontuação:{' '}
{row.score_minor ===
null
? 'Não calculada'
: (
row.score_minor /
100
).toFixed(2)}
</p>
{row.is_top && (
<p className="mt-2 font-medium">
{report.comparison
.top_count > 1
? 'Maior pontuação em empate; sem desempate automático.'
: 'Maior pontuação da matriz; não é uma adjudicação.'}
</p>
)}
</td>
<td className="border p-3 align-top">
{row.ineligibility_reasons
.length ? (
<ul className="list-inside list-disc space-y-1">
{row.ineligibility_reasons.map(
(reason) => (
<li
key={
reason
}
>
{reason}
</li>
),
)}
</ul>
) : (
<p>
Sem impedimentos
assinalados por esta
matriz. Não equivale
a admissão formal.
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 space-y-2">
{report.comparison.rows.map((row) => (
<p
key={row.id}
className="text-xs text-muted-foreground"
>
Ponderações de {row.reference}:{' '}
{Object.entries(row.weights)
.map(
([key, value]) =>
`${weights[key] ?? key} ${value}%`,
)
.join(' · ')}
.
</p>
))}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Sem propostas registadas.
</p>
)}
</Panel>
<Panel
title="Qualificação documental"
description="Apenas a versão mais recente dos requisitos e o último registo de cada analista por proposta. As correcções anteriores mantêm-se no histórico do módulo."
>
{qualification ? (
<>
<p className="text-sm font-medium">
Requisitos v{qualification.revision} ·{' '}
{qualification.is_current
? 'Base documental actual'
: 'Base documental histórica ou desactualizada'}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Registados por {qualification.author} em{' '}
{date(qualification.created_at)}.
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">
{qualification.reason}
</p>
{!qualification.is_current && (
<p className="mt-4 rounded-lg border border-amber-300 p-3 text-sm">
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.
</p>
)}
<ul className="mt-4 space-y-2">
{qualification.criteria.map((criterion) => (
<li
key={criterion.id}
className="rounded-lg border p-3 text-sm"
>
<strong className="font-medium">
{criterion.label}
</strong>
<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>
<div className="mt-6 space-y-6">
{qualification.proposals.map((proposal) => (
<section key={proposal.id}>
<h3 className="text-base font-semibold">
{proposal.supplier} ·{' '}
{proposal.reference}
</h3>
{proposal.analyses.length ? (
proposal.analyses.map(
(analysis) => (
<div
key={analysis.id}
className="mt-3 rounded-xl border p-4"
>
<h4 className="text-sm font-semibold">
{
analysis.analyst
.name
}{' '}
·{' '}
{
roleLabels[
analysis
.analyst
.purchasing_role
]
}{' '}
· Análise v
{analysis.revision}
</h4>
<p className="mt-1 text-xs text-muted-foreground">
{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
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">
{analysis.reason}
</p>
<table className="mt-4 w-full border-collapse text-left text-sm">
<thead>
<tr>
{[
'Requisito',
'Resultado',
'Referência da evidência',
'Fundamento',
].map(
(
label,
) => (
<th
key={
label
}
className="border bg-muted/40 p-2 font-medium"
>
{
label
}
</th>
),
)}
</tr>
</thead>
<tbody>
{analysis.answers.map(
(
answer,
) => (
<tr
key={
answer.criterion_id
}
>
<td className="border p-2 align-top">
{
qualification.criteria.find(
(
criterion,
) =>
criterion.id ===
answer.criterion_id,
)
?.label
}
</td>
<td className="border p-2 align-top">
{
results[
answer
.result
]
}
</td>
<td className="border p-2 align-top whitespace-pre-wrap">
{answer.evidence ||
'Não indicada'}
</td>
<td className="border p-2 align-top whitespace-pre-wrap">
{answer.reason ||
'Sem fundamento registado; por analisar.'}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
),
)
) : (
<p className="mt-3 text-sm text-muted-foreground">
Sem registos de analistas para
esta proposta nesta versão. Não
se presume uma análise concluída
ou uma dispensa.
</p>
)}
</section>
))}
</div>
<p className="mt-5 text-sm text-muted-foreground">
Não há consolidação por maioria ou quórum. Não
aplicável não dispensa automaticamente um
requisito declarado obrigatório.
</p>
<Link
href={`/requisicoes/${r.id}/qualificacao?version=${qualification.id}`}
className="mt-4 inline-flex text-sm underline print:hidden"
>
Consultar requisitos e histórico completo
</Link>
</>
) : (
<p className="text-sm text-muted-foreground">
Sem qualificação documental registada. A ausência do
registo não constitui admissão ou exclusão de
concorrentes.
</p>
)}
</Panel>
<Panel title="Registos do processo para consulta">
<div className="space-y-3 text-sm">
{report.opening ? (
<div>
<p>
Acta {report.opening.reference} · Versão{' '}
{report.opening.revision}.
</p>
{report.opening.source_changed && (
<p className="mt-1 text-muted-foreground">
Há diferenças ou propostas não incluídas
face aos dados actuais. A acta conserva
a sua base original.
</p>
)}
<Link
href={`/requisicoes/${r.id}/abertura`}
className="mt-2 inline-flex underline print:hidden"
>
Consultar actas
</Link>
</div>
) : (
<p>
Sem acta de abertura registada neste módulo
opcional.
</p>
)}
{report.award ? (
<p>
Adjudicação #{report.award.id} ·{' '}
{statusLabels[report.award.status] ??
report.award.status}{' '}
· Valor preservado:{' '}
{money(report.award.total_minor, 'MZN')}.
</p>
) : (
<p>Sem adjudicação registada.</p>
)}
{report.order ? (
<p>
Ordem {report.order.number} · Valor preservado:{' '}
{money(
report.order.total_minor,
report.order.currency,
)}
.
</p>
) : (
<p>Sem ordem emitida.</p>
)}
<p className="text-muted-foreground">
Valores de documentos emitidos ou da adjudicação são
os seus registos preservados; não são recalculados
por este relatório.
</p>
</div>
</Panel>
<footer className="border-t pt-4 text-xs text-muted-foreground">
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.
</footer>
</div>
</>
);
}
ReviewReport.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Relatório de conferência', href: '#' },
],
};
+6
View File
@@ -122,6 +122,12 @@ export default function Comparison({
>
Qualificação documental
</Link>
<Link
className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/relatorio`}
>
Relatório de conferência e impressão
</Link>
<Link
className="text-sm text-teal-700"
href={`/requisicoes/${requisition.id}`}
+2
View File
@@ -10,6 +10,7 @@ use App\Http\Controllers\ExternalPaymentController;
use App\Http\Controllers\GoodsReceiptController;
use App\Http\Controllers\InvestmentPolicyController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\ProcurementReviewReportController;
use App\Http\Controllers\ProposalOpeningController;
use App\Http\Controllers\PurchaseControlController;
use App\Http\Controllers\PurchaseController;
@@ -34,6 +35,7 @@ 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}/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']);
@@ -0,0 +1,50 @@
<?php
use App\Models\User;
use App\Services\ProcurementReviewReport;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
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_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);
+131
View File
@@ -0,0 +1,131 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\Department;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\ProposalOpeningWorkflow;
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;
$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;
@@ -0,0 +1,93 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\Department;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\ProcurementReviewReport;
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.'/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']);
});
+17
View File
@@ -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);
});