Regista abertura de propostas e actas imutaveis por versao

This commit is contained in:
2026-10-28 18:50:02 +02:00
parent 513f430edf
commit 07e775c492
21 changed files with 1404 additions and 4 deletions
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\Requisition;
use App\Services\ConflictGuard;
use App\Services\ProposalOpeningWorkflow;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProposalOpeningController extends Controller
{
private function access(Request $request, int $requisitionId): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($requisitionId);
}
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $workflow): Response
{
$this->access($request, $requisition->id);
$proposals = $workflow->proposals($requisition);
$query = ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision');
$latest = (clone $query)->first();
$openings = $query->paginate(10)->withQueryString();
Inertia::encryptHistory();
$response = Inertia::render('purchases/opening', [
'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings,
'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash,
'latestId' => $latest->id ?? 0,
'canRecord' => $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, Requisition $requisition, ProposalOpeningWorkflow $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate([
'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64',
'opened_at' => 'required|date_format:Y-m-d\TH:i',
'timezone' => 'required|in:Africa/Maputo,Africa/Johannesburg,UTC',
'submission_deadline' => 'nullable|date_format:Y-m-d\TH:i|before_or_equal:opened_at',
'reference' => 'required|string|min:3|max:150', 'location' => 'required|string|min:3|max:300',
'reason' => 'required|string|min:10|max:4000', 'notes' => 'nullable|string|max:6000',
'quotation_ids' => 'required|array|min:1|max:100', 'quotation_ids.*' => 'required|integer|distinct',
'participants' => 'required|array|min:1|max:50', 'participants.*.name' => 'required|string|min:2|max:150',
'participants.*' => 'required|array:name,capacity,group',
'participants.*.capacity' => 'required|string|min:2|max:150', 'participants.*.group' => 'required|in:jury,participant',
'annexes' => 'nullable|array|max:30', 'annexes.*' => 'required|string|min:3|max:300',
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
'confirmed' => 'accepted',
]);
$opened = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $data['opened_at'], $data['timezone']);
if (! $opened || $opened->isFuture()) {
throw ValidationException::withMessages(['opened_at' => 'A sessão não pode estar no futuro no fuso horário indicado.']);
}
$data['opened_at'] = $opened->utc()->format('Y-m-d H:i:s');
$data['submission_deadline'] = empty($data['submission_deadline']) ? null : CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $data['submission_deadline'], $data['timezone'])->utc()->format('Y-m-d H:i:s');
$data['quotation_ids'] = array_map(intval(...), $data['quotation_ids']);
$workflow->record($request->user(), $requisition, $data);
return back();
}
public function print(Request $request, ProposalOpening $opening): Response
{
$this->access($request, $opening->requisition_id);
$response = response()->view('purchases.opening', ['opening' => $opening, 'snapshot' => $opening->snapshot]);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
}
@@ -9,6 +9,7 @@ use App\Models\BudgetEntry;
use App\Models\ExternalPayment;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchasePlanItem;
@@ -35,6 +36,10 @@ class PurchaseProcessController extends Controller
$base = '/requisicoes/'.$requisition->id;
$stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
$opening = $purchases ? ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($opening) {
$documents[] = ['label' => 'Acta de abertura · Versão '.$opening->revision, 'kind' => 'Abertura', 'href' => $base.'/abertura'];
}
$planning = PurchasePlanItem::query()->visibleTo($actor)->where('requisition_id', $requisition->id)->first(['id', 'title']);
if ($planning) {
$documents[] = ['label' => $planning->title, 'kind' => 'Planeamento', 'href' => '/planeamento?item='.$planning->id];
@@ -98,7 +103,7 @@ class PurchaseProcessController extends Controller
}
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) {
$actions = [...$actions, 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
$actions = [...$actions, 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
}
if ($inventory) {
$actions[] = 'receipt.confirmed';
@@ -113,7 +118,7 @@ class PurchaseProcessController extends Controller
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
'future' => ['Sessão de abertura e acta', 'Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
'future' => ['Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class ProposalOpening extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['participants' => 'array', 'annexes' => 'array', 'snapshot' => 'array'];
}
}
+1
View File
@@ -84,6 +84,7 @@ class ConflictGuard
'requisition.created', 'requisition.updated', 'requisition.submitted',
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
'opening.recorded',
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected',
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\Quotation;
use App\Models\QuotationItem;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProposalOpeningWorkflow
{
/** @return list<array<string,mixed>> */
public function proposals(Requisition $requisition): array
{
return array_values(Quotation::query()->where('requisition_id', $requisition->id)->with(['supplier', 'items' => fn ($query) => $query->orderBy('id')])->orderBy('id')->get()->map(fn (Quotation $quote) => [
...$quote->only(['id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'payment_terms']),
'supplier' => $quote->supplier?->only(['id', 'name', 'nuit']),
'items' => $quote->items->map(fn ($item) => $item->only(['requisition_item_id', 'description', 'quantity', 'unit_price_minor', 'tax_rate', 'subtotal_minor', 'tax_minor', 'total_minor']))->all(),
])->all());
}
/** @param list<array<string,mixed>> $proposals */
public function basis(array $proposals): string
{
return hash('sha256', json_encode($proposals, JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $data */
public function record(User $actor, Requisition $requisition, array $data): ProposalOpening
{
return DB::transaction(function () use ($actor, $requisition, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
app(ConflictGuard::class)->assertParticipation($actor, $requisition);
if ($requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['opening' => 'Registe a abertura num pedido aprovado, antes da adjudicação. O histórico posterior não é reescrito.']);
}
$latest = ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision')->first();
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
throw ValidationException::withMessages(['previous_id' => 'A acta mudou. Actualize a página antes de registar outra versão.']);
}
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
$proposals = $this->proposals($requisition);
if ($data['basis_hash'] !== $this->basis($proposals)) {
throw ValidationException::withMessages(['basis_hash' => 'As propostas mudaram desde a consulta. Confira os dados actualizados antes de registar.']);
}
$selected = array_values(array_filter($proposals, fn ($proposal) => in_array($proposal['id'], $data['quotation_ids'], true)));
if ($selected === [] || count($selected) !== count($data['quotation_ids'])) {
throw ValidationException::withMessages(['quotation_ids' => 'Seleccione apenas propostas deste processo, sem repetições.']);
}
foreach ($selected as $proposal) {
app(ConflictGuard::class)->assertProcess($requisition, $proposal['supplier']['id'], $actor);
}
$opening = ProposalOpening::query()->create([
'tenant_id' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1,
'opened_at' => $data['opened_at'], 'timezone' => $data['timezone'], 'submission_deadline' => $data['submission_deadline'] ?? null,
'reference' => $data['reference'], 'location' => $data['location'], 'reason' => $data['reason'], 'notes' => $data['notes'] ?? null,
'participants' => $data['participants'], 'annexes' => $data['annexes'] ?? [], 'basis_hash' => $this->basis($selected),
'snapshot' => ['company' => Tenant::query()->findOrFail($actor->tenant_id)->only(['id', 'name']), 'requisition' => $requisition->only(['id', 'number', 'justification']), 'author' => $actor->only(['id', 'name']), 'proposals' => $selected],
'created_at' => now(),
]);
app(RequisitionWorkflow::class)->audit($actor, 'opening.recorded', $requisition, null, ['opening_id' => $opening->id, 'revision' => $opening->revision, 'previous_id' => $latest?->id, 'quotation_ids' => $data['quotation_ids']]);
return $opening;
});
}
}
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('proposal_openings', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->unsignedInteger('revision');
$table->dateTime('opened_at');
$table->string('timezone', 64);
$table->dateTime('submission_deadline')->nullable();
$table->string('reference', 150);
$table->string('location', 300);
$table->text('reason');
$table->text('notes')->nullable();
$table->json('participants');
$table->json('annexes');
$table->json('snapshot');
$table->string('basis_hash', 64);
$table->timestamp('created_at');
$table->unique(['requisition_id', 'revision']);
});
}
public function down(): void
{
throw new LogicException('Preservar as actas e as suas versões.');
}
};
@@ -0,0 +1,33 @@
{
"tested_at_utc": "2026-10-28T16:45:17+00:00",
"separate_database": true,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
{
"name": "competing_initial_versions",
"passed": true,
"overlapping_lock_waits": 2,
"immutable_versions": 1
},
{
"name": "competing_corrections",
"passed": true,
"overlapping_lock_waits": 2,
"immutable_versions": 2
},
{
"name": "revoked_role",
"passed": true,
"overlapping_lock_waits": 2,
"immutable_versions": 0
},
{
"name": "changed_basis",
"passed": true,
"overlapping_lock_waits": 2,
"immutable_versions": 0
}
],
"passed": true,
"test_database_dropped": true
}
+16
View File
@@ -0,0 +1,16 @@
{
"verified_at_utc": "2026-10-28T16:46:10+00:00",
"migration": "2026_10_28_000017_create_proposal_openings",
"batch": 18,
"backup_created": true,
"backup_bytes": 291397,
"restore_verified": true,
"restored_tables": 54,
"restore_database_dropped": true,
"business_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"proposal_openings_count": 0,
"purchase_plan_milestones_count": 0,
"operational_validation": false
}
+2 -1
View File
@@ -2,7 +2,8 @@
Evolução do roteiro: a visão única foi entregue em `76d2463`. O incremento de
planeamento acrescenta itens por empresa, responsáveis, marcos e datas
previstas/realizadas, com ligação posterior às requisições. Actas, qualificação
previstas/realizadas, com ligação posterior às requisições. O registo documental
opcional da abertura e actas por versão também foi implementado. Qualificação
específica e avaliação em novas modalidades continuam por implementar. Estes
avanços não importam os processos históricos nem aprovam as regras de
cancelamento de ordens emitidas.
+68
View File
@@ -1,5 +1,73 @@
# Validação técnica do SGC — 28 de Outubro de 2026
## Incremento: sessão de abertura e actas por versão
Nas propostas existe o acesso «Sessão de abertura e actas», em
`/requisicoes/{requisition}/abertura`. Compras pode registar uma sessão num
pedido aprovado, antes de existir adjudicação. O registo é opcional; não
introduz um concurso público obrigatório, um número mínimo de propostas,
um júri obrigatório ou um novo circuito financeiro em todas as empresas.
O operador declara referência, local, data/hora, fuso horário, intervenientes,
qualidade em que participam, observações e referências de anexos, selecciona
as propostas e confirma a conferência. As propostas seleccionadas são
copiadas no servidor com fornecedor, referência, valores e linhas; não se
aceitam valores de uma acta enviados pelo cliente. Uma impressão autenticada
usa a cópia preservada, incluindo nome da empresa e autor do registo.
Não contém pontuações ou uma recomendação de adjudicação.
As datas são interpretadas no fuso declarado, guardadas em UTC e apresentadas
nesse fuso. Este primeiro âmbito suporta Africa/Johannesburg, Africa/Maputo e
UTC; a interface começa pelo fuso do contexto local. Sessões futuras e prazo
de apresentação posterior à sessão são recusados. O prazo é declarado; não
se presume que a data de criação de uma cotação no SGC seja a sua data real
de entrega pelo concorrente.
Correcções criam versões novas, com fundamento e referência à versão anterior
na auditoria. As versões guardadas não são editadas ou eliminadas pela
aplicação. Uma base de propostas alterada desde a consulta ou uma versão
anterior desactualizada é recusada. A interface permite conferir a base
actualizada sem apagar os textos introduzidos. Propostas posteriormente
adicionadas ou alteradas não entram nas actas anteriores; uma divergência é
assinalada na consulta. Não se alterou a política de recepção de cotações.
Os nomes dos intervenientes são declarações, não assinaturas digitais
verificadas. A impressão disponibiliza espaço para assinaturas manuscritas.
As referências de anexos não representam carregamento ou validação de
ficheiros. Registar uma acta não avalia propostas, não certifica a realização
da sessão, não adjudica, não reserva orçamento e não autoriza compras.
Alterações documentais depois da adjudicação exigem um circuito futuro;
este incremento conserva as actas anteriores para consulta, sem as reescrever.
Administração, Compras, Finanças e chefias autorizadas consultam; apenas
Compras regista. Mantêm-se isolamento entre empresas, âmbito departamental,
verificação de conflitos e revalidação do operador dentro da transacção.
Solicitantes e Armazém não ganham acesso a preços ou actas. Consulta e
impressão usam `private, no-store`; o histórico Inertia é cifrado. Os registos
aparecem nos documentos e eventos da visão única conforme o perfil.
A migração aditiva 000017 foi aplicada no batch 18 após backup privado de
291397 bytes e restauro verificado de 54 tabelas. Dados anteriores,
restrições e `.env` foram preservados; não foram criadas actas fictícias na
base de negócio. Ver `abertura-migracao-000017.json`. Backup e manifesto
ficam fora do Git.
Resultado: **443 testes aprovados, 2 ignorados, 3431 asserções**, incluindo
11 testes novos de snapshots, versões, base desactualizada, impressão,
fuso horário, validação, segregação, empresas/departamentos, revogação de
perfil, rollback de auditoria, imutabilidade e escape de texto. Quatro
cenários PostgreSQL passaram, cada um com duas ligações e duas esperas de
bloqueio simultâneas: versões iniciais concorrentes, correcções concorrentes,
perfil revogado e base alterada durante a espera. A base temporária exclusiva
foi eliminada. Ver `abertura-concorrencia-postgresql.json`.
PHPStan, Pint, TypeScript e build passaram. A revisão visual da interface e
da impressão permanece pendente pela indisponibilidade do navegador. Não se
declara validação operacional de concursos, assinaturas, orçamentos ou regras
de cancelamento de ordens emitidas. Publicação de concursos, propostas
seladas, qualificação por etapas e novos métodos de avaliação continuam
pendentes; os exemplos locais não os tornam obrigações universais.
## Incremento: planeamento por empresa
O menu Compras inclui `/planeamento`. Administração e Compras registam itens
+560
View File
@@ -0,0 +1,560 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Participant = {
name: string;
capacity: string;
group: 'jury' | 'participant';
};
type Proposal = {
id: number;
reference: string;
currency: string;
supplier: { name: string } | null;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
};
type Opening = {
id: number;
revision: number;
opened_at: string;
timezone: string;
location: string;
reference: string;
reason: string;
notes: string | null;
participants: Participant[];
annexes: string[];
created_at: string;
snapshot: { author: { name: string }; proposals: Proposal[] };
};
type Props = {
requisition: { id: number; number: string; justification: string };
proposals: Proposal[];
basisHash: string;
latestId: number;
sourceChanged: boolean;
canRecord: boolean;
openings: {
data: Opening[];
prev_page_url: string | null;
next_page_url: string | null;
};
};
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
const amount = (minor: number, currency: string) =>
`${(minor / 100).toLocaleString('pt-PT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
const sessionDate = (utc: string, timezone: string) =>
new Date(`${utc.replace(' ', 'T')}Z`).toLocaleString('pt-PT', {
timeZone: timezone,
dateStyle: 'short',
timeStyle: 'short',
});
function OpeningForm({
requisition,
proposals,
basisHash,
latestId,
}: Pick<Props, 'requisition' | 'proposals' | 'basisHash' | 'latestId'>) {
const form = useForm({
previous_id: latestId,
basis_hash: basisHash,
opened_at: '',
timezone: 'Africa/Johannesburg',
submission_deadline: '',
reference: '',
location: '',
reason: '',
notes: '',
quotation_ids: [] as number[],
participants: [
{ name: '', capacity: '', group: 'participant' },
] as Participant[],
annexes_text: '',
confirmed: false,
});
const participant = (index: number, change: Partial<Participant>) =>
form.setData(
'participants',
form.data.participants.map((entry, position) =>
position === index ? { ...entry, ...change } : entry,
),
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.transform(({ annexes_text, ...data }) => ({
...data,
annexes: annexes_text
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
}));
form.post(`/requisicoes/${requisition.id}/abertura`, {
preserveScroll: true,
});
}}
className="space-y-5"
>
{form.data.basis_hash !== basisHash && (
<div className="rounded-lg border border-amber-300 p-4 text-sm">
<p>
As propostas foram actualizadas. Confira os valores
abaixo; os textos introduzidos foram mantidos.
</p>
<Button
type="button"
variant="outline"
className="mt-3"
onClick={() => {
form.setData('basis_hash', basisHash);
form.setData('confirmed', false);
form.setData(
'quotation_ids',
form.data.quotation_ids.filter((id) =>
proposals.some(
(proposal) => proposal.id === id,
),
),
);
}}
>
Usar a base actual para nova conferência
</Button>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Label>
Referência do processo ou concurso
<Input
required
minLength={3}
maxLength={150}
value={form.data.reference}
onChange={(event) =>
form.setData('reference', event.target.value)
}
/>
</Label>
<Label>
Local da sessão
<Input
required
minLength={3}
maxLength={300}
value={form.data.location}
onChange={(event) =>
form.setData('location', event.target.value)
}
/>
</Label>
<Label>
Data e hora da sessão
<Input
type="datetime-local"
required
value={form.data.opened_at}
onChange={(event) =>
form.setData('opened_at', event.target.value)
}
/>
</Label>
<Label>
Fuso horário
<select
className={selectClass}
value={form.data.timezone}
onChange={(event) =>
form.setData('timezone', event.target.value)
}
>
<option value="Africa/Johannesburg">
Africa/Johannesburg (UTC+2)
</option>
<option value="Africa/Maputo">
Africa/Maputo (UTC+2)
</option>
<option value="UTC">UTC</option>
</select>
</Label>
<Label>
Limite declarado de apresentação (opcional)
<Input
type="datetime-local"
value={form.data.submission_deadline}
onChange={(event) =>
form.setData(
'submission_deadline',
event.target.value,
)
}
/>
</Label>
</div>
<fieldset className="space-y-3">
<legend className="mb-3 text-sm font-semibold">
Propostas incluídas na sessão
</legend>
<p className="text-sm text-muted-foreground">
Os valores são copiados da aplicação. Seleccione e confira
as propostas incluídas.
</p>
{proposals.map((proposal) => (
<Label
key={proposal.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<input
type="checkbox"
checked={form.data.quotation_ids.includes(
proposal.id,
)}
onChange={(event) =>
form.setData(
'quotation_ids',
event.target.checked
? [
...form.data.quotation_ids,
proposal.id,
]
: form.data.quotation_ids.filter(
(id) => id !== proposal.id,
),
)
}
/>
<span>
{proposal.supplier?.name ??
'Fornecedor indisponível'}{' '}
· {proposal.reference}
<span className="mt-1 block text-xs text-muted-foreground">
Sem IVA:{' '}
{amount(
proposal.subtotal_minor,
proposal.currency,
)}{' '}
· IVA:{' '}
{amount(proposal.tax_minor, proposal.currency)}{' '}
· Total:{' '}
{amount(
proposal.total_minor,
proposal.currency,
)}
</span>
</span>
</Label>
))}
</fieldset>
<fieldset className="space-y-3">
<legend className="mb-3 text-sm font-semibold">
Intervenientes declarados
</legend>
{form.data.participants.map((entry, index) => (
<div
key={index}
className="grid gap-3 rounded-lg border p-3 sm:grid-cols-3"
>
<Label>
Nome
<Input
required
minLength={2}
maxLength={150}
value={entry.name}
onChange={(event) =>
participant(index, {
name: event.target.value,
})
}
/>
</Label>
<Label>
Qualidade ou função
<Input
required
minLength={2}
maxLength={150}
value={entry.capacity}
onChange={(event) =>
participant(index, {
capacity: event.target.value,
})
}
/>
</Label>
<Label>
Participação
<select
className={selectClass}
value={entry.group}
onChange={(event) =>
participant(index, {
group:
event.target.value === 'jury'
? 'jury'
: 'participant',
})
}
>
<option value="participant">
Participante
</option>
<option value="jury">Júri</option>
</select>
</Label>
{form.data.participants.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() =>
form.setData(
'participants',
form.data.participants.filter(
(_, position) => position !== index,
),
)
}
>
Retirar interveniente
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
disabled={form.data.participants.length >= 50}
onClick={() =>
form.setData('participants', [
...form.data.participants,
{ name: '', capacity: '', group: 'participant' },
])
}
>
Adicionar interveniente
</Button>
</fieldset>
<Label>
Referências de anexos (uma por linha)
<textarea
className="min-h-20 rounded-md border bg-background p-3 text-sm"
value={form.data.annexes_text}
onChange={(event) =>
form.setData('annexes_text', event.target.value)
}
/>
</Label>
<p className="text-xs text-muted-foreground">
São referências declaradas; não são ficheiros carregados nem
assinaturas verificadas.
</p>
<Label>
Observações
<textarea
className="min-h-24 rounded-md border bg-background p-3 text-sm"
maxLength={6000}
value={form.data.notes}
onChange={(event) =>
form.setData('notes', event.target.value)
}
/>
</Label>
<Label>
Fundamento do registo ou da correcção
<textarea
className="min-h-20 rounded-md border bg-background p-3 text-sm"
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<Label className="flex items-start gap-3">
<input
type="checkbox"
checked={form.data.confirmed}
onChange={(event) =>
form.setData('confirmed', event.target.checked)
}
required
/>
<span>
Conferi os dados da sessão e as propostas seleccionadas
antes de guardar esta versão.
</span>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing || !proposals.length}>
{latestId
? 'Registar nova versão da acta'
: 'Registar acta de abertura'}
</Button>
</form>
);
}
export default function OpeningPage(props: Props) {
const { requisition, openings, canRecord, sourceChanged, latestId } = props;
return (
<>
<Head title={`Abertura · ${requisition.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow={requisition.number}
title="Abertura de propostas"
description={requisition.justification}
action={
<Button asChild variant="outline">
<Link
href={`/requisicoes/${requisition.id}/processo`}
>
Ver processo
</Link>
</Button>
}
/>
<p className="rounded-xl border bg-muted/30 p-4 text-sm text-muted-foreground">
Registo documental opcional antes da adjudicação. A acta não
avalia propostas, verifica assinaturas ou autoriza compras.
Uma correcção gera outra versão e conserva as anteriores.
</p>
{sourceChanged && (
<p className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Há propostas não incluídas ou dados actuais diferentes
da última acta. Alterações posteriores não entram
automaticamente nos documentos guardados.
</p>
)}
{canRecord ? (
<Panel
title={
latestId
? 'Nova versão da acta'
: 'Registar sessão de abertura'
}
>
<OpeningForm key={latestId} {...props} />
</Panel>
) : (
<p className="text-sm text-muted-foreground">
O registo está disponível a Compras num pedido aprovado
e sem adjudicação. As versões anteriores continuam
disponíveis.
</p>
)}
{openings.data.map((opening) => (
<Panel
key={opening.id}
title={`${opening.reference} · Versão ${opening.revision}`}
description={`${sessionDate(opening.opened_at, opening.timezone)} · ${opening.timezone} · ${opening.location}`}
>
<p className="text-sm">
Registada por {opening.snapshot.author.name} em{' '}
{sessionDate(opening.created_at, opening.timezone)}.
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">
{opening.reason}
</p>
<ul className="mt-4 divide-y">
{opening.snapshot.proposals.map((proposal) => (
<li
key={proposal.id}
className="flex flex-wrap justify-between gap-2 py-3 text-sm"
>
<span>
{proposal.supplier?.name ??
'Fornecedor indisponível'}{' '}
· {proposal.reference}
</span>
<strong>
{amount(
proposal.total_minor,
proposal.currency,
)}
</strong>
</li>
))}
</ul>
<details className="mt-3 text-sm">
<summary className="cursor-pointer underline">
Intervenientes, observações e referências de
anexos
</summary>
<ul className="mt-3 space-y-2">
{opening.participants.map((entry, index) => (
<li key={index}>
{entry.name} · {entry.capacity} ·{' '}
{entry.group === 'jury'
? 'Júri'
: 'Participante'}
</li>
))}
</ul>
<p className="mt-3 whitespace-pre-wrap">
{opening.notes || 'Sem observações.'}
</p>
<ul className="mt-3 list-inside list-disc">
{opening.annexes.map((annex, index) => (
<li key={index}>{annex}</li>
))}
</ul>
</details>
<Button asChild variant="outline" className="mt-5">
<a
href={`/actas/${opening.id}/imprimir`}
target="_blank"
rel="noopener noreferrer"
>
Abrir acta para impressão
</a>
</Button>
</Panel>
))}
{!openings.data.length && (
<Panel title="Ainda não existe acta">
<p className="text-sm text-muted-foreground">
A ausência deste registo opcional não cria uma nova
etapa obrigatória.
</p>
</Panel>
)}
<nav
aria-label="Versões das actas"
className="flex justify-between gap-3"
>
{openings.prev_page_url ? (
<Button asChild variant="outline">
<Link href={openings.prev_page_url}>
Versões mais recentes
</Link>
</Button>
) : (
<span />
)}
{openings.next_page_url && (
<Button asChild variant="outline">
<Link href={openings.next_page_url}>
Versões anteriores
</Link>
</Button>
)}
</nav>
</div>
</>
);
}
OpeningPage.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Abertura de propostas', href: '#' },
],
};
+1
View File
@@ -48,6 +48,7 @@ const labels: Record<string, string> = {
issued: 'Emitida',
};
const events: Record<string, string> = {
'opening.recorded': 'Versão da acta de abertura registada',
'requisition.created': 'Requisição criada',
'requisition.updated': 'Requisição actualizada',
'requisition.submitted': 'Requisição submetida',
+6
View File
@@ -110,6 +110,12 @@ export default function Comparison({
>
Adjudicação, aprovações do valor final e ordem de compra
</Link>
<Link
className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/abertura`}
>
Sessão de abertura e actas
</Link>
<Link
className="text-sm text-teal-700"
href={`/requisicoes/${requisition.id}`}
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de abertura · {{ $opening->reference }} · SGC</title>
<style>
@page { size: A4; margin: 17mm; }
* { box-sizing: border-box; }
body { margin: 24px auto; max-width: 960px; color: #111; font: 12px/1.5 Arial, sans-serif; }
header { border-bottom: 3px solid #ed1c24; padding-bottom: 12px; }
h1 { font-size: 22px; margin-bottom: 4px; } h2 { font-size: 15px; margin-top: 24px; }
p, td, th { overflow-wrap: anywhere; } .pre { white-space: pre-wrap; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f2f2f2; } thead { display: table-header-group; } tr { break-inside: avoid; }
.notice { border: 1px solid #aaa; padding: 10px; margin-top: 16px; } .small { color: #444; font-size: 10px; }
.signature { min-width: 120px; padding-top: 28px; } footer { border-top: 1px solid #ddd; margin-top: 28px; padding-top: 12px; }
button { padding: 10px 18px; } @media print { body { margin: 0; max-width: none; } .no-print { display: none; } }
</style>
</head>
<body>
<button type="button" class="no-print" onclick="window.print()">Imprimir acta</button>
<header><strong>SGC · Sistema de Gestão de Compras</strong><p>{{ $snapshot['company']['name'] }}</p></header>
<h1>Acta de sessão de abertura de propostas</h1>
<p><strong>{{ $opening->reference }}</strong> · Versão {{ $opening->revision }} · {{ $snapshot['requisition']['number'] }}</p>
<p class="pre">Objecto: {{ $snapshot['requisition']['justification'] }}</p>
<p>Data e hora declaradas da sessão: {{ \Carbon\CarbonImmutable::parse($opening->opened_at, 'UTC')->timezone($opening->timezone)->format('d/m/Y H:i') }}<br>Fuso horário: {{ $opening->timezone }}<br>Local: {{ $opening->location }}</p>
@if($opening->submission_deadline)<p>Limite declarado para apresentação: {{ \Carbon\CarbonImmutable::parse($opening->submission_deadline, 'UTC')->timezone($opening->timezone)->format('d/m/Y H:i') }}</p>@endif
<div class="notice">Registo documental para conferência e assinatura. Os nomes são declarados; o SGC não valida assinaturas nem certifica a realização da sessão. Esta acta não constitui avaliação, recomendação, adjudicação ou autorização financeira.</div>
<h2>Intervenientes declarados</h2>
<table><thead><tr><th>Nome</th><th>Qualidade</th><th>Participação</th><th>Assinatura manuscrita</th></tr></thead><tbody>
@foreach($opening->participants as $participant)<tr><td>{{ $participant['name'] }}</td><td>{{ $participant['capacity'] }}</td><td>{{ $participant['group'] === 'jury' ? 'Júri' : 'Participante' }}</td><td class="signature">____________________</td></tr>@endforeach
</tbody></table>
<h2>Mapa das propostas registadas na aplicação</h2>
<p>Valores preservados à data deste registo, para conferência com os dados da sessão. Não inclui propostas adicionadas ou alteradas posteriormente.</p>
<table><thead><tr><th>Ordem</th><th>Concorrente e referência</th><th>Sem IVA</th><th>IVA</th><th>Total</th></tr></thead><tbody>
@foreach($snapshot['proposals'] as $index => $proposal)<tr><td>{{ $index + 1 }}</td><td>{{ $proposal['supplier']['name'] ?? 'Fornecedor indisponível' }}<br>{{ $proposal['reference'] }}</td><td>{{ number_format($proposal['subtotal_minor'] / 100, 2, ',', '.') }} {{ $proposal['currency'] }}</td><td>{{ number_format($proposal['tax_minor'] / 100, 2, ',', '.') }} {{ $proposal['currency'] }}</td><td>{{ number_format($proposal['total_minor'] / 100, 2, ',', '.') }} {{ $proposal['currency'] }}</td></tr>@endforeach
</tbody></table>
<h2>Observações</h2><p class="pre">{{ $opening->notes ?: 'Sem observações registadas.' }}</p>
<h2>Referências de anexos</h2>
@if(count($opening->annexes))<ul>@foreach($opening->annexes as $annex)<li>{{ $annex }}</li>@endforeach</ul>@else<p>Sem referências de anexos registadas.</p>@endif
<p class="small">As referências acima não significam que os ficheiros ou assinaturas foram carregados ou verificados no SGC.</p>
<h2>Fundamento do registo desta versão</h2><p class="pre">{{ $opening->reason }}</p>
<footer class="small">Registado por {{ $snapshot['author']['name'] }} em {{ \Carbon\CarbonImmutable::parse($opening->created_at, 'UTC')->timezone($opening->timezone)->format('d/m/Y H:i') }} · {{ $opening->timezone }}.<br>SGC · Sistema de Gestão de Compras · Documento do processo {{ $snapshot['requisition']['number'] }}.</footer>
</body>
</html>
+4
View File
@@ -9,6 +9,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\ProposalOpeningController;
use App\Http\Controllers\PurchaseControlController;
use App\Http\Controllers\PurchaseController;
use App\Http\Controllers\PurchasePlanningController;
@@ -32,6 +33,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::get('requisicoes/{requisition}/abertura', [ProposalOpeningController::class, 'show'])->name('openings.show');
Route::post('requisicoes/{requisition}/abertura', [ProposalOpeningController::class, 'store']);
Route::get('actas/{opening}/imprimir', [ProposalOpeningController::class, 'print'])->name('openings.print');
Route::get('planeamento', [PurchasePlanningController::class, 'index'])->name('planning.index');
Route::post('planeamento', [PurchasePlanningController::class, 'store']);
Route::post('planeamento/{item}/responsavel', [PurchasePlanningController::class, 'assign']);
+34
View File
@@ -0,0 +1,34 @@
<?php
use App\Models\Requisition;
use App\Models\User;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
require __DIR__.'/../../vendor/autoload.php';
$database = $argv[1] ?? '';
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
throw new LogicException('Base de teste inválida.');
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
throw new LogicException('Worker não isolado.');
}
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_opening_'.$argv[4]]);
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
$actor = User::query()->findOrFail($argv[2]);
auth()->guard()->setUser($actor);
$data = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
try {
app(ProposalOpeningWorkflow::class)->record($actor, Requisition::query()->findOrFail($argv[3]), $data);
$status = 200;
} catch (ValidationException) {
$status = 422;
} catch (HttpExceptionInterface $exception) {
$status = $exception->getStatusCode();
}
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Models\AuditLog;
use App\Models\ProposalOpening;
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/ProposalOpeningFixture.php';
$original = DB::connection();
if ($original->getDriverName() !== 'pgsql') {
throw new LogicException('Exige PostgreSQL.');
}
$config = $original->getConfig();
$admin = $original->getPdo();
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
$created = false;
$processes = [];
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
try {
$admin->exec('CREATE DATABASE "'.$database.'"');
$created = true;
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
DB::purge('pgsql');
DB::setDefaultConnection('pgsql');
Schema::clearResolvedInstance('db.schema');
app()->forgetInstance('db.schema');
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
throw new LogicException('Migração fora do isolamento recusada.');
}
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
foreach (['competing_initial_versions', 'competing_corrections', 'revoked_role', 'changed_basis'] as $scenario) {
$f = proposalOpeningFixture();
$actor = $f['issuer'];
$data = $f['service_data'];
if ($scenario === 'competing_corrections') {
$initial = app(ProposalOpeningWorkflow::class)->record($actor, $f['r'], $data);
$data['previous_id'] = $initial->id;
}
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->first();
$processes = [];
foreach (['A', 'B'] as $suffix) {
$process = new Process([PHP_BINARY, __DIR__.'/opening-worker.php', $database, (string) $actor->id, (string) $f['r']->id, $suffix], base_path(), $workerEnv);
$process->setInput(json_encode([...$data, 'notes' => 'Versão concorrente fictícia '.$suffix], JSON_THROW_ON_ERROR));
$process->setTimeout(40);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_opening_A','sgc_opening_B') AND wait_event_type = 'Lock'");
$deadline = microtime(true) + 20;
do {
$statement->execute([$database]);
$waits = (int) $statement->fetchColumn();
if ($waits === 2) {
break;
}
foreach ($processes as $process) {
if (! $process->isRunning()) {
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
}
}
usleep(20000);
} while (microtime(true) < $deadline);
if ($scenario === 'revoked_role') {
$connection->table('users')->where('id', $actor->id)->update(['purchasing_role' => 'finance']);
}
if ($scenario === 'changed_basis') {
$connection->table('quotations')->where('id', $f['quote']->id)->update(['reference' => 'Referência alterada durante a espera']);
}
$connection->commit();
if ($waits !== 2) {
throw new RuntimeException('Sem sobreposição de bloqueios.');
}
$results = [];
foreach ($processes as $process) {
$process->wait();
if (! $process->isSuccessful()) {
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
}
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
}
$statuses = array_column($results, 'status');
sort($statuses);
$expected = match ($scenario) {
'revoked_role' => [403, 403], 'changed_basis' => [422, 422], default => [200, 422]
};
$count = ProposalOpening::query()->count();
$expectedCount = match ($scenario) {
'competing_initial_versions' => 1, 'competing_corrections' => 2, default => 0
};
if ($statuses !== $expected || count(array_unique(array_column($results, 'backend_pid'))) !== 2 || $count !== $expectedCount || AuditLog::query()->where('action', 'opening.recorded')->count() !== $expectedCount) {
throw new RuntimeException('Versão concorrente inesperada.');
}
if ($count && (int) ProposalOpening::query()->max('revision') !== $expectedCount) {
throw new RuntimeException('Numeração inconsistente.');
}
if ($scenario === 'competing_corrections' && $initial->refresh()->notes !== $f['data']['notes']) {
throw new RuntimeException('Versão anterior alterada.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'immutable_versions' => $count];
}
$report['passed'] = true;
} finally {
foreach ($processes as $process) {
if ($process->isRunning()) {
$process->stop();
}
}
if (isset($connection) && $connection->transactionLevel() > 0) {
$connection->rollBack();
}
DB::disconnect('pgsql');
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
$admin->exec('DROP DATABASE "'.$database.'"');
$report['test_database_dropped'] = true;
}
}
file_put_contents(base_path('docs/abertura-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;
+133
View File
@@ -0,0 +1,133 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\Department;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ProposalOpeningFixture.php';
beforeEach(function () {
$this->f = proposalOpeningFixture();
$this->url = '/requisicoes/'.$this->f['r']->id.'/abertura';
$this->actingAs($this->f['issuer']);
});
function recordOpening(object $test, array $changes = []): ProposalOpening
{
$test->post($test->url, [...$test->f['data'], ...$changes])->assertSessionHasNoErrors();
return ProposalOpening::query()->latest('revision')->firstOrFail();
}
test('opening is an optional documentary snapshot without approval or financial effects', function () {
$opening = recordOpening($this);
expect($opening->revision)->toBe(1)->and($opening->snapshot['proposals'][0]['total_minor'])->toBe(10000)
->and($opening->snapshot['proposals'][0])->not->toHaveKey('evaluations')->and(BudgetEntry::query()->count())->toBe(0)
->and($this->f['r']->refresh()->status)->toBe('approved')->and(PurchaseAward::query()->count())->toBe(0);
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->component('purchases/opening')->where('sourceChanged', false)->where('latestId', $opening->id));
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('kind', 'Abertura')));
});
test('opening corrections append a new version and refuse stale predecessor ids', function () {
$first = recordOpening($this);
$second = recordOpening($this, ['previous_id' => $first->id, 'notes' => 'Nova observação sem apagar o passado']);
expect($second->revision)->toBe(2)->and($first->refresh()->notes)->toBe('Observações fictícias');
$this->post($this->url, [...$this->f['data'], 'previous_id' => $first->id])->assertSessionHasErrors('previous_id');
expect(ProposalOpening::query()->count())->toBe(2);
});
test('opening refuses a proposal basis changed after the form was loaded', function () {
$this->f['quote']->update(['reference' => 'Referência actualizada']);
$this->post($this->url, $this->f['data'])->assertSessionHasErrors('basis_hash');
expect(ProposalOpening::query()->count())->toBe(0);
});
test('printed acta preserves supplier and price snapshots after source changes', function () {
$opening = recordOpening($this);
$name = $opening->snapshot['proposals'][0]['supplier']['name'];
Supplier::query()->whereKey($this->f['quote']->supplier_id)->update(['name' => 'Nome posterior fictício']);
$this->f['quote']->update(['total_minor' => 20000]);
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('sourceChanged', true));
$this->get('/actas/'.$opening->id.'/imprimir')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertSee($name)->assertDontSee('Nome posterior fictício')->assertSee('100,00')->assertSee('não valida assinaturas');
});
test('opening stores local session time as UTC and prints the declared timezone', function () {
$opening = recordOpening($this);
expect($opening->opened_at)->toBe($this->f['service_data']['opened_at'])->and($opening->timezone)->toBe('Africa/Johannesburg');
$this->get('/actas/'.$opening->id.'/imprimir')->assertSee('Africa/Johannesburg');
$this->post($this->url, [...$this->f['data'], 'previous_id' => $opening->id, 'opened_at' => now()->addHour()->timezone('Africa/Johannesburg')->format('Y-m-d\TH:i')])->assertSessionHasErrors('opened_at');
});
test('opening validates deadline confirmation and declared participants', function () {
$this->post($this->url, [...$this->f['data'], 'confirmed' => false])->assertSessionHasErrors('confirmed');
$this->post($this->url, [...$this->f['data'], 'submission_deadline' => now()->addDay()->timezone('Africa/Johannesburg')->format('Y-m-d\TH:i')])->assertSessionHasErrors('submission_deadline');
$this->post($this->url, [...$this->f['data'], 'participants' => []])->assertSessionHasErrors('participants');
$this->post($this->url, [...$this->f['data'], 'snapshot' => ['signed' => true]])->assertSessionHasErrors('snapshot');
});
test('opening cannot rewrite a process after an award or record a draft request', function () {
$this->f['r']->update(['status' => 'draft']);
$this->post($this->url, $this->f['data'])->assertSessionHasErrors('opening');
$this->f['r']->update(['status' => 'approved']);
PurchaseAward::query()->create($this->f['award_attributes']);
$this->post($this->url, $this->f['data'])->assertSessionHasErrors('opening');
expect(ProposalOpening::query()->count())->toBe(0);
});
test('opening is restricted by role department company and authentication including print', function () {
$opening = recordOpening($this);
$print = '/actas/'.$opening->id.'/imprimir';
$this->actingAs($this->f['receiver'])->get($this->url)->assertForbidden();
$this->get($print)->assertForbidden();
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($finance)->get($this->url)->assertOk();
$this->get($print)->assertOk();
$this->post($this->url, [...$this->f['data'], 'previous_id' => $opening->id])->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($print)->assertNotFound();
$other = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$this->actingAs(User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]))->get($print)->assertNotFound();
auth()->logout();
$this->get($print)->assertRedirect('/login');
});
test('opening rejects revoked procurement roles and never accepts foreign proposals', function () {
$original = $this->f;
$foreign = proposalOpeningFixture();
$this->actingAs($original['issuer']);
$this->post($this->url, [...$original['data'], 'quotation_ids' => [$foreign['quote']->id]])->assertSessionHasErrors('quotation_ids');
DB::table('users')->where('id', $original['issuer']->id)->update(['purchasing_role' => 'finance']);
$this->post($this->url, $original['data'])->assertForbidden();
expect(ProposalOpening::query()->count())->toBe(0);
});
test('opening audit failure rolls back the entire immutable document', function () {
AuditLog::creating(function (AuditLog $event) {
if ($event->action === 'opening.recorded') {
throw new RuntimeException('Falha fictícia de auditoria');
}
});
try {
expect(fn () => app(ProposalOpeningWorkflow::class)->record($this->f['issuer'], $this->f['r'], $this->f['service_data']))->toThrow(RuntimeException::class);
} finally {
AuditLog::flushEventListeners();
}
expect(ProposalOpening::query()->count())->toBe(0);
});
test('opening documents are immutable and user text is escaped for printing', function () {
$opening = recordOpening($this, ['notes' => '<script>alert("ficticio")</script>']);
$this->get('/actas/'.$opening->id.'/imprimir')->assertDontSee('<script>', false)->assertSee('&lt;script&gt;', false);
expect(fn () => $opening->update(['notes' => 'Alteração proibida']))->toThrow(LogicException::class);
expect(fn () => $opening->delete())->toThrow(LogicException::class);
});
+1 -1
View File
@@ -21,7 +21,7 @@ test('process overview links records without declaring partial execution complet
->where('stages.7.state', 'recorded')
->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
->where('stages.8.state', 'not_started')
->has('future', 3)
->has('future', 2)
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
});
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Models\PurchaseAward;
use App\Models\Quotation;
use App\Services\ProposalOpeningWorkflow;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
require_once __DIR__.'/ReceiptFixture.php';
/** receiptFixture refuses execution outside the isolated test database. */
function proposalOpeningFixture(): array
{
$f = receiptFixture();
auth()->guard()->setUser($f['issuer']);
$award = PurchaseAward::query()->findOrFail($f['order']->purchase_award_id);
$awardAttributes = $award->getAttributes();
unset($awardAttributes['id']);
DB::table('purchase_order_items')->where('purchase_order_id', $f['order']->id)->delete();
DB::table('purchase_orders')->where('id', $f['order']->id)->delete();
DB::table('purchase_awards')->where('id', $award->id)->delete();
$quote = Quotation::query()->where('requisition_id', $f['r']->id)->firstOrFail();
$workflow = app(ProposalOpeningWorkflow::class);
$data = ['previous_id' => 0, 'basis_hash' => $workflow->basis($workflow->proposals($f['r'])), 'opened_at' => now()->subMinute()->timezone('Africa/Johannesburg')->format('Y-m-d\TH:i'), 'timezone' => 'Africa/Johannesburg', 'submission_deadline' => null, 'reference' => 'ABERTURA-FICTICIA', 'location' => 'Sala exclusivamente fictícia', 'reason' => 'Registo exclusivamente fictício de teste', 'notes' => 'Observações fictícias', 'quotation_ids' => [$quote->id], 'participants' => [['name' => 'Participante fictício', 'capacity' => 'Secretariado de teste', 'group' => 'participant']], 'annexes' => ['Lista fictícia de presenças'], 'confirmed' => true];
$serviceData = [...$data, 'opened_at' => CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $data['opened_at'], $data['timezone'])->utc()->format('Y-m-d H:i:s')];
return [...$f, 'quote' => $quote, 'data' => $data, 'service_data' => $serviceData, 'award_attributes' => $awardAttributes];
}
+111
View File
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000017.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = DB::connection();
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
}
$pdo = $connection->getPdo();
$migration = '2026_10_28_000017_create_proposal_openings';
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
throw new LogicException('Migração já aplicada; não repetir.');
}
$config = $connection->getConfig();
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
if (! is_file($bin.$tool)) {
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
}
}
$envHash = hash_file('sha256', base_path('.env'));
$directory = storage_path('app/private/backups/proposal-openings-000017-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Não foi possível criar backup privado.');
}
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
$columns = [];
$fingerprints = function (PDO $db, array $tables) use ($quote, &$columns): array {
$result = [];
foreach ($tables as $table) {
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM (SELECT '.implode(', ', array_map($quote, $columns[$table])).' FROM public.'.$quote($table).') t')->fetchAll(PDO::FETCH_COLUMN);
sort($rows, SORT_STRING);
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
}
return $result;
};
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$statement = $pdo->prepare("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position");
$statement->execute([$table]);
$columns[$table] = $statement->fetchAll(PDO::FETCH_COLUMN);
}
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
$restoreCreated = false;
try {
// pg_dump and the manifest read exactly the same committed snapshot.
$pdo->beginTransaction();
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
$backupState = $fingerprints($pdo, $tables);
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
$dump->setTimeout(120);
$dump->run();
if (! $dump->isSuccessful()) {
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
}
$pdo->commit();
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
$restoreCreated = true;
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
$restore->setTimeout(120);
$restore->run();
if (! $restore->isSuccessful()) {
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
}
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
$restored = DB::connection('budget_restore')->getPdo();
if ($fingerprints($restored, $tables) !== $backupState) {
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
}
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'columns' => $columns, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
DB::disconnect('budget_restore');
unset($restored);
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
$restoreCreated = false;
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
$before = $fingerprints($pdo, $businessTables);
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
if ($exit !== 0) {
throw new RuntimeException('Migração falhou; consultar backup privado.');
}
$after = $fingerprints($pdo, $businessTables);
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
}
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'proposal_openings_count' => (int) $pdo->query('SELECT count(*) FROM proposal_openings')->fetchColumn(), 'purchase_plan_milestones_count' => (int) $pdo->query('SELECT count(*) FROM purchase_plan_milestones')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/abertura-migracao-000017.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
DB::disconnect('budget_restore');
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
}
}