diff --git a/app/Http/Controllers/ProposalOpeningController.php b/app/Http/Controllers/ProposalOpeningController.php new file mode 100644 index 0000000..f615514 --- /dev/null +++ b/app/Http/Controllers/ProposalOpeningController.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php index eb51b8d..d4f7320 100644 --- a/app/Http/Controllers/PurchaseProcessController.php +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -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'); diff --git a/app/Models/ProposalOpening.php b/app/Models/ProposalOpening.php new file mode 100644 index 0000000..44f035a --- /dev/null +++ b/app/Models/ProposalOpening.php @@ -0,0 +1,21 @@ + 'array', 'annexes' => 'array', 'snapshot' => 'array']; + } +} diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php index a72de67..e4430ca 100644 --- a/app/Services/ConflictGuard.php +++ b/app/Services/ConflictGuard.php @@ -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', diff --git a/app/Services/ProposalOpeningWorkflow.php b/app/Services/ProposalOpeningWorkflow.php new file mode 100644 index 0000000..52b7639 --- /dev/null +++ b/app/Services/ProposalOpeningWorkflow.php @@ -0,0 +1,77 @@ +> */ + 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> $proposals */ + public function basis(array $proposals): string + { + return hash('sha256', json_encode($proposals, JSON_THROW_ON_ERROR)); + } + + /** @param array $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; + }); + } +} diff --git a/database/migrations/2026_10_28_000017_create_proposal_openings.php b/database/migrations/2026_10_28_000017_create_proposal_openings.php new file mode 100644 index 0000000..48d1eee --- /dev/null +++ b/database/migrations/2026_10_28_000017_create_proposal_openings.php @@ -0,0 +1,37 @@ +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.'); + } +}; diff --git a/docs/abertura-concorrencia-postgresql.json b/docs/abertura-concorrencia-postgresql.json new file mode 100644 index 0000000..d920bd5 --- /dev/null +++ b/docs/abertura-concorrencia-postgresql.json @@ -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 +} diff --git a/docs/abertura-migracao-000017.json b/docs/abertura-migracao-000017.json new file mode 100644 index 0000000..cdc4261 --- /dev/null +++ b/docs/abertura-migracao-000017.json @@ -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 +} diff --git a/docs/exemplos-orquestracao-compras.md b/docs/exemplos-orquestracao-compras.md index 0711364..e74f838 100644 --- a/docs/exemplos-orquestracao-compras.md +++ b/docs/exemplos-orquestracao-compras.md @@ -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. diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index 80766e3..91a7901 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -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 diff --git a/resources/js/pages/purchases/opening.tsx b/resources/js/pages/purchases/opening.tsx new file mode 100644 index 0000000..2d90786 --- /dev/null +++ b/resources/js/pages/purchases/opening.tsx @@ -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) { + 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) => + form.setData( + 'participants', + form.data.participants.map((entry, position) => + position === index ? { ...entry, ...change } : entry, + ), + ); + return ( +
{ + 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 && ( +
+

+ As propostas foram actualizadas. Confira os valores + abaixo; os textos introduzidos foram mantidos. +

+ +
+ )} +
+ + + + + +
+
+ + Propostas incluídas na sessão + +

+ Os valores são copiados da aplicação. Seleccione e confira + as propostas incluídas. +

+ {proposals.map((proposal) => ( + + ))} +
+
+ + Intervenientes declarados + + {form.data.participants.map((entry, index) => ( +
+ + + + {form.data.participants.length > 1 && ( + + )} +
+ ))} + +
+