Implementa planeamento de compras com marcos e responsabilidades por empresa
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ const groups: { label: string; entries: Entry[] }[] = [
|
||||
{
|
||||
label: 'Compras',
|
||||
entries: [
|
||||
{ label: 'Planeamento', path: '/planeamento', demo: '' },
|
||||
{
|
||||
label: 'Requisições',
|
||||
path: '/requisicoes',
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user