Regista abertura de propostas e actas imutaveis por versao
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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('<script>', false);
|
||||
expect(fn () => $opening->update(['notes' => 'Alteração proibida']))->toThrow(LogicException::class);
|
||||
expect(fn () => $opening->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user