Implementa revisao de adjudicacoes e navegacao partilhada do SGC
This commit is contained in:
@@ -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,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'].'%');
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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/'):
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user