Implementa planeamento de compras com marcos e responsabilidades por empresa

This commit is contained in:
2026-10-28 18:30:41 +02:00
parent 76d2463f36
commit 513f430edf
18 changed files with 1604 additions and 2 deletions
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\Department;
use App\Models\PurchasePlanItem;
use App\Models\PurchasePlanMilestone;
use App\Models\Requisition;
use App\Models\User;
use App\Services\PurchasePlanningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class PurchasePlanningController extends Controller
{
public function index(Request $request): Response
{
$actor = $request->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();
}
}
@@ -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');
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Enums\PurchasingRole;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class PurchasePlanItem extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1];
/** @param Builder<PurchasePlanItem> $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.'));
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class PurchasePlanMilestone extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1, 'actual_on' => null];
protected static function booted(): void
{
static::deleting(fn () => throw new \LogicException('Preservar marcos e datas anteriores na auditoria.'));
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Department;
use App\Models\PurchasePlanItem;
use App\Models\PurchasePlanMilestone;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchasePlanningWorkflow
{
private function manager(User $actor): bool
{
return in_array($actor->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<string,mixed> $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']]);
});
}
}
@@ -0,0 +1,41 @@
<?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('purchase_plan_items', function (Blueprint $table) {
$table->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.');
}
};
+7
View File
@@ -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.
@@ -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
}
+16
View File
@@ -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
}
+63
View File
@@ -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
+1
View File
@@ -17,6 +17,7 @@ const groups: { label: string; entries: Entry[] }[] = [
{
label: 'Compras',
entries: [
{ label: 'Planeamento', path: '/planeamento', demo: '' },
{
label: 'Requisições',
path: '/requisicoes',
+706
View File
@@ -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<string, string | number | null> | 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<string, string> = {
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<Props, 'users' | 'departments' | 'requisitions'>) {
const form = useForm({
title: '',
nature: '',
department_id: '',
responsible_id: '',
requisition_id: '',
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post('/planeamento', {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}}
className="grid gap-4 sm:grid-cols-2"
>
<Label>
Título da compra
<Input
required
minLength={3}
maxLength={200}
value={form.data.title}
onChange={(event) =>
form.setData('title', event.target.value)
}
/>
</Label>
<Label>
Natureza
<select
required
className={selectClass}
value={form.data.nature}
onChange={(event) =>
form.setData('nature', event.target.value)
}
>
<option value="">Seleccionar</option>
{Object.entries(natureLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</Label>
<Label>
Departamento
<select
required
className={selectClass}
value={form.data.department_id}
onChange={(event) => {
form.setData('department_id', event.target.value);
form.setData('requisition_id', '');
}}
>
<option value="">Seleccionar</option>
{departments.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</select>
</Label>
<Label>
Responsável
<select
required
className={selectClass}
value={form.data.responsible_id}
onChange={(event) =>
form.setData('responsible_id', event.target.value)
}
>
<option value="">Seleccionar</option>
{users.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</select>
</Label>
<Label className="sm:col-span-2">
Requisição existente (opcional)
<select
className={selectClass}
value={form.data.requisition_id}
onChange={(event) =>
form.setData('requisition_id', event.target.value)
}
>
<option value="">
Planear antes de criar a requisição
</option>
{requisitions
.filter(
(entry) =>
String(entry.department_id) ===
form.data.department_id,
)
.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.number}
</option>
))}
</select>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing}>Adicionar ao plano</Button>
</form>
);
}
function LinkRequest({
item,
requisitions,
}: {
item: Item;
requisitions: RequestOption[];
}) {
const form = useForm({
requisition_id: '',
version: item.version,
reason: '',
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/planeamento/${item.id}/requisicao`, {
preserveScroll: true,
});
}}
className="mt-4 grid gap-3"
>
<Label>
Requisição
<select
required
className={selectClass}
value={form.data.requisition_id}
onChange={(event) =>
form.setData('requisition_id', event.target.value)
}
>
<option value="">Seleccionar</option>
{requisitions
.filter(
(entry) =>
entry.department_id === item.department_id,
)
.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.number}
</option>
))}
</select>
</Label>
<Label>
Fundamento da ligação
<Input
required
minLength={10}
maxLength={2000}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing}>Ligar requisição</Button>
</form>
);
}
function AddMilestone({ id }: { id: number }) {
const form = useForm({ label: '', planned_on: '' });
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/planeamento/${id}/marcos`, {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}}
className="mt-4 grid gap-3 sm:grid-cols-2"
>
<Label>
Marco
<Input
required
minLength={3}
maxLength={120}
placeholder="Ex.: concluir avaliação"
value={form.data.label}
onChange={(event) =>
form.setData('label', event.target.value)
}
/>
</Label>
<Label>
Data prevista
<Input
type="date"
required
value={form.data.planned_on}
onChange={(event) =>
form.setData('planned_on', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button variant="outline" disabled={form.processing}>
Adicionar marco
</Button>
</form>
);
}
function Assign({ item, users }: { item: Item; users: Option[] }) {
const form = useForm({
responsible_id: String(item.responsible_id),
version: item.version,
reason: '',
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/planeamento/${item.id}/responsavel`, {
preserveScroll: true,
});
}}
className="mt-4 grid gap-3"
>
<Label>
Responsável
<select
required
className={selectClass}
value={form.data.responsible_id}
onChange={(event) =>
form.setData('responsible_id', event.target.value)
}
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</Label>
<Label>
Motivo da alteração
<Input
required
minLength={10}
maxLength={2000}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button variant="outline" disabled={form.processing}>
Actualizar responsável
</Button>
</form>
);
}
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 (
<li className="py-4">
<div className="flex flex-wrap justify-between gap-2">
<h3 className="text-sm font-semibold">{milestone.label}</h3>
<span className="text-xs text-muted-foreground">{status}</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Previsto: {date(milestone.planned_on)} · Realizado:{' '}
{milestone.actual_on
? date(milestone.actual_on)
: 'Não registado'}
</p>
{canRecord && (
<details className="mt-3">
<summary className="cursor-pointer text-sm underline">
Actualizar datas
</summary>
<form
onSubmit={(event) => {
event.preventDefault();
form.patch(`/planeamento/marcos/${milestone.id}`, {
preserveScroll: true,
});
}}
className="mt-3 grid gap-3 sm:grid-cols-2"
>
{canManage && (
<Label>
Data prevista
<Input
type="date"
required
value={form.data.planned_on}
onChange={(event) =>
form.setData(
'planned_on',
event.target.value,
)
}
/>
</Label>
)}
<Label>
Data realizada
<Input
type="date"
max={today}
value={form.data.actual_on}
onChange={(event) =>
form.setData(
'actual_on',
event.target.value,
)
}
/>
</Label>
<Label className="sm:col-span-2">
Fundamento ou referência de suporte
<Input
required
minLength={10}
maxLength={2000}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<p className="text-xs text-muted-foreground sm:col-span-2">
Deixar a data realizada vazia remove essa indicação,
conservando o valor anterior no histórico.
</p>
<FormErrors errors={form.errors} />
<Button disabled={form.processing}>
Guardar alteração
</Button>
</form>
</details>
)}
</li>
);
}
export default function Planning({
items,
milestones,
history,
users,
departments,
requisitions,
canManage,
today,
}: Props) {
return (
<>
<Head title="Planeamento de compras" />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Organização das compras"
title="Planeamento"
description="Acompanhe responsáveis e datas previstas e realizadas por compra."
/>
<p className="rounded-xl border bg-muted/30 p-4 text-sm text-muted-foreground">
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.
</p>
{canManage && (
<Panel
title="Adicionar compra ao plano"
description="Bens, obras, serviços e consultoria são classificações do plano; não activam modalidades de contratação."
>
<CreateItem
users={users}
departments={departments}
requisitions={requisitions}
/>
</Panel>
)}
{items.data.map((item) => (
<Panel
key={item.id}
title={item.title}
description={`${natureLabels[item.nature]} · ${item.department} · Responsável: ${item.responsible}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
{item.requisition ? (
<Link
className="text-sm underline"
href={`/requisicoes/${item.requisition.id}/processo`}
>
Processo {item.requisition.number}
</Link>
) : (
<p className="text-sm text-muted-foreground">
Sem requisição visível associada
</p>
)}
{canManage && (
<details>
<summary className="cursor-pointer text-sm underline">
Alterar responsável
</summary>
<Assign
key={`${item.id}-${item.version}`}
item={item}
users={users}
/>
</details>
)}
</div>
{item.canLink && (
<details className="mt-3">
<summary className="cursor-pointer text-sm underline">
Ligar uma requisição criada posteriormente
</summary>
<LinkRequest
key={`${item.id}-${item.version}`}
item={item}
requisitions={requisitions}
/>
</details>
)}
<ul className="mt-3 divide-y">
{milestones
.filter(
(milestone) =>
milestone.purchase_plan_item_id ===
item.id,
)
.map((milestone) => (
<MilestoneRow
key={`${milestone.id}-${milestone.version}`}
milestone={milestone}
canManage={canManage}
canRecord={item.canRecord}
today={today}
/>
))}
</ul>
{!milestones.some(
(milestone) =>
milestone.purchase_plan_item_id === item.id,
) && (
<p className="py-4 text-sm text-muted-foreground">
Sem marcos definidos.
</p>
)}
{canManage && (
<details className="border-t pt-4">
<summary className="cursor-pointer text-sm font-medium">
Adicionar marco
</summary>
<AddMilestone id={item.id} />
</details>
)}
</Panel>
))}
{!items.data.length && (
<Panel title="Sem compras no plano">
<p className="text-sm text-muted-foreground">
Ainda não existem itens disponíveis para o seu
perfil.
</p>
</Panel>
)}
<nav
aria-label="Páginas do plano"
className="flex justify-between gap-3"
>
{items.prev_page_url ? (
<Button asChild variant="outline">
<Link href={items.prev_page_url}>Anterior</Link>
</Button>
) : (
<span />
)}
{items.next_page_url && (
<Button asChild variant="outline">
<Link href={items.next_page_url}>Seguinte</Link>
</Button>
)}
</nav>
{canManage && (
<Panel
title="Histórico das alterações"
description="Últimos 50 eventos de planeamento da empresa, com as datas anteriores e o fundamento."
>
{history.length ? (
<div className="space-y-3">
{history.map((event) => (
<details
key={event.id}
className="rounded-lg border p-3"
>
<summary className="cursor-pointer text-sm">
{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)}
</summary>
<p className="mt-2 text-xs text-muted-foreground">
Por {event.actor}
</p>
<p className="mt-2 text-sm">
{typeof event.after.reason ===
'string'
? event.after.reason
: 'Registo inicial'}
</p>
{event.before && (
<p className="mt-2 text-xs text-muted-foreground">
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 && (
<p className="mt-2 text-xs text-muted-foreground">
Depois: previsto{' '}
{date(
event.after
.milestone
.planned_on,
)}
, realizado{' '}
{event.after.milestone
.actual_on
? date(
event.after
.milestone
.actual_on,
)
: 'Não registado'}
</p>
)}
</p>
)}
</details>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Sem alterações registadas.
</p>
)}
</Panel>
)}
</div>
</>
);
}
Planning.layout = {
breadcrumbs: [
{ title: 'Compras', href: '/requisicoes' },
{ title: 'Planeamento', href: '/planeamento' },
],
};
+7
View File
@@ -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']);
+42
View File
@@ -0,0 +1,42 @@
<?php
use App\Models\PurchasePlanItem;
use App\Models\PurchasePlanMilestone;
use App\Models\User;
use App\Services\PurchasePlanningWorkflow;
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_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);
+138
View File
@@ -0,0 +1,138 @@
<?php
use App\Models\AuditLog;
use App\Models\PurchasePlanMilestone;
use App\Services\PurchasePlanningWorkflow;
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/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;
+150
View File
@@ -0,0 +1,150 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\Department;
use App\Models\PurchasePlanItem;
use App\Models\PurchasePlanMilestone;
use App\Models\Tenant;
use App\Models\User;
use App\Services\PurchasePlanningWorkflow;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
beforeEach(function () {
$this->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();
});
+1 -1
View File
@@ -21,7 +21,7 @@ test('process overview links records without declaring partial execution complet
->where('stages.7.state', 'recorded')
->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
->where('stages.8.state', 'not_started')
->has('future', 4)
->has('future', 3)
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
});
+111
View File
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000016.
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_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));
}
}