Implementa revisao de adjudicacoes e navegacao partilhada do SGC

This commit is contained in:
2026-10-28 17:46:59 +02:00
parent 5ff8d78803
commit ff403ea927
28 changed files with 1767 additions and 131 deletions
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\AwardCancellation;
use App\Models\PurchaseAward;
use App\Models\PurchaseControlPolicy;
use App\Models\PurchaseControlReview;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Requisition;
use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PurchaseControlController extends Controller
{
public function index(Request $request): Response
{
$user = $request->user();
$operational = in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
$sources = Requisition::query()->where('requester_id', $user->id)->select('id');
$ownAwards = PurchaseAward::query()->whereIn('requisition_id', $sources)->select('id');
$cancellations = AwardCancellation::query()->whereIn('purchase_award_id', $ownAwards)->where('status', 'approved')->get(['id', 'purchase_award_id']);
return Inertia::render('purchase-controls/index', [
'role' => $user->purchasing_role->value, 'actorId' => $user->id,
'policies' => $operational ? PurchaseControlPolicy::query()->latest('revision')->get() : [],
'reviews' => $operational ? PurchaseControlReview::query()->get() : [],
'awards' => $operational ? PurchaseAward::query()->whereNotIn('requisition_id', PurchaseOrder::query()->select('requisition_id'))->latest('id')->get(['id', 'requisition_id', 'status']) : [],
'cancellations' => $operational ? AwardCancellation::query()->get(['id', 'purchase_award_id', 'created_by', 'status', 'reason', 'review_reason']) : [],
'sources' => $cancellations->map(function (AwardCancellation $cancellation) {
$award = PurchaseAward::query()->findOrFail($cancellation->purchase_award_id);
$source = Requisition::query()->findOrFail($award->requisition_id);
return ['id' => $cancellation->id, 'number' => $source->number, 'revision_id' => PurchaseRevision::query()->where('award_cancellation_id', $cancellation->id)->value('requisition_id')];
}),
]);
}
public function propose(Request $request, PurchaseControlPolicyWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:unissued_award', 'enabled' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|min:10|max:4000']);
$workflow->propose($request->user(), $data);
return back();
}
public function policyReview(Request $request, PurchaseControlPolicy $policy, PurchaseControlPolicyWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000']);
$workflow->review($request->user(), $policy, $data['decision'], $data['reason']);
return back();
}
public function cancel(Request $request, PurchaseAward $award, PurchaseRevisionWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['reason' => 'required|string|min:10|max:4000']);
$workflow->request($request->user(), $award, $data['reason']);
return back();
}
public function review(Request $request, AwardCancellation $cancellation, PurchaseRevisionWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000']);
$workflow->review($request->user(), $cancellation, $data['decision'], $data['reason']);
return back();
}
public function revise(Request $request, AwardCancellation $cancellation, PurchaseRevisionWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'justification' => 'required|string|min:10|max:4000']);
$revision = $workflow->revise($request->user(), $cancellation, $data['needed_by'], $data['justification']);
return to_route('requisitions.edit', $revision);
}
}
+4 -2
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\AwardCancellation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
@@ -28,12 +29,13 @@ class PurchaseController extends Controller
$this->access($request);
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id,
'canDecide' => $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]);
}
@@ -25,7 +25,7 @@ class RequisitionController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected'])]]);
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])]]);
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
if (! empty($filters['search'])) {
$query->where('number', 'like', '%'.$filters['search'].'%');
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $purchase_award_id
* @property int $purchase_control_policy_id
* @property int $created_by
* @property string $status
* @property string $basis_hash
* @property array<string,mixed> $snapshot
*/
class AwardCancellation extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'pending'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
protected static function booted(): void
{
static::updating(function (AwardCancellation $cancellation) {
if ($cancellation->getRawOriginal('status') !== 'pending' || array_diff(array_keys($cancellation->getDirty()), ['status', 'reviewed_by', 'review_reason', 'reviewed_at', 'updated_at']) !== []) {
throw new \LogicException('Preservar o pedido de cancelamento e a decisão.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar o histórico de cancelamento.'));
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $created_by
* @property int $revision
* @property string $status
*/
class PurchaseControlPolicy extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'pending'];
protected function casts(): array
{
return ['enabled' => 'boolean'];
}
protected static function booted(): void
{
static::updating(function (PurchaseControlPolicy $policy) {
if ($policy->getRawOriginal('status') !== 'pending' || array_diff(array_keys($policy->getDirty()), ['status', 'decided_at', 'updated_at']) !== []) {
throw new \LogicException('Preservar a política e as decisões.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar versões de política.'));
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class PurchaseControlReview extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class PurchaseRevision extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
}
+1
View File
@@ -87,6 +87,7 @@ class ConflictGuard
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected',
'cancellation.requested', 'cancellation.approved', 'cancellation.rejected',
])->pluck('user_id'))
->unique()->values()->all();
$ids = [];
@@ -0,0 +1,93 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\PurchaseControlPolicy;
use App\Models\PurchaseControlReview;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchaseControlPolicyWorkflow
{
private function admin(User $user): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
}
/** @param array<string,mixed> $data */
public function propose(User $user, array $data): PurchaseControlPolicy
{
$this->admin($user);
return DB::transaction(function () use ($user, $data) {
app(ConflictGuard::class)->lockCompany($user);
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
$this->admin($user);
$latest = PurchaseControlPolicy::query()->latest('revision')->first();
if ((int) $data['previous_id'] !== ($latest->id ?? 0) || $data['mode'] !== 'unissued_award') {
throw ValidationException::withMessages(['cancellation' => 'Versão desactualizada ou regra não suportada. Não se presume um circuito de excepção.']);
}
$policy = PurchaseControlPolicy::query()->create(['tenant_id' => $user->tenant_id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, 'mode' => $data['mode'], 'enabled' => $data['enabled'], 'valid_from' => $data['valid_from'], 'valid_until' => $data['valid_until'], 'reason' => $data['reason']]);
app(RequisitionWorkflow::class)->audit($user, 'purchase_control.proposed', null, null, $policy->toArray());
return $policy;
});
}
public function review(User $user, PurchaseControlPolicy $policy, string $decision, string $reason): void
{
$this->admin($user);
abort_unless($policy->tenant_id === $user->tenant_id && $policy->created_by !== $user->id, 403);
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
throw ValidationException::withMessages(['cancellation' => 'Decisão e fundamento obrigatórios.']);
}
DB::transaction(function () use ($user, $policy, $decision, $reason) {
app(ConflictGuard::class)->lockCompany($user);
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
$this->admin($user);
$policy = PurchaseControlPolicy::query()->lockForUpdate()->findOrFail($policy->id);
$existing = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('reviewed_by', $user->id)->first();
if ($existing) {
if ($existing->decision !== $decision || $existing->reason !== $reason) {
throw ValidationException::withMessages(['cancellation' => 'Decisão anterior imutável.']);
}
return;
}
if ($policy->status !== 'pending' || PurchaseControlPolicy::query()->max('revision') !== $policy->revision) {
throw ValidationException::withMessages(['cancellation' => 'Versão decidida ou substituída por proposta mais recente.']);
}
if ($decision === 'approved') {
$ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
}
}
PurchaseControlReview::query()->create(['tenant_id' => $user->tenant_id, 'purchase_control_policy_id' => $policy->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]);
if ($decision === 'rejected') {
$policy->update(['status' => 'rejected', 'decided_at' => now()]);
} elseif (PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->count() === 2) {
$policy->update(['status' => 'approved', 'decided_at' => now()]);
}
app(RequisitionWorkflow::class)->audit($user, 'purchase_control.'.$decision, null, null, ['policy_id' => $policy->id, 'status' => $policy->status]);
});
}
public function current(): ?PurchaseControlPolicy
{
return PurchaseControlPolicy::query()->where('status', 'approved')->where('valid_from', '<=', today()->format('Y-m-d'))->latest('revision')->first();
}
public function requireCurrent(): PurchaseControlPolicy
{
$policy = $this->current();
if (! $policy || $policy->valid_until < today()->format('Y-m-d') || $policy->mode !== 'unissued_award' || ! $policy->enabled) {
throw ValidationException::withMessages(['cancellation' => 'A empresa deve activar o cancelamento de adjudicações sem ordem emitida numa política vigente, com duas aprovações administrativas independentes.']);
}
return $policy;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\AwardCancellation;
use App\Models\BudgetEntry;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchaseRevisionWorkflow
{
private function invalid(string $message): never
{
throw ValidationException::withMessages(['cancellation' => $message]);
}
private function noOrder(PurchaseAward $award): void
{
if (PurchaseOrder::query()->where(fn ($query) => $query->where('purchase_award_id', $award->id)->orWhere('requisition_id', $award->requisition_id))->exists()) {
$this->invalid('Existe ordem emitida. Este circuito não cancela ordens, recepções, facturas ou pagamentos.');
}
}
private function basis(PurchaseAward $award): string
{
return hash('sha256', json_encode($award->only(['id', 'requisition_id', 'quotation_id', 'status', 'subtotal_minor', 'tax_minor', 'total_minor', 'comparison_hash', 'approval_snapshot', 'investment_snapshot', 'delivery_date', 'delivery_location']), JSON_THROW_ON_ERROR));
}
public function request(User $actor, PurchaseAward $award, string $reason): AwardCancellation
{
abort_unless($actor->tenant_id === $award->tenant_id, 403);
return DB::transaction(function () use ($actor, $award, $reason) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$request = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
abort_if($request->requester_id === $actor->id, 403);
app(ConflictGuard::class)->assertParticipation($actor, $request);
$this->noOrder($award);
$existing = AwardCancellation::query()->where('purchase_award_id', $award->id)->first();
if ($existing) {
if ($existing->created_by !== $actor->id || $existing->reason !== trim($reason)) {
$this->invalid('Já existe pedido ou decisão para esta adjudicação. O histórico não pode ser substituído.');
}
return $existing;
}
$policy = app(PurchaseControlPolicyWorkflow::class)->requireCurrent();
if (! in_array($award->status, ['pending', 'approved', 'rejected']) || trim($reason) === '') {
$this->invalid('Estado ou fundamento inválido para cancelamento.');
}
$cancellation = AwardCancellation::query()->create(['tenant_id' => $actor->tenant_id, 'purchase_award_id' => $award->id, 'purchase_control_policy_id' => $policy->id, 'created_by' => $actor->id, 'reason' => trim($reason), 'basis_hash' => $this->basis($award), 'snapshot' => ['policy' => $policy->only(['id', 'revision', 'mode', 'enabled', 'valid_from', 'valid_until']), 'award' => $award->toArray(), 'requisition_id' => $request->id, 'development_only' => true]]);
app(RequisitionWorkflow::class)->audit($actor, 'cancellation.requested', $request, null, ['cancellation_id' => $cancellation->id, 'award_id' => $award->id, 'policy_id' => $policy->id]);
return $cancellation;
});
}
public function review(User $actor, AwardCancellation $cancellation, string $decision, string $reason): void
{
abort_unless($actor->tenant_id === $cancellation->tenant_id, 403);
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
$this->invalid('Decisão e fundamento obrigatórios.');
}
DB::transaction(function () use ($actor, $cancellation, $decision, $reason) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance]);
$source = PurchaseAward::query()->findOrFail($cancellation->purchase_award_id);
$request = Requisition::query()->lockForUpdate()->findOrFail($source->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($source->id);
$cancellation = AwardCancellation::query()->lockForUpdate()->findOrFail($cancellation->id);
abort_if(in_array($actor->id, [$cancellation->created_by, $request->requester_id, $award->created_by], true), 403);
app(ConflictGuard::class)->assertParticipation($actor, $request);
if ($cancellation->status !== 'pending') {
if ($cancellation->status !== $decision || $cancellation->review_reason !== trim($reason)) {
$this->invalid('Decisão de cancelamento imutável.');
}
return;
}
$this->noOrder($award);
if ($cancellation->basis_hash !== $this->basis($award)) {
$this->invalid('A base da adjudicação mudou. Não é permitido cancelar com uma base desactualizada.');
}
if ($decision === 'approved') {
$policy = app(PurchaseControlPolicyWorkflow::class)->requireCurrent();
if ($policy->id !== $cancellation->purchase_control_policy_id) {
$this->invalid('A política da empresa mudou. A aprovação deste pedido está bloqueada; pode rejeitá-lo para retomar o processo.');
}
if ((int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor') !== 0) {
$this->invalid('Existe compromisso financeiro. Este circuito só liberta reservas não comprometidas.');
}
if (BudgetEntry::query()->where('requisition_id', $request->id)->exists()) {
app(BudgetWorkflow::class)->record($actor, $request, 'release');
}
$award->update(['status' => 'cancelled']);
$request->update(['status' => 'cancelled']);
}
$cancellation->update(['status' => $decision, 'reviewed_by' => $actor->id, 'review_reason' => trim($reason), 'reviewed_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'cancellation.'.$decision, $request, null, ['cancellation_id' => $cancellation->id, 'award_id' => $award->id, 'reservation_released' => $decision === 'approved']);
});
}
public function revise(User $actor, AwardCancellation $cancellation, string $neededBy, string $justification): Requisition
{
abort_unless($actor->tenant_id === $cancellation->tenant_id, 403);
return DB::transaction(function () use ($actor, $cancellation, $neededBy, $justification) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$cancellation = AwardCancellation::query()->lockForUpdate()->findOrFail($cancellation->id);
$award = PurchaseAward::query()->findOrFail($cancellation->purchase_award_id);
$source = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$this->noOrder($award);
abort_unless($source->requester_id === $actor->id && $source->department_id === $actor->department_id, 403);
if ($cancellation->status !== 'approved' || $source->status !== 'cancelled' || $award->status !== 'cancelled') {
$this->invalid('Só o solicitante pode preparar revisão após cancelamento aprovado.');
}
$existing = PurchaseRevision::query()->where('award_cancellation_id', $cancellation->id)->first();
if ($existing) {
return Requisition::query()->findOrFail($existing->requisition_id);
}
$items = [];
foreach ($source->items()->orderBy('id')->get() as $line) {
$items[] = [...$line->only(['description', 'category', 'purchase_type', 'quantity', 'unit', 'tax_rate', 'equipment_id', 'spare_part_id', 'production_area']), 'unit_price' => (string) BigDecimal::of($line->unit_price_minor)->dividedBy(100, 2), 'downtime_started_at' => $line->downtime_started_at ? substr(str_replace(' ', 'T', $line->downtime_started_at), 0, 16) : null];
}
$revised = app(RequisitionWorkflow::class)->save($actor, ['cost_center_id' => $source->cost_center_id, 'priority' => $source->priority, 'needed_by' => $neededBy, 'justification' => $justification, 'items' => $items]);
PurchaseRevision::query()->create(['tenant_id' => $actor->tenant_id, 'award_cancellation_id' => $cancellation->id, 'source_requisition_id' => $source->id, 'requisition_id' => $revised->id, 'created_by' => $actor->id, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'requisition.revision_created', $revised, null, ['source_requisition_id' => $source->id, 'cancellation_id' => $cancellation->id]);
return $revised;
});
}
}
+10
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\AwardCancellation;
use App\Models\Equipment;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
@@ -77,6 +78,7 @@ class PurchaseWorkflow
$user = app(CurrentActor::class)->lock($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
$this->assertNoPendingCancellation($award);
abort_unless($this->canDecide($user, $award, $requisition), 403);
if ($decision === 'approved') {
$this->currentBasis($user, $award, $requisition);
@@ -105,6 +107,7 @@ class PurchaseWorkflow
if ($existing) {
return $existing;
}
$this->assertNoPendingCancellation($award);
if ($award->status !== 'approved') {
throw ValidationException::withMessages(['approvals' => 'Conclua as aprovações do valor adjudicado antes de emitir a ordem.']);
}
@@ -287,4 +290,11 @@ class PurchaseWorkflow
app(EquipmentWorkflow::class)->line($line->toArray(), $requisition->department_id, $requisition->priority);
}
}
private function assertNoPendingCancellation(PurchaseAward $award): void
{
if (AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists()) {
throw ValidationException::withMessages(['cancellation' => 'Existe pedido de cancelamento pendente. Aguarde a decisão antes de aprovar ou emitir.']);
}
}
}
@@ -0,0 +1,66 @@
<?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_control_policies', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->unsignedInteger('revision');
$table->string('status')->default('pending');
$table->string('mode');
$table->boolean('enabled');
$table->date('valid_from');
$table->date('valid_until');
$table->text('reason');
$table->timestamp('decided_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'revision']);
});
Schema::create('purchase_control_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('purchase_control_policy_id')->constrained()->restrictOnDelete();
$table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete();
$table->string('decision');
$table->text('reason');
$table->timestamp('created_at');
$table->unique(['purchase_control_policy_id', 'reviewed_by']);
});
Schema::create('award_cancellations', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('purchase_award_id')->unique()->constrained()->restrictOnDelete();
$table->foreignId('purchase_control_policy_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
$table->string('status')->default('pending');
$table->string('basis_hash', 64);
$table->text('reason');
$table->text('review_reason')->nullable();
$table->json('snapshot');
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
});
Schema::create('purchase_revisions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('award_cancellation_id')->unique()->constrained()->restrictOnDelete();
$table->foreignId('source_requisition_id')->constrained('requisitions')->restrictOnDelete();
$table->foreignId('requisition_id')->unique()->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->timestamp('created_at');
});
}
public function down(): void
{
throw new LogicException('Preservar políticas, cancelamentos e ligações de revisão.');
}
};
+16
View File
@@ -0,0 +1,16 @@
{
"verified_at_utc": "2026-10-28T15:44:25+00:00",
"migration": "2026_10_28_000015_create_purchase_controls",
"batch": 16,
"backup_created": true,
"backup_bytes": 259925,
"restore_verified": true,
"restored_tables": 48,
"restore_database_dropped": true,
"business_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"purchase_control_policies_count": 0,
"purchase_control_reviews_count": 0,
"operational_validation": false
}
+57 -1
View File
@@ -1,5 +1,61 @@
# Validação técnica do SGC — 28 de Outubro de 2026
## Incremento: navegação partilhada e revisão sem ordem emitida
O cabeçalho e rodapé do SGC são partilhados pelo acesso autenticado e pela
consulta pública, incluindo a proposta `/proposta/principal`. Os dropdowns
agrupam Compras, Finanças, Armazém e Gestão; preservam os acessos anteriores,
o menu da conta e a localização do ecrã. As opções autenticadas respeitam o
perfil; a autorização continua a ser verificada no servidor. A referência
visual foi consultada apenas em leitura no InvesteMoz: preto `#090909`,
vermelho `#ed1c24`, menus brancos. O projecto InvesteMoz não foi alterado.
Em `/revisao-compras`, cada empresa pode propor uma política datada para
cancelar adjudicações sem ordem emitida. Não existe activação por defeito.
A implementação usa duas assinaturas administrativas distintas do proponente
para publicar a regra e uma decisão Financeira independente para cada
cancelamento pedido por Compras. Estas são opções deste circuito implementado;
não constituem valores financeiros predefinidos para as empresas.
Um cancelamento pendente bloqueia novas decisões finais e emissão. Aprovação
encerra a adjudicação e requisição originais, mantendo cotações e decisões;
liberta apenas reserva não comprometida. Repetições idênticas não duplicam
lançamentos. Rejeição mantém a compra original. Uma decisão é imutável e,
neste primeiro âmbito, existe no máximo um pedido de cancelamento por
adjudicação, incluindo pedidos rejeitados. Uma alteração da política bloqueia
a aprovação de pedidos antigos; a rejeição continua disponível para retomar
o processo. Ordens emitidas e compromissos financeiros impedem este circuito.
Só o solicitante original, ainda no mesmo departamento, pode preparar um
único novo rascunho ligado ao cancelamento aprovado, com nova data e
justificação. Copiam-se artigos e estimativas, sem decisões, cotações,
validações técnicas ou reserva automática. O novo pedido segue o circuito
normal, incluindo validação técnica quando aplicável. As restrições únicas
de adjudicação e ordem por requisição foram conservadas.
A migração aditiva 000015 foi aplicada no batch 16 após backup privado de
259925 bytes e restauro verificado de 48 tabelas numa base temporária.
Dados de negócio anteriores, restrições e `.env` foram preservados; não foram
criadas políticas ou operações fictícias na base real. Ver relatório
`revisao-migracao-000015.json`. O backup e o manifesto ficam fora do Git.
Sete cenários novos verificam política obrigatória, bloqueio pendente,
rejeição e retoma, histórico e rascunho único, recusa após emissão,
segregação após mudança de perfil, libertação única da reserva e rollback
conjunto quando falha a auditoria. A validação concorrente PostgreSQL deste
novo circuito continua pendente; os 32 cenários do incremento anterior não
comprovam a concorrência destas operações novas.
Resultado do incremento: **414 testes aprovados, 2 ignorados, 3099 asserções**.
PHPStan e Pint passaram; TypeScript, lint/formatação e build passaram.
As quatro verificações HTTP da proposta, requisições públicas, reforços e
aparência responderam 200. Estes pedidos HTTP não constituem revisão visual.
A revisão visual pública, autenticada e de impressão continua pendente:
o acesso ao navegador permanece indisponível. Compilação e testes não a
substituem. Ordens emitidas, estornos, notas de crédito, devoluções e
validação operacional das regras de cada empresa continuam fora deste âmbito.
Esta revisão cobre os módulos implementados e as pendências documentadas no
repositório. Não equivale a validação operacional integral ou certificação de
todos os requisitos do sistema. Valores e políticas reais continuam a pertencer
@@ -65,7 +121,7 @@ sessões. Nenhuma autorização de compra ou transferência bancária foi emitid
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Revisão visual pública, autenticada e impressão | Bloqueada pelo acesso ao navegador; testes HTTP não a substituem. |
| MFA obrigatório e ciclo completo de acessos | Não implementados/activados; dois testes condicionais continuam ignorados. |
| Revisão/cancelamento de adjudicações e ordens | Fluxo e política próprios ainda ausentes. Operações incompatíveis continuam bloqueadas. |
| Revisão/cancelamento de adjudicações e ordens | Cancelamento de adjudicações sem ordem implementado no incremento abaixo; cancelamento de ordens emitidas continua ausente. |
| Reversões, estornos, notas de crédito e devoluções | Funcionalidades ainda não implementadas; não se apagam lançamentos existentes. |
| Regras empresariais | Escalões provisórios, regras fiscais, validade/levantamento de conflitos e excepções exigem decisão da empresa. |
| Investimento agregado e fraccionamento | Apenas tecto por processo suportado; sem agregação/detecção automática. |
+4 -1
View File
@@ -1,5 +1,7 @@
import { createInertiaApp } from '@inertiajs/react';
import type { ReactNode } from 'react';
import { Toaster } from '@/components/ui/sonner';
import { SgcShell } from '@/components/sgc-shell';
import { TooltipProvider } from '@/components/ui/tooltip';
import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout';
@@ -14,8 +16,9 @@ void createInertiaApp({
switch (true) {
case name === 'welcome':
case name === 'home':
case name.startsWith('preview/'):
return null;
case name.startsWith('preview/'):
return (page: ReactNode) => <SgcShell demo>{page}</SgcShell>;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
+303
View File
@@ -0,0 +1,303 @@
import { Link, usePage } from '@inertiajs/react';
import { ChevronDown, LayoutDashboard } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UserMenuContent } from '@/components/user-menu-content';
type Entry = { label: string; path: string; demo: string; roles?: string[] };
const readers = ['admin', 'procurement', 'finance', 'department_head'];
const inventory = [...readers, 'warehouse'];
const groups: { label: string; entries: Entry[] }[] = [
{
label: 'Compras',
entries: [
{
label: 'Requisições',
path: '/requisicoes',
demo: '/consulta/requisicoes',
},
{
label: 'Fornecedores',
path: '/fornecedores',
demo: '/consulta/fornecedores',
roles: readers,
},
{
label: 'Cotações',
path: '/cotacoes',
demo: '/consulta/cotacoes',
roles: readers,
},
{
label: 'Ordens de compra',
path: '/ordens',
demo: '/consulta/ordens',
roles: readers,
},
],
},
{
label: 'Finanças',
entries: [
{
label: 'Orçamentos',
path: '/orcamentos',
demo: '/consulta?ecra=reinforcements',
roles: ['admin', 'finance'],
},
{
label: 'Facturas',
path: '/facturas',
demo: '/consulta/facturas',
roles: ['admin', 'finance'],
},
{
label: 'Reforços orçamentais',
path: '/orcamentos',
demo: '/consulta/reforcos',
roles: ['admin', 'finance'],
},
{
label: 'Dados bancários',
path: '/fornecedores',
demo: '/consulta/dados-bancarios',
roles: ['admin', 'finance'],
},
{
label: 'Pagamentos externos',
path: '/pagamentos',
demo: '/consulta/pagamentos',
roles: ['admin', 'finance'],
},
{
label: 'Regras de investimento',
path: '/regras-investimento',
demo: '/consulta/investimento',
roles: ['admin', 'finance', 'procurement'],
},
],
},
{
label: 'Armazém',
entries: [
{
label: 'Recepções',
path: '/recepcoes',
demo: '/consulta/recepcoes',
roles: inventory,
},
{
label: 'Equipamentos e peças',
path: '/equipamentos',
demo: '/consulta/equipamentos',
roles: inventory,
},
{
label: 'Stock e movimentos',
path: '/stock',
demo: '/consulta/stock',
roles: ['admin', 'procurement', 'finance', 'warehouse'],
},
{
label: 'Saídas e inventários',
path: '/stock/operacoes',
demo: '/consulta/operacoes-stock',
roles: ['admin', 'procurement', 'finance', 'warehouse'],
},
],
},
{
label: 'Gestão',
entries: [
{
label: 'Conflitos de interesses',
path: '/conflitos',
demo: '/consulta/conflitos',
},
{
label: 'Cancelamento e revisão',
path: '/revisao-compras',
demo: '',
},
{
label: 'Empresa e equipa',
path: '/empresa',
demo: '',
roles: ['admin'],
},
{
label: 'Aparência',
path: '/settings/appearance',
demo: '/consulta/aparencia',
},
],
},
];
export function SgcShell({
children,
demo = false,
}: {
children: ReactNode;
demo?: boolean;
}) {
const page = usePage();
const user = page.props.auth?.user;
const role = user?.purchasing_role ?? '';
const overview = demo ? '/proposta/principal' : '/dashboard';
const active = (entry: Entry) =>
demo
? page.url.split('?')[0] === entry.demo.split('?')[0] &&
page.url === entry.demo
: page.url === entry.path || page.url.startsWith(`${entry.path}/`);
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<a
href="#sgc-main"
className="sr-only z-50 rounded bg-white p-3 text-black focus:not-sr-only focus:absolute"
>
Saltar para o conteúdo
</a>
{demo && (
<div className="bg-[#1f1f1f] px-4 py-2 text-center text-xs text-slate-200">
Demonstração · Dados fictícios · Sem operações reais
</div>
)}
<header className="border-b border-white/10 bg-[#090909] text-white print:hidden">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-5 py-5 lg:px-8">
<Link href={overview} className="flex items-center gap-3">
<span className="grid size-11 place-items-center rounded-xl bg-[#ed1c24] font-bold text-white">
SGC
</span>
<span>
<span className="block text-sm font-semibold">
Sistema de Gestão de Compras
</span>
<span className="mt-1 block text-xs text-slate-400">
Compras com controlo e transparência
</span>
</span>
</Link>
{user && !demo ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="text-white hover:bg-white/10 hover:text-white"
>
{user.name}
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<UserMenuContent user={user} />
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
asChild
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 hover:text-white"
>
<Link href="/login">Entrar na minha empresa</Link>
</Button>
)}
</div>
<nav
aria-label="Navegação principal"
className="mx-auto flex max-w-7xl flex-wrap gap-1 px-5 pb-3 lg:px-8"
>
<Link
href={overview}
aria-current={
page.url === overview ? 'page' : undefined
}
className={`inline-flex min-h-11 items-center gap-2 rounded-lg border-b-2 px-4 py-2 text-sm font-medium ${page.url === overview ? 'border-[#ed1c24] bg-white/5' : 'border-transparent text-slate-200 hover:bg-white/10'}`}
>
<LayoutDashboard className="size-4" />
Visão geral
</Link>
{groups.map((group) => {
const entries = group.entries.filter((entry) =>
demo
? entry.demo !== ''
: !entry.roles || entry.roles.includes(role),
);
if (!entries.length) return null;
return (
<DropdownMenu key={group.label}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={`min-h-11 border-b-2 text-slate-200 hover:bg-white/10 hover:text-white data-[state=open]:bg-white/10 ${entries.some(active) ? 'border-[#ed1c24] text-white' : 'border-transparent'}`}
>
{group.label}
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-60 rounded-xl border-slate-200 bg-white p-2 text-slate-900 shadow-xl"
>
{entries.map((entry) => (
<DropdownMenuItem
key={entry.label}
asChild
className="min-h-11 rounded-md focus:bg-red-50 focus:text-red-700"
>
<Link
href={
demo
? entry.demo
: entry.path
}
aria-current={
active(entry)
? 'page'
: undefined
}
>
{entry.label}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
})}
</nav>
</header>
<main id="sgc-main" className="min-w-0 flex-1" tabIndex={-1}>
{children}
</main>
<footer className="mt-auto bg-[#090909] text-slate-300 print:hidden">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-5 py-6 text-xs lg:px-8">
<span>
<strong className="font-semibold text-white">
SGC
</strong>{' '}
· Sistema de Gestão de Compras
</span>
<span>
{demo
? 'Ambiente de demonstração'
: 'Espaço de trabalho da sua empresa'}
<span className="mx-3 text-[#ed1c24]">•</span>
<Link
href={demo ? '/' : '/settings/profile'}
className="hover:text-white"
>
{demo ? 'Página inicial' : 'A minha conta'}
</Link>
</span>
</div>
</footer>
</div>
);
}
@@ -41,7 +41,7 @@ export function UserMenuContent({ user }: Props) {
onClick={cleanup}
>
<Settings className="mr-2" />
Settings
A minha conta
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -55,7 +55,7 @@ export function UserMenuContent({ user }: Props) {
data-test="logout-button"
>
<LogOut className="mr-2" />
Log out
Terminar sessão
</Link>
</DropdownMenuItem>
</>
+10 -11
View File
@@ -1,7 +1,5 @@
import { AppContent } from '@/components/app-content';
import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import { SgcShell } from '@/components/sgc-shell';
import { Breadcrumbs } from '@/components/breadcrumbs';
import type { AppLayoutProps } from '@/types';
export default function AppSidebarLayout({
@@ -9,12 +7,13 @@ export default function AppSidebarLayout({
breadcrumbs = [],
}: AppLayoutProps) {
return (
<AppShell variant="sidebar">
<AppSidebar />
<AppContent variant="sidebar" className="min-w-0 overflow-x-clip">
<AppSidebarHeader breadcrumbs={breadcrumbs} />
{children}
</AppContent>
</AppShell>
<SgcShell>
{breadcrumbs.length > 0 && (
<div className="mx-auto w-full max-w-7xl px-5 pt-5 lg:px-8">
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
)}
{children}
</SgcShell>
);
}
+9 -111
View File
@@ -1,14 +1,11 @@
import { Head, Link, router } from '@inertiajs/react';
import { Head, router } from '@inertiajs/react';
import { useState } from 'react';
import {
ArrowRight,
Building2,
ClipboardList,
Eye,
FileText,
LayoutGrid,
LockKeyhole,
Menu,
Palette,
Scale,
Search,
@@ -126,17 +123,7 @@ const navigation = [
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
const navigationGroups = [
{ title: 'Geral', ids: ['overview'] },
{ title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] },
{ title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] },
{ title: 'Controlo', ids: ['conflicts'] },
{
title: 'Finanças',
ids: ['invoices', 'bank', 'payments', 'reinforcements', 'investment'],
},
{ title: 'Apresentação', ids: ['appearance'] },
];
export default function Preview({
initialScreen = 'overview',
}: {
@@ -144,12 +131,12 @@ export default function Preview({
}) {
const screen = initialScreen;
const [search, setSearch] = useState('');
const [menu, setMenu] = useState(false);
const [detail, setDetail] = useState<Detail | null>(null);
const title = navigation.find((n) => n.id === screen)?.title ?? 'Consulta';
function navigate(id: string) {
setSearch('');
setMenu(false);
router.get(previewPaths[id] ?? '/consulta');
}
const filteredRequests = requests.filter((r) =>
@@ -231,96 +218,11 @@ export default function Preview({
<>
<Head title="Consulta livre" />
<div className="min-h-screen bg-muted/25 text-foreground">
<aside
className={`${menu ? 'flex' : 'hidden'} fixed inset-y-0 left-0 z-30 w-64 flex-col border-r bg-background p-5 lg:flex`}
>
<Link
href="/"
className="mb-8 flex items-center gap-3 text-xl font-semibold"
>
<span className="grid size-9 place-items-center rounded-xl bg-teal-700 text-sm text-white">
SGC
</span>
SGC
</Link>
<div className="mb-7 rounded-xl border bg-muted/30 p-4">
<p className="flex items-center gap-2 text-sm font-semibold">
<Building2 className="size-4" />
Empresa de demonstração
</p>
<p className="mt-2 text-xs text-muted-foreground">
Consulta livre · sem autenticação
</p>
<div>
<div className="mx-auto max-w-7xl px-5 pt-5 text-sm text-muted-foreground">
Consulta / {title}
</div>
<nav
aria-label="Áreas da demonstração"
className="space-y-4"
>
{navigationGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-3 text-[10px] font-semibold tracking-widest text-muted-foreground uppercase">
{group.title}
</p>
{navigation
.filter((n) => group.ids.includes(n.id))
.map((n) => (
<Link
href={previewPaths[n.id]}
key={n.id}
onClick={() => setMenu(false)}
aria-current={
screen === n.id
? 'page'
: undefined
}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${screen === n.id ? 'bg-teal-50 font-medium text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'text-muted-foreground hover:bg-muted'}`}
>
<n.icon className="size-4" />
{n.title}
</Link>
))}
</div>
))}
</nav>
<p className="mt-auto pt-8 text-xs leading-5 text-muted-foreground">
Dados fictícios. A navegação e a pesquisa permitem
avaliar a apresentação; não executam compras.
</p>
</aside>
{menu && (
<button
className="fixed inset-0 z-20 bg-black/25 lg:hidden"
aria-label="Fechar menu"
onClick={() => setMenu(false)}
/>
)}
<div className="lg:ml-64">
<header className="flex min-h-20 items-center justify-between gap-3 border-b bg-background px-5 lg:px-8">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
aria-label="Abrir menu"
onClick={() => setMenu(!menu)}
>
<Menu className="size-5" />
</Button>
<span className="text-sm text-muted-foreground">
Consulta visual <span className="mx-2">/</span>
<strong className="font-medium text-foreground">
{title}
</strong>
</span>
</div>
<Button asChild variant="outline">
<Link href="/login">
<LockKeyhole className="size-4" />
Entrar na empresa
</Link>
</Button>
</header>
<main className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900 dark:border-teal-900 dark:bg-teal-950 dark:text-teal-100">
<span className="flex items-center gap-2">
<Eye className="size-4" />
@@ -932,11 +834,7 @@ export default function Preview({
</Panel>
</>
)}
<footer className="py-4 text-xs text-muted-foreground">
SGC · Consulta pública de exemplos · Os processos
reais permanecem no acesso autenticado.
</footer>
</main>
</div>
</div>
</div>
<Dialog
+266
View File
@@ -0,0 +1,266 @@
import { Head, Link } from '@inertiajs/react';
import {
ArrowRight,
CheckCheck,
ChevronDown,
ClipboardList,
Clock3,
Plus,
Wallet,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const href = (screen: string) =>
screen.startsWith('?') ? `/consulta${screen}` : `/consulta/${screen}`;
const requests = [
{
number: 'REQ-DEMO-024',
description: 'Material de escritório',
department: 'Administração',
amount: '24 500,00 MT',
status: 'Em aprovação',
},
{
number: 'REQ-DEMO-023',
description: 'Peças para manutenção',
department: 'Produção',
amount: '86 200,00 MT',
status: 'Aprovada',
},
{
number: 'REQ-DEMO-022',
description: 'Consumíveis de limpeza',
department: 'Operações',
amount: '12 800,00 MT',
status: 'Rascunho',
},
];
export default function PrincipalProposal() {
const [filter, setFilter] = useState('Todos os estados');
const visible = requests.filter(
(request) => filter === 'Todos os estados' || request.status === filter,
);
return (
<>
<Head title="Proposta de ecrã principal" />
<div className="min-h-screen bg-slate-50 text-slate-900">
<div className="mx-auto flex max-w-7xl flex-col gap-7 px-5 py-8 lg:px-8 lg:py-10">
<section className="flex flex-wrap items-end justify-between gap-5">
<div>
<p className="text-xs font-semibold tracking-widest text-red-700 uppercase">
O seu espaço de trabalho
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
As compras, numa só vista.
</h1>
<p className="mt-3 max-w-xl text-sm leading-6 text-slate-500">
Veja o que precisa da sua atenção e avance para
a próxima tarefa.
</p>
</div>
<Button
asChild
className="bg-[#ed1c24] text-white hover:bg-[#b91c1c]"
>
<Link href="/consulta/requisicoes">
<Plus className="size-4" />
Explorar requisições
</Link>
</Button>
</section>
<section
aria-label="Indicadores de demonstração"
className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
{[
{
label: 'Requisições',
value: '24',
detail: 'Pedidos neste exemplo',
icon: ClipboardList,
},
{
label: 'Em aprovação',
value: '6',
detail: '2 aguardam a sua decisão',
icon: Clock3,
},
{
label: 'Aprovadas',
value: '15',
detail: 'Com circuito concluído',
icon: CheckCheck,
},
{
label: 'Valor aprovado',
value: '428 500 MT',
detail: 'Estimativa de demonstração, com IVA',
icon: Wallet,
},
].map((card) => (
<div
key={card.label}
className="rounded-2xl border border-slate-200 bg-white p-5 shadow-xs"
>
<div className="flex items-center justify-between text-sm text-slate-500">
{card.label}
<card.icon className="size-4 text-red-700" />
</div>
<p className="mt-5 text-3xl font-semibold tracking-tight">
{card.value}
</p>
<p className="mt-2 text-xs text-slate-500">
{card.detail}
</p>
</div>
))}
</section>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-100 p-5">
<div>
<h2 className="font-semibold">
Requisições recentes
</h2>
<p className="mt-1 text-xs text-slate-400">
Exemplos para avaliar a organização do
ecrã
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="border-slate-200 bg-white text-slate-700"
>
{filter}
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[
'Todos os estados',
'Em aprovação',
'Aprovada',
'Rascunho',
].map((status) => (
<DropdownMenuItem
key={status}
onSelect={() =>
setFilter(status)
}
>
{status}
{filter === status && (
<CheckCheck className="ml-auto size-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="divide-y divide-slate-100">
{visible.map((request) => (
<Link
key={request.number}
href="/consulta/requisicoes"
className="flex flex-wrap items-center justify-between gap-4 p-5 transition-colors hover:bg-slate-50"
>
<div>
<p className="text-xs font-medium text-red-700">
{request.number}
</p>
<p className="mt-1 text-sm font-medium">
{request.description}
</p>
<p className="mt-1 text-xs text-slate-400">
{request.department}
</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold">
{request.amount}
</p>
<span
className={`mt-2 inline-flex rounded-full px-2.5 py-1 text-xs ${request.status === 'Aprovada' ? 'bg-red-50 text-[#ed1c24]' : request.status === 'Em aprovação' ? 'bg-amber-50 text-amber-800' : 'bg-slate-100 text-slate-600'}`}
>
{request.status}
</span>
</div>
</Link>
))}
</div>
<Link
href="/consulta/requisicoes"
className="flex items-center justify-center gap-2 border-t border-slate-100 p-4 text-sm font-medium text-[#ed1c24]"
>
Ver todas as requisições
<ArrowRight className="size-4" />
</Link>
</section>
<aside className="flex flex-col gap-5">
<section className="rounded-2xl border-t-4 border-[#ed1c24] bg-[#090909] p-6 text-white">
<span className="inline-flex rounded-xl bg-white/10 p-2.5">
<Clock3 className="size-5" />
</span>
<h2 className="mt-4 text-lg font-semibold">
A sua atenção faz a diferença
</h2>
<p className="mt-2 text-sm leading-6 text-slate-200">
Neste exemplo, 2 pedidos aguardam a sua
decisão. Consulte os detalhes antes de
aprovar.
</p>
<Button
asChild
className="mt-5 w-full bg-white text-[#b91c1c] hover:bg-red-50"
>
<Link href="/consulta/requisicoes">
Consultar pedidos
<ArrowRight className="size-4" />
</Link>
</Button>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-5">
<h2 className="font-semibold">
Acessos rápidos
</h2>
<div className="mt-3 space-y-1">
{[
['Conferir entregas', 'recepcoes'],
[
'Consultar reforços',
'?ecra=reinforcements',
],
[
'Encontrar fornecedor',
'fornecedores',
],
].map(([label, screen]) => (
<Link
key={screen}
href={href(screen)}
className="flex items-center justify-between rounded-lg py-3 text-sm text-slate-600 hover:bg-slate-50 hover:text-[#ed1c24]"
>
{label}
<ArrowRight className="size-4" />
</Link>
))}
</div>
</section>
</aside>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,357 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Policy = {
id: number;
revision: number;
status: string;
created_by: number;
enabled: boolean;
valid_from: string;
valid_until: string;
};
type Cancellation = {
id: number;
purchase_award_id: number;
created_by: number;
status: string;
reason: string;
review_reason: string | null;
};
type Props = {
role: string;
actorId: number;
policies: Policy[];
reviews: {
purchase_control_policy_id: number;
reviewed_by: number;
decision: string;
}[];
awards: { id: number; requisition_id: number; status: string }[];
cancellations: Cancellation[];
sources: { id: number; number: string; revision_id: number | null }[];
};
const states: Record<string, string> = {
pending: 'Pendente',
approved: 'Aprovado',
rejected: 'Rejeitado',
cancelled: 'Cancelado',
};
function ReasonForm({
url,
decisions = false,
}: {
url: string;
decisions?: boolean;
}) {
const form = useForm({ reason: '', decision: 'approved' });
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(url, { preserveScroll: true });
}}
className="mt-3 space-y-3"
>
<Label>
Fundamento
<Input
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
minLength={10}
maxLength={4000}
required
/>
</Label>
{decisions && (
<Label>
Decisão
<select
className="block w-full rounded border bg-background p-2"
value={form.data.decision}
onChange={(event) =>
form.setData('decision', event.target.value)
}
>
<option value="approved">Aprovar</option>
<option value="rejected">Rejeitar</option>
</select>
</Label>
)}
{Object.entries(form.errors).map(([key, message]) => (
<p key={key} className="text-sm text-red-700">
{message}
</p>
))}
<Button disabled={form.processing}>
{decisions ? 'Registar decisão' : 'Solicitar cancelamento'}
</Button>
</form>
);
}
function PolicyForm({ previousId }: { previousId: number }) {
const form = useForm({
previous_id: previousId,
mode: 'unissued_award',
enabled: false,
valid_from: '',
valid_until: '',
reason: '',
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post('/revisao-compras/politicas', {
preserveScroll: true,
});
}}
className="grid gap-4 sm:grid-cols-2"
>
<Label>
Início da vigência
<Input
type="date"
required
value={form.data.valid_from}
onChange={(event) =>
form.setData('valid_from', event.target.value)
}
/>
</Label>
<Label>
Fim da vigência
<Input
type="date"
required
value={form.data.valid_until}
onChange={(event) =>
form.setData('valid_until', event.target.value)
}
/>
</Label>
<Label className="sm:col-span-2">
Fundamento
<Input
required
minLength={10}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<Label className="flex items-center gap-2 sm:col-span-2">
<input
type="checkbox"
checked={form.data.enabled}
onChange={(event) =>
form.setData('enabled', event.target.checked)
}
/>
Permitir cancelamento de adjudicações sem ordem emitida
</Label>
{Object.entries(form.errors).map(([key, message]) => (
<p key={key} className="text-sm text-red-700">
{message}
</p>
))}
<Button disabled={form.processing}>Propor regra da empresa</Button>
</form>
);
}
function RevisionForm({ id }: { id: number }) {
const form = useForm({ needed_by: '', justification: '' });
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/cancelamentos/${id}/revisao`);
}}
className="mt-3 grid gap-3"
>
<Label>
Nova data necessária
<Input
type="date"
required
value={form.data.needed_by}
onChange={(event) =>
form.setData('needed_by', event.target.value)
}
/>
</Label>
<Label>
Justificação actualizada
<Input
required
minLength={10}
value={form.data.justification}
onChange={(event) =>
form.setData('justification', event.target.value)
}
/>
</Label>
{Object.entries(form.errors).map(([key, message]) => (
<p key={key} className="text-sm text-red-700">
{message}
</p>
))}
<Button disabled={form.processing}>Preparar novo rascunho</Button>
</form>
);
}
export default function PurchaseControls({
role,
actorId,
policies,
reviews,
awards,
cancellations,
sources,
}: Props) {
return (
<>
<Head title="Cancelamento e revisão" />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Controlo de compras"
title="Cancelamento e revisão"
description="Preserve o processo anterior e prepare uma nova requisição após cancelamento aprovado."
/>
<p className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
Este circuito abrange apenas adjudicações sem ordem emitida.
Um pedido pendente suspende decisões e emissão. Ordens,
recepções, facturas e pagamentos exigem um circuito próprio.
</p>
{role === 'admin' && (
<Panel
title="Regra da empresa"
description="A publicação requer dois administradores distintos do proponente. A regra não vem activada por defeito."
>
<PolicyForm
key={policies[0]?.id ?? 0}
previousId={policies[0]?.id ?? 0}
/>
</Panel>
)}
{policies.map((policy) => (
<Panel
key={policy.id}
title={`Regra v${policy.revision} · ${states[policy.status]}`}
description={`${policy.enabled ? 'Permite cancelamento' : 'Não permite cancelamento'} · ${policy.valid_from} a ${policy.valid_until}`}
>
<p className="text-sm">
Assinaturas:{' '}
{
reviews.filter(
(review) =>
review.purchase_control_policy_id ===
policy.id &&
review.decision === 'approved',
).length
}
/2
</p>
{role === 'admin' &&
policy.status === 'pending' &&
policy.id === policies[0]?.id &&
policy.created_by !== actorId &&
!reviews.some(
(review) =>
review.purchase_control_policy_id ===
policy.id &&
review.reviewed_by === actorId,
) && (
<ReasonForm
url={`/revisao-compras/politicas/${policy.id}/decidir`}
decisions
/>
)}
</Panel>
))}
{awards.map((award) => {
const cancellation = cancellations.find(
(entry) => entry.purchase_award_id === award.id,
);
return (
<Panel
key={award.id}
title={`Adjudicação #${award.id} · ${states[award.status] ?? award.status}`}
>
<Link
href={`/requisicoes/${award.requisition_id}/adjudicacao`}
className="text-sm underline"
>
Consultar processo
</Link>
{cancellation ? (
<>
<p className="mt-3 text-sm">
Cancelamento{' '}
{states[cancellation.status]}:{' '}
{cancellation.reason}
</p>
{cancellation.review_reason && (
<p className="mt-2 text-sm">
Decisão:{' '}
{cancellation.review_reason}
</p>
)}
{role === 'finance' &&
cancellation.status === 'pending' &&
cancellation.created_by !== actorId && (
<ReasonForm
url={`/cancelamentos/${cancellation.id}/decidir`}
decisions
/>
)}
</>
) : (
role === 'procurement' && (
<ReasonForm
url={`/adjudicacoes/${award.id}/cancelamento`}
/>
)
)}
</Panel>
);
})}
<Panel
title="Os meus processos para revisão"
description="Só o solicitante original pode criar um novo rascunho. Aprovações, cotações e reservas não são copiadas."
>
{sources.length === 0 && (
<p className="text-sm text-muted-foreground">
Sem cancelamentos aprovados para revisão.
</p>
)}
{sources.map((source) => (
<div key={source.id} className="border-b py-4">
<p className="font-medium">{source.number}</p>
{source.revision_id ? (
<Link
href={`/requisicoes/${source.revision_id}`}
className="text-sm underline"
>
Abrir nova requisição
</Link>
) : (
<RevisionForm id={source.id} />
)}
</div>
))}
</Panel>
</div>
</>
);
}
+1 -1
View File
@@ -247,7 +247,7 @@ export default function Award({
{award && (
<Panel
title={`Adjudicação #${award.id}`}
description={`Estado: ${award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}`}
description={`Estado: ${award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : award.status === 'cancelled' ? 'Cancelada' : 'Rejeitada'}`}
>
<p className="text-sm whitespace-pre-wrap">
{award.justification}
+3 -1
View File
@@ -96,7 +96,9 @@ export default function Orders({
? 'Em aprovação'
: award.status === 'approved'
? 'Aprovada para emissão'
: 'Rejeitada'}
: award.status === 'cancelled'
? 'Cancelada'
: 'Rejeitada'}
</p>
</div>
<span>{money(award.total_minor)}</span>
+1
View File
@@ -74,6 +74,7 @@ export const statusLabels: Record<string, string> = {
pending: 'Em aprovação',
approved: 'Aprovada',
rejected: 'Rejeitada',
cancelled: 'Cancelada',
};
export const priorityLabels: Record<string, string> = {
normal: 'Normal',
+2
View File
@@ -5,6 +5,8 @@ use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
// Public visual consultation. No business records or authenticated identities.
Route::inertia('proposta/principal', 'preview/principal', ['auth' => ['user' => null]])->name('preview.principal');
Route::get('consulta', function (Request $request) {
$screen = $request->query('ecra', 'overview');
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'payments', 'reinforcements', 'investment', 'appearance'];
+7
View File
@@ -9,6 +9,7 @@ use App\Http\Controllers\ExternalPaymentController;
use App\Http\Controllers\GoodsReceiptController;
use App\Http\Controllers\InvestmentPolicyController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\PurchaseControlController;
use App\Http\Controllers\PurchaseController;
use App\Http\Controllers\QuotationController;
use App\Http\Controllers\RequisitionController;
@@ -29,6 +30,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('revisao-compras', [PurchaseControlController::class, 'index'])->name('purchase-controls.index');
Route::post('revisao-compras/politicas', [PurchaseControlController::class, 'propose']);
Route::post('revisao-compras/politicas/{policy}/decidir', [PurchaseControlController::class, 'policyReview']);
Route::post('adjudicacoes/{award}/cancelamento', [PurchaseControlController::class, 'cancel']);
Route::post('cancelamentos/{cancellation}/decidir', [PurchaseControlController::class, 'review']);
Route::post('cancelamentos/{cancellation}/revisao', [PurchaseControlController::class, 'revise']);
Route::get('regras-investimento', [InvestmentPolicyController::class, 'index'])->name('investment.index');
Route::post('regras-investimento', [InvestmentPolicyController::class, 'store'])->name('investment.store');
Route::post('regras-investimento/{policy}/decidir', [InvestmentPolicyController::class, 'review'])->name('investment.review');
+109
View File
@@ -2,7 +2,9 @@
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\AwardCancellation;
use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Models\CostCenter;
use App\Models\CriticalityLevel;
use App\Models\Department;
@@ -10,7 +12,9 @@ use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\InvestmentPolicy;
use App\Models\PurchaseAward;
use App\Models\PurchaseControlPolicy;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\SparePart;
@@ -23,6 +27,8 @@ use App\Models\Warehouse;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
@@ -504,3 +510,106 @@ test('final approval rechecks a department head who moved departments', function
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
expect($award->approvals()->where('decision', 'approved')->count())->toBe(0);
});
function enablePurchaseCancellation(object $test): PurchaseControlPolicy
{
$admins = [$test->admin, User::factory()->create(['tenant_id' => $test->tenant->id, 'purchasing_role' => PurchasingRole::Admin]), User::factory()->create(['tenant_id' => $test->tenant->id, 'purchasing_role' => PurchasingRole::Admin])];
$test->actingAs($admins[0]);
$workflow = app(PurchaseControlPolicyWorkflow::class);
$policy = $workflow->propose($admins[0], ['previous_id' => 0, 'mode' => 'unissued_award', 'enabled' => true, 'valid_from' => today()->format('Y-m-d'), 'valid_until' => today()->addMonth()->format('Y-m-d'), 'reason' => 'Regra fictícia para teste isolado']);
foreach ([$admins[1], $admins[2]] as $admin) {
$test->actingAs($admin);
$workflow->review($admin, $policy, 'approved', 'Validação independente fictícia');
}
return $policy->refresh();
}
test('cancellation requires an explicitly published company policy', function () {
$award = selectPurchaseAward($this);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade alterada para teste'])->assertSessionHasErrors('cancellation');
expect(AwardCancellation::query()->count())->toBe(0);
});
test('pending cancellation freezes final approval and rejection resumes the original process', function () {
enablePurchaseCancellation($this);
$award = selectPurchaseAward($this);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade alterada para teste'])->assertSessionHasNoErrors();
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasErrors('cancellation');
$cancel = AwardCancellation::query()->firstOrFail();
$this->actingAs($this->finance)->post('/cancelamentos/'.$cancel->id.'/decidir', ['decision' => 'rejected', 'reason' => 'Manter o processo original fictício'])->assertSessionHasNoErrors();
issuePurchaseOrder($this, $award);
expect(PurchaseOrder::query()->count())->toBe(1);
});
test('approved cancellation preserves history and creates exactly one fresh requester draft', function () {
enablePurchaseCancellation($this);
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
$steps = $award->approvals()->count();
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade alterada para teste'])->assertSessionHasNoErrors();
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('cancellation');
$cancel = AwardCancellation::query()->firstOrFail();
$this->actingAs($this->finance)->post('/cancelamentos/'.$cancel->id.'/decidir', ['decision' => 'approved', 'reason' => 'Cancelamento independente fictício'])->assertSessionHasNoErrors();
expect($award->refresh()->status)->toBe('cancelled')->and($this->r->refresh()->status)->toBe('cancelled')->and($award->approvals()->count())->toBe($steps)->and(Quotation::query()->count())->toBe(3);
$this->actingAs($this->owner);
$data = ['needed_by' => today()->addWeek()->format('Y-m-d'), 'justification' => 'Nova necessidade fictícia actualizada'];
$this->post('/cancelamentos/'.$cancel->id.'/revisao', $data)->assertSessionHasNoErrors();
$this->post('/cancelamentos/'.$cancel->id.'/revisao', $data)->assertSessionHasNoErrors();
$link = PurchaseRevision::query()->sole();
$draft = Requisition::query()->findOrFail($link->requisition_id);
expect($draft->status)->toBe('draft')->and($draft->steps()->count())->toBe(0)->and($draft->items()->count())->toBe(1)->and($draft->total_minor)->toBe($this->r->total_minor)->and(PurchaseOrder::query()->count())->toBe(0);
});
test('issued orders cannot enter the cancellation circuit', function () {
enablePurchaseCancellation($this);
$award = selectPurchaseAward($this);
issuePurchaseOrder($this, $award);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Tentativa fictícia após emissão'])->assertSessionHasErrors('cancellation');
expect(AwardCancellation::query()->count())->toBe(0)->and(PurchaseOrder::query()->count())->toBe(1);
});
test('cancellation cannot be self reviewed after a role change', function () {
enablePurchaseCancellation($this);
$award = selectPurchaseAward($this);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade alterada para teste'])->assertSessionHasNoErrors();
$cancel = AwardCancellation::query()->firstOrFail();
DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => 'finance']);
$this->post('/cancelamentos/'.$cancel->id.'/decidir', ['decision' => 'approved', 'reason' => 'Tentativa de auto aprovação fictícia'])->assertForbidden();
expect($cancel->refresh()->status)->toBe('pending');
});
test('cancellation releases the unused budget reservation exactly once', function () {
prepareEquipmentInvestment($this);
enablePurchaseCancellation($this);
$award = selectPurchaseAward($this);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade alterada para teste'])->assertSessionHasNoErrors();
$cancel = AwardCancellation::query()->firstOrFail();
$data = ['decision' => 'approved', 'reason' => 'Libertação independente fictícia'];
$this->actingAs($this->finance)->post('/cancelamentos/'.$cancel->id.'/decidir', $data)->assertSessionHasNoErrors();
$this->post('/cancelamentos/'.$cancel->id.'/decidir', $data)->assertSessionHasNoErrors();
expect(BudgetEntry::query()->where('requisition_id', $this->r->id)->sum('reserved_minor'))->toBe(0)
->and(BudgetEntry::query()->where('requisition_id', $this->r->id)->where('kind', 'release')->count())->toBe(1);
});
test('cancellation and reservation release roll back together if audit fails', function () {
prepareEquipmentInvestment($this);
enablePurchaseCancellation($this);
$award = selectPurchaseAward($this);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade alterada para teste'])->assertSessionHasNoErrors();
$cancel = AwardCancellation::query()->firstOrFail();
$this->actingAs($this->finance);
AuditLog::creating(function (AuditLog $entry) {
if ($entry->action === 'cancellation.approved') {
throw new RuntimeException('Falha fictícia de auditoria');
}
});
try {
expect(fn () => app(PurchaseRevisionWorkflow::class)->review($this->finance, $cancel, 'approved', 'Teste de atomicidade fictício'))->toThrow(RuntimeException::class);
} finally {
AuditLog::flushEventListeners();
}
expect($cancel->refresh()->status)->toBe('pending')->and($award->refresh()->status)->toBe('pending')
->and(BudgetEntry::query()->where('kind', 'release')->count())->toBe(0)
->and((int) BudgetEntry::query()->sum('reserved_minor'))->toBeGreaterThan(0);
});
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000015.
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_000015_create_purchase_controls';
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-controls-000015-'.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_control_policies_count' => (int) $pdo->query('SELECT count(*) FROM purchase_control_policies')->fetchColumn(), 'purchase_control_reviews_count' => (int) $pdo->query('SELECT count(*) FROM purchase_control_reviews')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/revisao-migracao-000015.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));
}
}