From 513f430edf5bafd9c1b8e61f673ddd4c6eee9fd6 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 18:30:41 +0200 Subject: [PATCH] Implementa planeamento de compras com marcos e responsabilidades por empresa --- .../PurchasePlanningController.php | 111 +++ .../Controllers/PurchaseProcessController.php | 7 +- app/Models/PurchasePlanItem.php | 32 + app/Models/PurchasePlanMilestone.php | 20 + app/Services/PurchasePlanningWorkflow.php | 119 +++ ..._10_28_000016_create_purchase_planning.php | 41 + docs/exemplos-orquestracao-compras.md | 7 + docs/planeamento-concorrencia-postgresql.json | 33 + docs/planeamento-migracao-000016.json | 16 + docs/validacao-tecnica.md | 63 ++ resources/js/components/sgc-shell.tsx | 1 + resources/js/pages/planning/index.tsx | 706 ++++++++++++++++++ routes/web.php | 7 + tests/Concurrency/planning-worker.php | 42 ++ tests/Concurrency/run-planning.php | 138 ++++ tests/Feature/PurchasePlanningTest.php | 150 ++++ tests/Feature/PurchaseProcessTest.php | 2 +- tests/Support/apply-planning-migration.php | 111 +++ 18 files changed, 1604 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/PurchasePlanningController.php create mode 100644 app/Models/PurchasePlanItem.php create mode 100644 app/Models/PurchasePlanMilestone.php create mode 100644 app/Services/PurchasePlanningWorkflow.php create mode 100644 database/migrations/2026_10_28_000016_create_purchase_planning.php create mode 100644 docs/planeamento-concorrencia-postgresql.json create mode 100644 docs/planeamento-migracao-000016.json create mode 100644 resources/js/pages/planning/index.tsx create mode 100644 tests/Concurrency/planning-worker.php create mode 100644 tests/Concurrency/run-planning.php create mode 100644 tests/Feature/PurchasePlanningTest.php create mode 100644 tests/Support/apply-planning-migration.php diff --git a/app/Http/Controllers/PurchasePlanningController.php b/app/Http/Controllers/PurchasePlanningController.php new file mode 100644 index 0000000..bf39308 --- /dev/null +++ b/app/Http/Controllers/PurchasePlanningController.php @@ -0,0 +1,111 @@ +user(); + $canManage = in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]); + $filters = $request->validate(['item' => 'nullable|integer|min:1']); + $items = PurchasePlanItem::query()->visibleTo($actor)->when($filters['item'] ?? null, fn ($query, $id) => $query->whereKey($id))->latest('id')->paginate(15)->withQueryString(); + $milestones = PurchasePlanMilestone::query()->whereIn('purchase_plan_item_id', $items->pluck('id'))->orderBy('planned_on')->orderBy('id')->get(); + $responsibles = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $items->pluck('responsible_id'))->pluck('name', 'id'); + $departments = Department::query()->whereIn('id', $items->pluck('department_id'))->pluck('name', 'id'); + $requisitions = Requisition::query()->visibleTo($actor)->whereIn('id', $items->pluck('requisition_id'))->get(['id', 'number'])->keyBy('id'); + $rows = $items->through(fn (PurchasePlanItem $item) => [ + ...$item->only(['id', 'title', 'nature', 'responsible_id', 'version', 'department_id']), + 'canLink' => $canManage && $item->requisition_id === null, + 'responsible' => $responsibles[$item->responsible_id] ?? 'Responsável indisponível', + 'department' => $departments[$item->department_id] ?? '', + 'requisition' => isset($requisitions[$item->requisition_id]) ? $requisitions[$item->requisition_id]->only(['id', 'number']) : null, + 'canRecord' => $canManage || $item->responsible_id === $actor->id, + ]); + $history = []; + if ($canManage) { + $history = AuditLog::query()->whereIn('action', ['planning.created', 'planning.linked', 'planning.assigned', 'planning.milestone_created', 'planning.milestone_updated'])->latest('id')->limit(50)->get(['id', 'action', 'user_id', 'before', 'after', 'created_at']); + $authors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $history->pluck('user_id'))->pluck('name', 'id'); + foreach ($history as $event) { + $event->setAttribute('actor', $authors[$event->user_id] ?? 'Utilizador indisponível'); + } + } + Inertia::encryptHistory(); + $response = Inertia::render('planning/index', [ + 'items' => $rows, 'milestones' => $milestones, 'history' => $history, 'canManage' => $canManage, + 'today' => today()->format('Y-m-d'), + 'departments' => $canManage ? Department::query()->orderBy('name')->get(['id', 'name']) : [], + 'users' => $canManage ? User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']) : [], + 'requisitions' => $canManage ? Requisition::query()->visibleTo($actor)->whereNotIn('id', PurchasePlanItem::query()->whereNotNull('requisition_id')->select('requisition_id'))->latest('id')->get(['id', 'number', 'department_id']) : [], + ])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function store(Request $request, PurchasePlanningWorkflow $workflow): RedirectResponse + { + $data = $request->validate(['title' => 'required|string|min:3|max:200', 'department_id' => 'required|integer', 'responsible_id' => 'required|integer', 'requisition_id' => 'nullable|integer', 'nature' => 'required|in:goods,works,services,consulting']); + $data['department_id'] = (int) $data['department_id']; + $data['responsible_id'] = (int) $data['responsible_id']; + $workflow->create($request->user(), $data); + + return back(); + } + + public function assign(Request $request, PurchasePlanItem $item, PurchasePlanningWorkflow $workflow): RedirectResponse + { + $data = $request->validate(['responsible_id' => 'required|integer', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:2000']); + $workflow->assign($request->user(), $item, (int) $data['responsible_id'], (int) $data['version'], $data['reason']); + + return back(); + } + + public function milestone(Request $request, PurchasePlanItem $item, PurchasePlanningWorkflow $workflow): RedirectResponse + { + $data = $request->validate(['label' => 'required|string|min:3|max:120', 'planned_on' => 'required|date_format:Y-m-d']); + $workflow->milestone($request->user(), $item, $data['label'], $data['planned_on']); + + return back(); + } + + public function link(Request $request, PurchasePlanItem $item, PurchasePlanningWorkflow $workflow): RedirectResponse + { + $data = $request->validate(['requisition_id' => 'required|integer', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:2000']); + $workflow->link($request->user(), $item, (int) $data['requisition_id'], (int) $data['version'], $data['reason']); + + return back(); + } + + public function update(Request $request, PurchasePlanMilestone $milestone, PurchasePlanningWorkflow $workflow): RedirectResponse + { + $data = $request->validate(['version' => 'required|integer|min:1', 'planned_on' => 'sometimes|required|date_format:Y-m-d', 'actual_on' => 'sometimes|nullable|date_format:Y-m-d|before_or_equal:today', 'reason' => 'required|string|min:10|max:2000']); + if (! array_key_exists('planned_on', $data) && ! array_key_exists('actual_on', $data)) { + throw ValidationException::withMessages(['actual_on' => 'Indique a data prevista ou a data realizada a actualizar.']); + } + $changes = ['version' => (int) $data['version'], 'reason' => $data['reason']]; + if (array_key_exists('planned_on', $data)) { + $changes['planned_on'] = $data['planned_on']; + } + if (array_key_exists('actual_on', $data)) { + $changes['actual_on'] = $data['actual_on']; + } + $workflow->update($request->user(), $milestone, $changes); + + return back(); + } +} diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php index cda5fa3..eb51b8d 100644 --- a/app/Http/Controllers/PurchaseProcessController.php +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -11,6 +11,7 @@ use App\Models\GoodsReceipt; use App\Models\Invoice; use App\Models\PurchaseAward; use App\Models\PurchaseOrder; +use App\Models\PurchasePlanItem; use App\Models\PurchaseRevision; use App\Models\Quotation; use App\Models\Requisition; @@ -34,6 +35,10 @@ class PurchaseProcessController extends Controller $base = '/requisicoes/'.$requisition->id; $stages = []; $documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]]; + $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]; + } $decisions = []; $stages[] = ['label' => 'Requisição e aprovação da necessidade', 'state' => $requisition->status, 'owner' => 'Solicitante e aprovadores', 'detail' => 'Estado do pedido; as decisões individuais constam abaixo.', 'href' => $base]; foreach ($requisition->steps as $step) { @@ -108,7 +113,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' => ['Plano de compras e marcos previstos/realizados', 'Sessão de abertura e acta', 'Qualificação específica do concurso', 'Relatório de recomendação em etapas'], + 'future' => ['Sessão de abertura e acta', '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/PurchasePlanItem.php b/app/Models/PurchasePlanItem.php new file mode 100644 index 0000000..99b37eb --- /dev/null +++ b/app/Models/PurchasePlanItem.php @@ -0,0 +1,32 @@ + 1]; + + /** @param Builder $query */ + public function scopeVisibleTo(Builder $query, User $user): void + { + match ($user->purchasing_role) { + PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null, + PurchasingRole::DepartmentHead => $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id)), + default => $query->where('responsible_id', $user->id), + }; + } + + protected static function booted(): void + { + static::deleting(fn () => throw new \LogicException('Preservar o planeamento.')); + } +} diff --git a/app/Models/PurchasePlanMilestone.php b/app/Models/PurchasePlanMilestone.php new file mode 100644 index 0000000..9f0923a --- /dev/null +++ b/app/Models/PurchasePlanMilestone.php @@ -0,0 +1,20 @@ + 1, 'actual_on' => null]; + + protected static function booted(): void + { + static::deleting(fn () => throw new \LogicException('Preservar marcos e datas anteriores na auditoria.')); + } +} diff --git a/app/Services/PurchasePlanningWorkflow.php b/app/Services/PurchasePlanningWorkflow.php new file mode 100644 index 0000000..4df3f2d --- /dev/null +++ b/app/Services/PurchasePlanningWorkflow.php @@ -0,0 +1,119 @@ +purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]); + } + + private function responsible(User $actor, int $id): void + { + if (! User::query()->where('tenant_id', $actor->tenant_id)->whereKey($id)->exists()) { + throw ValidationException::withMessages(['responsible_id' => 'O responsável deve pertencer à empresa.']); + } + } + + private function version(int $expected, int $current): void + { + if ($expected !== $current) { + throw ValidationException::withMessages(['version' => 'O registo mudou. Actualize a página antes de voltar a guardar.']); + } + } + + /** @param array $data */ + public function create(User $actor, array $data): PurchasePlanItem + { + return DB::transaction(function () use ($actor, $data) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]); + Department::query()->findOrFail($data['department_id']); + $this->responsible($actor, $data['responsible_id']); + $requisition = null; + if ($data['requisition_id'] ?? null) { + $requisition = Requisition::query()->visibleTo($actor)->whereKey($data['requisition_id'])->firstOrFail(); + if ($requisition->department_id !== $data['department_id'] || PurchasePlanItem::query()->where('requisition_id', $requisition->id)->exists()) { + throw ValidationException::withMessages(['requisition_id' => 'A requisição deve pertencer ao departamento e ainda não ter planeamento associado.']); + } + } + $item = PurchasePlanItem::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'department_id' => $data['department_id'], 'requisition_id' => $requisition?->id, 'responsible_id' => $data['responsible_id'], 'title' => $data['title'], 'nature' => $data['nature']]); + app(RequisitionWorkflow::class)->audit($actor, 'planning.created', $requisition, null, $item->toArray()); + + return $item; + }); + } + + public function assign(User $actor, PurchasePlanItem $item, int $responsibleId, int $version, string $reason): void + { + DB::transaction(function () use ($actor, $item, $responsibleId, $version, $reason) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]); + $item = PurchasePlanItem::query()->lockForUpdate()->findOrFail($item->id); + $this->version($version, $item->version); + $this->responsible($actor, $responsibleId); + $before = $item->toArray(); + $item->update(['responsible_id' => $responsibleId, 'version' => $item->version + 1]); + app(RequisitionWorkflow::class)->audit($actor, 'planning.assigned', null, $before, ['item' => $item->toArray(), 'reason' => $reason]); + }); + } + + public function link(User $actor, PurchasePlanItem $item, int $requisitionId, int $version, string $reason): void + { + DB::transaction(function () use ($actor, $item, $requisitionId, $version, $reason) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]); + $item = PurchasePlanItem::query()->lockForUpdate()->findOrFail($item->id); + $this->version($version, $item->version); + $requisition = Requisition::query()->visibleTo($actor)->findOrFail($requisitionId); + if ($item->requisition_id !== null || $requisition->department_id !== $item->department_id || PurchasePlanItem::query()->where('requisition_id', $requisitionId)->exists()) { + throw ValidationException::withMessages(['requisition_id' => 'A ligação deve ser única, do mesmo departamento, e não pode substituir uma ligação anterior.']); + } + $before = $item->toArray(); + $item->update(['requisition_id' => $requisitionId, 'version' => $item->version + 1]); + app(RequisitionWorkflow::class)->audit($actor, 'planning.linked', $requisition, $before, ['item' => $item->toArray(), 'reason' => $reason]); + }); + } + + public function milestone(User $actor, PurchasePlanItem $item, string $label, string $plannedOn): void + { + DB::transaction(function () use ($actor, $item, $label, $plannedOn) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]); + $item = PurchasePlanItem::query()->lockForUpdate()->findOrFail($item->id); + if (PurchasePlanMilestone::query()->where('purchase_plan_item_id', $item->id)->where('label', trim($label))->exists()) { + throw ValidationException::withMessages(['label' => 'Já existe um marco com este nome neste item.']); + } + $milestone = PurchasePlanMilestone::query()->create(['tenant_id' => $actor->tenant_id, 'purchase_plan_item_id' => $item->id, 'created_by' => $actor->id, 'label' => trim($label), 'planned_on' => $plannedOn]); + app(RequisitionWorkflow::class)->audit($actor, 'planning.milestone_created', null, null, $milestone->toArray()); + }); + } + + /** @param array{version:int,planned_on?:string,actual_on?:string|null,reason:string} $data */ + public function update(User $actor, PurchasePlanMilestone $milestone, array $data): void + { + DB::transaction(function () use ($actor, $milestone, $data) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $item = PurchasePlanItem::query()->visibleTo($actor)->lockForUpdate()->findOrFail($milestone->purchase_plan_item_id); + $milestone = PurchasePlanMilestone::query()->lockForUpdate()->findOrFail($milestone->id); + abort_unless($this->manager($actor) || $item->responsible_id === $actor->id, 403); + abort_if(! $this->manager($actor) && array_key_exists('planned_on', $data), 403); + $this->version($data['version'], $milestone->version); + $before = $milestone->toArray(); + $changes = array_intersect_key($data, array_flip(['planned_on', 'actual_on'])); + $milestone->update([...$changes, 'version' => $milestone->version + 1]); + app(RequisitionWorkflow::class)->audit($actor, 'planning.milestone_updated', null, $before, ['milestone' => $milestone->toArray(), 'reason' => $data['reason']]); + }); + } +} diff --git a/database/migrations/2026_10_28_000016_create_purchase_planning.php b/database/migrations/2026_10_28_000016_create_purchase_planning.php new file mode 100644 index 0000000..309454e --- /dev/null +++ b/database/migrations/2026_10_28_000016_create_purchase_planning.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('department_id')->constrained()->restrictOnDelete(); + $table->foreignId('requisition_id')->nullable()->unique()->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('responsible_id')->constrained('users')->restrictOnDelete(); + $table->string('title', 200); + $table->string('nature'); + $table->unsignedInteger('version')->default(1); + $table->timestamps(); + }); + Schema::create('purchase_plan_milestones', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('purchase_plan_item_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->string('label', 120); + $table->date('planned_on'); + $table->date('actual_on')->nullable(); + $table->unsignedInteger('version')->default(1); + $table->timestamps(); + $table->unique(['purchase_plan_item_id', 'label']); + }); + } + + public function down(): void + { + throw new LogicException('Preservar o planeamento e o histórico das alterações.'); + } +}; diff --git a/docs/exemplos-orquestracao-compras.md b/docs/exemplos-orquestracao-compras.md index 5276276..0711364 100644 --- a/docs/exemplos-orquestracao-compras.md +++ b/docs/exemplos-orquestracao-compras.md @@ -1,5 +1,12 @@ # Exemplos locais para organizar o processo de compras +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 +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. + Análise de 28 de Outubro de 2026. Os quatro ficheiros indicados pelo responsável são **exemplos**, conforme instrução expressa. Não aprovam regras do SGC, não autorizam operações e não são parâmetros comuns às empresas aderentes. diff --git a/docs/planeamento-concorrencia-postgresql.json b/docs/planeamento-concorrencia-postgresql.json new file mode 100644 index 0000000..9b54cec --- /dev/null +++ b/docs/planeamento-concorrencia-postgresql.json @@ -0,0 +1,33 @@ +{ + "tested_at_utc": "2026-10-28T16:26:56+00:00", + "separate_database": true, + "transport": "Independent PHP service transactions and PostgreSQL connections", + "scenarios": [ + { + "name": "competing_dates", + "passed": true, + "overlapping_lock_waits": 2, + "successful_audit_entries": 1 + }, + { + "name": "competing_responsibles", + "passed": true, + "overlapping_lock_waits": 2, + "successful_audit_entries": 1 + }, + { + "name": "duplicate_milestone", + "passed": true, + "overlapping_lock_waits": 2, + "successful_audit_entries": 1 + }, + { + "name": "revoked_role", + "passed": true, + "overlapping_lock_waits": 2, + "successful_audit_entries": 0 + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/planeamento-migracao-000016.json b/docs/planeamento-migracao-000016.json new file mode 100644 index 0000000..7694949 --- /dev/null +++ b/docs/planeamento-migracao-000016.json @@ -0,0 +1,16 @@ +{ + "verified_at_utc": "2026-10-28T16:27:35+00:00", + "migration": "2026_10_28_000016_create_purchase_planning", + "batch": 17, + "backup_created": true, + "backup_bytes": 281059, + "restore_verified": true, + "restored_tables": 52, + "restore_database_dropped": true, + "business_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "purchase_plan_items_count": 0, + "purchase_plan_milestones_count": 0, + "operational_validation": false +} diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index efe0912..80766e3 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -1,5 +1,68 @@ # Validação técnica do SGC — 28 de Outubro de 2026 +## Incremento: planeamento por empresa + +O menu Compras inclui `/planeamento`. Administração e Compras registam itens +para bens, obras, serviços ou consultoria, seleccionam departamento e +responsável da própria empresa e definem marcos com nomes e datas próprios. +Estas naturezas são classificações de acompanhamento, não novas modalidades +de contratação. Não existem marcos obrigatórios ou valores financeiros +predefinidos, e o plano não cria reservas ou aprovações. + +Uma compra pode ser planeada antes da requisição. Administração ou Compras +podem depois ligar uma requisição visível, do mesmo departamento, com +fundamento. A ligação é única e não substitui uma anterior. A visão única do +processo oferece um atalho para o item do plano quando o utilizador também +tem acesso a esse item; o filtro `?item=` conserva as mesmas permissões. + +O responsável atribuído regista ou corrige datas realizadas e indica o +fundamento. Administração e Compras também podem rever datas previstas e +reatribuir responsáveis. Datas realizadas futuras são recusadas. Todas as +alterações guardam autor, valores anteriores, novos valores e fundamento, +com versão optimista e bloqueio da empresa para impedir sobrescrita silenciosa. +Uma data realizada é uma declaração de acompanhamento: não confirma entregas, +facturas, pagamentos ou execução integral da compra. + +Administração, Compras e Finanças consultam o plano da empresa. Chefias +consultam o departamento ou itens de que são responsáveis; os restantes +perfis consultam apenas os itens atribuídos. Só Administração e Compras +definem o plano e consultam o histórico geral. Finanças não ganha permissão +de edição por ser leitor; um responsável pode registar datas realizadas no +seu item. Uma atribuição não dá acesso à requisição ou aos seus documentos +se as permissões normais não o permitirem. Dados de outras empresas, opções +de equipa e histórico geral não são expostos a utilizadores sem acesso. + +A migração aditiva 000016 foi aplicada no batch 17 após backup privado de +281059 bytes e restauro verificado de 52 tabelas. Dados anteriores, restrições +e `.env` foram preservados. As duas tabelas novas ficaram vazias: não foram +criadas compras ou datas fictícias na base de negócio. Ver relatório +`planeamento-migracao-000016.json`; os ficheiros privados de recuperação +permanecem fora do Git. + +Treze testes específicos verificam criação, marcos livres, datas realizadas, +reprogramação e versão, rejeição de datas futuras e ausência de fundamento, +perfis, empresas, departamentos, ligação única e posterior, reatribuição, +revogação de perfil, rollback de auditoria e acesso ao processo ligado. +Quatro cenários PostgreSQL com duas ligações e duas esperas simultâneas +passaram: datas concorrentes, responsáveis concorrentes, marcos duplicados e +perfil revogado enquanto os operadores aguardam o bloqueio. Cada escrita +aceite produz um único evento; a base temporária exclusiva foi eliminada. +Ver `planeamento-concorrencia-postgresql.json`. + +O histórico apresenta os 50 eventos de planeamento mais recentes da empresa. +Título, natureza e departamento são definidos na criação; as revisões deste +âmbito abrangem responsável, ligação posterior e datas dos marcos. Não há +eliminação de itens ou marcos. Estimativas financeiras do plano, aprovações +do plano, importação dos exemplos, alertas automáticos, dependências entre +marcos, actas e novas modalidades continuam fora deste primeiro incremento. +A revisão visual continua pendente, devido à indisponibilidade do navegador. + +Resultado deste incremento: **432 testes aprovados, 2 ignorados, 3336 asserções**; +**4 cenários concorrentes PostgreSQL aprovados**. PHPStan, Pint, TypeScript, +lint/formatação e build passaram. O navegador voltou a responder +`The browser access host is unavailable`. Não se declara revisão visual +concluída nem validação operacional de regras ou orçamentos das empresas. + ## Incremento: visão única do processo de compra O detalhe de uma requisição inclui o acesso «Ver processo completo», em diff --git a/resources/js/components/sgc-shell.tsx b/resources/js/components/sgc-shell.tsx index 940ad63..1448460 100644 --- a/resources/js/components/sgc-shell.tsx +++ b/resources/js/components/sgc-shell.tsx @@ -17,6 +17,7 @@ const groups: { label: string; entries: Entry[] }[] = [ { label: 'Compras', entries: [ + { label: 'Planeamento', path: '/planeamento', demo: '' }, { label: 'Requisições', path: '/requisicoes', diff --git a/resources/js/pages/planning/index.tsx b/resources/js/pages/planning/index.tsx new file mode 100644 index 0000000..8ef9f9d --- /dev/null +++ b/resources/js/pages/planning/index.tsx @@ -0,0 +1,706 @@ +import { Head, Link, useForm } from '@inertiajs/react'; +import { PageTitle, Panel, FormErrors } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { date } from '@/types/procurement'; + +type Option = { id: number; name: string }; +type RequestOption = { id: number; number: string; department_id: number }; +type Item = { + id: number; + title: string; + nature: string; + department: string; + department_id: number; + canLink: boolean; + responsible: string; + responsible_id: number; + version: number; + canRecord: boolean; + requisition: { id: number; number: string } | null; +}; +type Milestone = { + id: number; + purchase_plan_item_id: number; + label: string; + planned_on: string; + actual_on: string | null; + version: number; +}; +type Audit = { + id: number; + action: string; + created_at: string; + actor: string; + before: Record | null; + after: { + reason?: string; + milestone?: { planned_on: string; actual_on: string | null }; + }; +}; +type Props = { + items: { + data: Item[]; + prev_page_url: string | null; + next_page_url: string | null; + }; + milestones: Milestone[]; + history: Audit[]; + users: Option[]; + departments: Option[]; + requisitions: RequestOption[]; + canManage: boolean; + today: string; +}; +const natureLabels: Record = { + goods: 'Bens', + works: 'Obras', + services: 'Serviços', + consulting: 'Consultoria', +}; +const selectClass = + 'block h-10 w-full rounded-md border bg-background px-3 text-sm'; + +function CreateItem({ + users, + departments, + requisitions, +}: Pick) { + const form = useForm({ + title: '', + nature: '', + department_id: '', + responsible_id: '', + requisition_id: '', + }); + return ( +
{ + event.preventDefault(); + form.post('/planeamento', { + preserveScroll: true, + onSuccess: () => form.reset(), + }); + }} + className="grid gap-4 sm:grid-cols-2" + > + + + + + + + + + ); +} + +function LinkRequest({ + item, + requisitions, +}: { + item: Item; + requisitions: RequestOption[]; +}) { + const form = useForm({ + requisition_id: '', + version: item.version, + reason: '', + }); + return ( +
{ + event.preventDefault(); + form.post(`/planeamento/${item.id}/requisicao`, { + preserveScroll: true, + }); + }} + className="mt-4 grid gap-3" + > + + + + + + ); +} + +function AddMilestone({ id }: { id: number }) { + const form = useForm({ label: '', planned_on: '' }); + return ( +
{ + event.preventDefault(); + form.post(`/planeamento/${id}/marcos`, { + preserveScroll: true, + onSuccess: () => form.reset(), + }); + }} + className="mt-4 grid gap-3 sm:grid-cols-2" + > + + + + + + ); +} + +function Assign({ item, users }: { item: Item; users: Option[] }) { + const form = useForm({ + responsible_id: String(item.responsible_id), + version: item.version, + reason: '', + }); + return ( +
{ + event.preventDefault(); + form.post(`/planeamento/${item.id}/responsavel`, { + preserveScroll: true, + }); + }} + className="mt-4 grid gap-3" + > + + + + + + ); +} + +function MilestoneRow({ + milestone, + canManage, + canRecord, + today, +}: { + milestone: Milestone; + canManage: boolean; + canRecord: boolean; + today: string; +}) { + const form = useForm<{ + version: number; + actual_on: string; + reason: string; + planned_on?: string; + }>({ + version: milestone.version, + actual_on: milestone.actual_on ?? '', + reason: '', + ...(canManage ? { planned_on: milestone.planned_on } : {}), + }); + const delta = milestone.actual_on + ? Math.round( + (Date.parse(`${milestone.actual_on}T00:00:00Z`) - + Date.parse(`${milestone.planned_on}T00:00:00Z`)) / + 86400000, + ) + : null; + const status = + delta !== null + ? delta > 0 + ? `Realizado com ${delta} dias de atraso` + : 'Realizado dentro do prazo' + : milestone.planned_on < today + ? 'Data prevista ultrapassada' + : milestone.planned_on === today + ? 'Previsto para hoje' + : 'Previsto'; + return ( +
  • +
    +

    {milestone.label}

    + {status} +
    +

    + Previsto: {date(milestone.planned_on)} · Realizado:{' '} + {milestone.actual_on + ? date(milestone.actual_on) + : 'Não registado'} +

    + {canRecord && ( +
    + + Actualizar datas + +
    { + event.preventDefault(); + form.patch(`/planeamento/marcos/${milestone.id}`, { + preserveScroll: true, + }); + }} + className="mt-3 grid gap-3 sm:grid-cols-2" + > + {canManage && ( + + )} + + +

    + Deixar a data realizada vazia remove essa indicação, + conservando o valor anterior no histórico. +

    + + + +
    + )} +
  • + ); +} + +export default function Planning({ + items, + milestones, + history, + users, + departments, + requisitions, + canManage, + today, +}: Props) { + return ( + <> + +
    + +

    + Os marcos são definidos para cada compra. Registar uma data + realizada não aprova a compra, valida o orçamento, emite + documentos ou confirma entregas e pagamentos. +

    + {canManage && ( + + + + )} + {items.data.map((item) => ( + +
    + {item.requisition ? ( + + Processo {item.requisition.number} + + ) : ( +

    + Sem requisição visível associada +

    + )} + {canManage && ( +
    + + Alterar responsável + + +
    + )} +
    + {item.canLink && ( +
    + + Ligar uma requisição criada posteriormente + + +
    + )} +
      + {milestones + .filter( + (milestone) => + milestone.purchase_plan_item_id === + item.id, + ) + .map((milestone) => ( + + ))} +
    + {!milestones.some( + (milestone) => + milestone.purchase_plan_item_id === item.id, + ) && ( +

    + Sem marcos definidos. +

    + )} + {canManage && ( +
    + + Adicionar marco + + +
    + )} +
    + ))} + {!items.data.length && ( + +

    + Ainda não existem itens disponíveis para o seu + perfil. +

    +
    + )} + + {canManage && ( + + {history.length ? ( +
    + {history.map((event) => ( +
    + + {event.action === 'planning.created' + ? 'Compra adicionada' + : event.action === + 'planning.assigned' + ? 'Responsável alterado' + : event.action === + 'planning.milestone_created' + ? 'Marco criado' + : 'Datas actualizadas'}{' '} + · {date(event.created_at)} + +

    + Por {event.actor} +

    +

    + {typeof event.after.reason === + 'string' + ? event.after.reason + : 'Registo inicial'} +

    + {event.before && ( +

    + Antes: previsto{' '} + {String( + event.before.planned_on ?? + '—', + )} + , realizado{' '} + {String( + event.before.actual_on ?? + '—', + )} + , responsável{' '} + {String( + event.before.responsible_id + ? (users.find( + (user) => + user.id === + event.before + ?.responsible_id, + )?.name ?? + 'Indisponível') + : '—', + )} + {event.after.milestone && ( +

    + Depois: previsto{' '} + {date( + event.after + .milestone + .planned_on, + )} + , realizado{' '} + {event.after.milestone + .actual_on + ? date( + event.after + .milestone + .actual_on, + ) + : 'Não registado'} +

    + )} +

    + )} +
    + ))} +
    + ) : ( +

    + Sem alterações registadas. +

    + )} +
    + )} +
    + + ); +} +Planning.layout = { + breadcrumbs: [ + { title: 'Compras', href: '/requisicoes' }, + { title: 'Planeamento', href: '/planeamento' }, + ], +}; diff --git a/routes/web.php b/routes/web.php index 7925bea..08ebd1d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\InvestmentPolicyController; use App\Http\Controllers\InvoiceController; use App\Http\Controllers\PurchaseControlController; use App\Http\Controllers\PurchaseController; +use App\Http\Controllers\PurchasePlanningController; use App\Http\Controllers\PurchaseProcessController; use App\Http\Controllers\QuotationController; use App\Http\Controllers\RequisitionController; @@ -31,6 +32,12 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store'); Route::middleware(EnsureCompanyIsConfigured::class)->group(function () { + Route::get('planeamento', [PurchasePlanningController::class, 'index'])->name('planning.index'); + Route::post('planeamento', [PurchasePlanningController::class, 'store']); + Route::post('planeamento/{item}/responsavel', [PurchasePlanningController::class, 'assign']); + Route::post('planeamento/{item}/requisicao', [PurchasePlanningController::class, 'link']); + Route::post('planeamento/{item}/marcos', [PurchasePlanningController::class, 'milestone']); + Route::patch('planeamento/marcos/{milestone}', [PurchasePlanningController::class, 'update']); Route::get('requisicoes/{requisition}/processo', [PurchaseProcessController::class, 'show'])->name('purchases.process'); Route::get('revisao-compras', [PurchaseControlController::class, 'index'])->name('purchase-controls.index'); Route::post('revisao-compras/politicas', [PurchaseControlController::class, 'propose']); diff --git a/tests/Concurrency/planning-worker.php b/tests/Concurrency/planning-worker.php new file mode 100644 index 0000000..20b24a5 --- /dev/null +++ b/tests/Concurrency/planning-worker.php @@ -0,0 +1,42 @@ +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_planning_'.$argv[4]]); +$pid = DB::selectOne('select pg_backend_pid() as pid')->pid; +$actor = User::query()->findOrFail($argv[2]); +auth()->guard()->setUser($actor); +$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); +try { + $workflow = app(PurchasePlanningWorkflow::class); + if ($payload['action'] === 'dates') { + $workflow->update($actor, PurchasePlanMilestone::query()->findOrFail($argv[3]), ['version' => 1, 'actual_on' => $payload['actual_on'], 'reason' => 'Actualização concorrente fictícia']); + } elseif ($payload['action'] === 'assign') { + $workflow->assign($actor, PurchasePlanItem::query()->findOrFail($argv[3]), $payload['responsible_id'], 1, 'Atribuição concorrente fictícia'); + } else { + $workflow->milestone($actor, PurchasePlanItem::query()->findOrFail($argv[3]), 'Marco concorrente fictício', today()->format('Y-m-d')); + } + $status = 200; +} catch (ValidationException) { + $status = 422; +} catch (HttpExceptionInterface $exception) { + $status = $exception->getStatusCode(); +} +echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR); diff --git a/tests/Concurrency/run-planning.php b/tests/Concurrency/run-planning.php new file mode 100644 index 0000000..63619d4 --- /dev/null +++ b/tests/Concurrency/run-planning.php @@ -0,0 +1,138 @@ + '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/ReceiptFixture.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_dates', 'competing_responsibles', 'duplicate_milestone', 'revoked_role'] as $scenario) { + $f = receiptFixture(); + $actor = $f['issuer']; + auth()->guard()->setUser($actor); + $workflow = app(PurchasePlanningWorkflow::class); + $item = $workflow->create($actor, ['title' => 'Planeamento exclusivamente fictício', 'department_id' => $f['department']->id, 'responsible_id' => $f['receiver']->id, 'nature' => 'goods', 'requisition_id' => $f['r']->id]); + $target = $item->id; + if ($scenario === 'competing_dates') { + $workflow->milestone($actor, $item, 'Marco para datas concorrentes', today()->format('Y-m-d')); + $milestone = PurchasePlanMilestone::query()->sole(); + $target = $milestone->id; + } + $connection = DB::connection(); + $connection->beginTransaction(); + $connection->table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->first(); + $processes = []; + foreach (['A', 'B'] as $index => $suffix) { + $payload = match ($scenario) { + 'competing_dates' => ['action' => 'dates', 'actual_on' => today()->subDays($index)->format('Y-m-d')], + 'competing_responsibles' => ['action' => 'assign', 'responsible_id' => $index === 0 ? $f['otherReceiver']->id : $actor->id], + default => ['action' => 'milestone'], + }; + $process = new Process([PHP_BINARY, __DIR__.'/planning-worker.php', $database, (string) $actor->id, (string) $target, $suffix], base_path(), $workerEnv); + $process->setInput(json_encode($payload, 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_planning_A','sgc_planning_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' => 'requester']); + } + $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); + $eventName = match ($scenario) { + 'competing_dates' => 'planning.milestone_updated', 'competing_responsibles' => 'planning.assigned', default => 'planning.milestone_created' + }; + $count = AuditLog::query()->where('action', $eventName)->count(); + if ($statuses !== ($scenario === 'revoked_role' ? [403, 403] : [200, 422]) || count(array_unique(array_column($results, 'backend_pid'))) !== 2 || $count !== ($scenario === 'revoked_role' ? 0 : 1)) { + throw new RuntimeException('Resultado concorrente inesperado.'); + } + if ($scenario === 'competing_dates' && ($milestone->refresh()->version !== 2 || ! in_array($milestone->actual_on, [today()->format('Y-m-d'), today()->subDay()->format('Y-m-d')]))) { + throw new RuntimeException('Datas ou versão inconsistentes.'); + } + if ($scenario === 'competing_responsibles' && ($item->refresh()->version !== 2 || ! in_array($item->responsible_id, [$actor->id, $f['otherReceiver']->id]))) { + throw new RuntimeException('Responsabilidade ou versão inconsistentes.'); + } + if (in_array($scenario, ['duplicate_milestone', 'revoked_role']) && PurchasePlanMilestone::query()->count() !== ($scenario === 'revoked_role' ? 0 : 1)) { + throw new RuntimeException('Marco duplicado ou criado após revogação.'); + } + $report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'successful_audit_entries' => $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/planeamento-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; diff --git a/tests/Feature/PurchasePlanningTest.php b/tests/Feature/PurchasePlanningTest.php new file mode 100644 index 0000000..f8abaa1 --- /dev/null +++ b/tests/Feature/PurchasePlanningTest.php @@ -0,0 +1,150 @@ +f = receiptFixture(); + $this->data = ['title' => 'Plano fictício de materiais', 'nature' => 'goods', 'department_id' => $this->f['department']->id, 'responsible_id' => $this->f['receiver']->id, 'requisition_id' => null]; +}); + +function createPlanningItem(object $test, array $overrides = []): PurchasePlanItem +{ + $test->actingAs($test->f['issuer'])->post('/planeamento', [...$test->data, ...$overrides])->assertSessionHasNoErrors(); + + return PurchasePlanItem::query()->latest('id')->firstOrFail(); +} + +function createPlanningMilestone(object $test, PurchasePlanItem $item): PurchasePlanMilestone +{ + $test->actingAs($test->f['issuer'])->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Concluir avaliação', 'planned_on' => today()->subDays(2)->format('Y-m-d')])->assertSessionHasNoErrors(); + + return PurchasePlanMilestone::query()->latest('id')->firstOrFail(); +} + +test('planning records responsibility without changing financial or requisition state', function () { + $item = createPlanningItem($this, ['requisition_id' => (string) $this->f['r']->id, 'department_id' => (string) $this->f['department']->id]); + expect($item->requisition_id)->toBe($this->f['r']->id)->and(BudgetEntry::query()->count())->toBe(0)->and($this->f['r']->refresh()->status)->toBe('approved'); + $this->get('/planeamento')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private') + ->assertInertia(fn (Assert $page) => $page->component('planning/index')->where('items.data.0.responsible', $this->f['receiver']->name)->where('items.data.0.requisition.number', $this->f['r']->number)->has('milestones', 0)); +}); + +test('planning milestones are chosen per purchase and duplicate labels are rejected', function () { + $item = createPlanningItem($this); + $milestone = createPlanningMilestone($this, $item); + expect($milestone->actual_on)->toBeNull(); + $this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Concluir avaliação', 'planned_on' => today()->format('Y-m-d')])->assertSessionHasErrors('label'); + expect(PurchasePlanMilestone::query()->count())->toBe(1); +}); + +test('assigned responsible can declare an actual date but cannot reschedule the plan', function () { + $milestone = createPlanningMilestone($this, createPlanningItem($this)); + $this->actingAs($this->f['receiver'])->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Registo real declarado no teste'])->assertSessionHasNoErrors(); + expect($milestone->refresh()->actual_on)->toBe(today()->format('Y-m-d'))->and($milestone->version)->toBe(2); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 2, 'planned_on' => today()->addWeek()->format('Y-m-d'), 'reason' => 'Tentativa de alterar a previsão'])->assertForbidden(); +}); + +test('planning rejects stale dates and preserves their prior values and author', function () { + $milestone = createPlanningMilestone($this, createPlanningItem($this)); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'planned_on' => today()->format('Y-m-d'), 'reason' => 'Reprogramação fundamentada de teste'])->assertSessionHasNoErrors(); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'planned_on' => today()->addWeek()->format('Y-m-d'), 'reason' => 'Segunda gravação desactualizada'])->assertSessionHasErrors('version'); + $event = AuditLog::query()->where('action', 'planning.milestone_updated')->sole(); + expect($event->before['planned_on'])->toBe(today()->subDays(2)->format('Y-m-d'))->and($event->after['milestone']['planned_on'])->toBe(today()->format('Y-m-d'))->and($event->user_id)->toBe($this->f['issuer']->id); +}); + +test('future actual dates and missing reasons are refused', function () { + $milestone = createPlanningMilestone($this, createPlanningItem($this)); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->addDay()->format('Y-m-d'), 'reason' => 'Data futura não realizada'])->assertSessionHasErrors('actual_on'); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d')])->assertSessionHasErrors('reason'); + expect($milestone->refresh()->version)->toBe(1); +}); + +test('finance is a planning reader and unrelated staff cannot alter or inspect assigned plans', function () { + $item = createPlanningItem($this); + $milestone = createPlanningMilestone($this, $item); + $finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); + $this->actingAs($finance)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->has('items.data', 1)->has('users', 0)->has('history', 0)); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Operador sem responsabilidade atribuída'])->assertForbidden(); + $this->actingAs($this->f['otherReceiver'])->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)->has('milestones', 0)->has('history', 0)); + $this->post('/planeamento', $this->data)->assertForbidden(); +}); + +test('planning protects company and department boundaries', function () { + $item = createPlanningItem($this); + $other = Tenant::query()->create(['name' => 'Outra empresa fictícia']); + $foreign = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]); + $this->actingAs($foreign)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)); + $this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Marco indevido', 'planned_on' => today()->format('Y-m-d')])->assertNotFound(); + $this->actingAs($this->f['issuer'])->post('/planeamento', [...$this->data, 'responsible_id' => $foreign->id])->assertSessionHasErrors('responsible_id'); + $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('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)); +}); + +test('a later requisition link is unique and cannot replace its predecessor', function () { + $item = createPlanningItem($this); + $this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 1, 'requisition_id' => $this->f['r']->id, 'reason' => 'Ligação posterior ao planeamento'])->assertSessionHasNoErrors(); + expect($item->refresh()->requisition_id)->toBe($this->f['r']->id)->and($item->version)->toBe(2); + $this->post('/planeamento', [...$this->data, 'requisition_id' => $this->f['r']->id])->assertSessionHasErrors('requisition_id'); + $this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 2, 'requisition_id' => $this->f['r']->id, 'reason' => 'Não substituir ligação já existente'])->assertSessionHasErrors('requisition_id'); +}); + +test('responsibility changes preserve history and revoke previous write access', function () { + $item = createPlanningItem($this); + $milestone = createPlanningMilestone($this, $item); + $this->post('/planeamento/'.$item->id.'/responsavel', ['version' => 1, 'responsible_id' => $this->f['otherReceiver']->id, 'reason' => 'Mudança de responsável no teste'])->assertSessionHasNoErrors(); + $this->actingAs($this->f['receiver'])->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Operador anterior já substituído'])->assertNotFound(); + expect(AuditLog::query()->where('action', 'planning.assigned')->count())->toBe(1); +}); + +test('planning refreshes revoked operator roles inside the transaction', function () { + $item = createPlanningItem($this); + DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']); + $this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Marco após revogação', 'planned_on' => today()->format('Y-m-d')])->assertForbidden(); + expect(PurchasePlanMilestone::query()->count())->toBe(0); +}); + +test('audit failure rolls back milestone dates and optimistic version together', function () { + $milestone = createPlanningMilestone($this, createPlanningItem($this)); + AuditLog::creating(function (AuditLog $event) { + if ($event->action === 'planning.milestone_updated') { + throw new RuntimeException('Auditoria fictícia indisponível'); + } + }); + try { + expect(fn () => app(PurchasePlanningWorkflow::class)->update($this->f['issuer'], $milestone, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Teste de atomicidade']))->toThrow(RuntimeException::class); + } finally { + AuditLog::flushEventListeners(); + } + expect($milestone->refresh()->actual_on)->toBeNull()->and($milestone->version)->toBe(1); +}); + +test('planning links only requisitions of its department and appears in the visible process', function () { + $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento diferente']); + $item = createPlanningItem($this, ['department_id' => $department->id]); + $this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 1, 'requisition_id' => $this->f['r']->id, 'reason' => 'Ligação a departamento diferente'])->assertSessionHasErrors('requisition_id'); + $linked = createPlanningItem($this, ['requisition_id' => $this->f['r']->id]); + $this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('href', '/planeamento?item='.$linked->id))); + $this->get('/planeamento?item='.$linked->id)->assertInertia(fn (Assert $page) => $page->has('items.data', 1)->where('items.data.0.id', $linked->id)); +}); + +test('an explicitly assigned department head sees only the plan beyond department access', function () { + $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento diferente']); + $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + $item = createPlanningItem($this, ['responsible_id' => $head->id, 'requisition_id' => $this->f['r']->id]); + $milestone = createPlanningMilestone($this, $item); + $this->actingAs($head)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 1)->where('items.data.0.requisition', null)->where('items.data.0.canRecord', true)); + $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Responsável explicitamente designado'])->assertSessionHasNoErrors(); + $this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertNotFound(); +}); diff --git a/tests/Feature/PurchaseProcessTest.php b/tests/Feature/PurchaseProcessTest.php index 9eb1023..636792e 100644 --- a/tests/Feature/PurchaseProcessTest.php +++ b/tests/Feature/PurchaseProcessTest.php @@ -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', 4) + ->has('future', 3) ->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number))); }); diff --git a/tests/Support/apply-planning-migration.php b/tests/Support/apply-planning-migration.php new file mode 100644 index 0000000..4e6133d --- /dev/null +++ b/tests/Support/apply-planning-migration.php @@ -0,0 +1,111 @@ +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_000016_create_purchase_planning'; +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/purchase-planning-000016-'.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, 'purchase_plan_items_count' => (int) $pdo->query('SELECT count(*) FROM purchase_plan_items')->fetchColumn(), 'purchase_plan_milestones_count' => (int) $pdo->query('SELECT count(*) FROM purchase_plan_milestones')->fetchColumn(), 'operational_validation' => false]; + file_put_contents(base_path('docs/planeamento-migracao-000016.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)); + } +}