Reune comparacao e qualificacao em relatorio de conferencia imprimivel
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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);
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user