From ff403ea927908cc784810bd2729830f37f81d668 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 17:46:59 +0200 Subject: [PATCH] Implementa revisao de adjudicacoes e navegacao partilhada do SGC --- .../Controllers/PurchaseControlController.php | 85 +++++ app/Http/Controllers/PurchaseController.php | 6 +- .../Controllers/RequisitionController.php | 2 +- app/Models/AwardCancellation.php | 39 ++ app/Models/PurchaseControlPolicy.php | 36 ++ app/Models/PurchaseControlReview.php | 16 + app/Models/PurchaseRevision.php | 16 + app/Services/ConflictGuard.php | 1 + .../PurchaseControlPolicyWorkflow.php | 93 +++++ app/Services/PurchaseRevisionWorkflow.php | 142 +++++++ app/Services/PurchaseWorkflow.php | 10 + ..._10_28_000015_create_purchase_controls.php | 66 ++++ docs/revisao-migracao-000015.json | 16 + docs/validacao-tecnica.md | 58 ++- resources/js/app.tsx | 5 +- resources/js/components/sgc-shell.tsx | 303 +++++++++++++++ resources/js/components/user-menu-content.tsx | 4 +- .../js/layouts/app/app-sidebar-layout.tsx | 21 +- resources/js/pages/preview/index.tsx | 120 +----- resources/js/pages/preview/principal.tsx | 266 +++++++++++++ .../js/pages/purchase-controls/index.tsx | 357 ++++++++++++++++++ resources/js/pages/purchases/award.tsx | 2 +- resources/js/pages/purchases/index.tsx | 4 +- resources/js/types/procurement.ts | 1 + routes/preview.php | 2 + routes/web.php | 7 + tests/Feature/PurchaseAwardOrderTest.php | 109 ++++++ .../apply-purchase-controls-migration.php | 111 ++++++ 28 files changed, 1767 insertions(+), 131 deletions(-) create mode 100644 app/Http/Controllers/PurchaseControlController.php create mode 100644 app/Models/AwardCancellation.php create mode 100644 app/Models/PurchaseControlPolicy.php create mode 100644 app/Models/PurchaseControlReview.php create mode 100644 app/Models/PurchaseRevision.php create mode 100644 app/Services/PurchaseControlPolicyWorkflow.php create mode 100644 app/Services/PurchaseRevisionWorkflow.php create mode 100644 database/migrations/2026_10_28_000015_create_purchase_controls.php create mode 100644 docs/revisao-migracao-000015.json create mode 100644 resources/js/components/sgc-shell.tsx create mode 100644 resources/js/pages/preview/principal.tsx create mode 100644 resources/js/pages/purchase-controls/index.tsx create mode 100644 tests/Support/apply-purchase-controls-migration.php diff --git a/app/Http/Controllers/PurchaseControlController.php b/app/Http/Controllers/PurchaseControlController.php new file mode 100644 index 0000000..232bcc9 --- /dev/null +++ b/app/Http/Controllers/PurchaseControlController.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php index 0e466d3..d93d7ed 100644 --- a/app/Http/Controllers/PurchaseController.php +++ b/app/Http/Controllers/PurchaseController.php @@ -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']), ]); } diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php index da14413..7dcb12f 100644 --- a/app/Http/Controllers/RequisitionController.php +++ b/app/Http/Controllers/RequisitionController.php @@ -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'].'%'); diff --git a/app/Models/AwardCancellation.php b/app/Models/AwardCancellation.php new file mode 100644 index 0000000..474c363 --- /dev/null +++ b/app/Models/AwardCancellation.php @@ -0,0 +1,39 @@ + $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.')); + } +} diff --git a/app/Models/PurchaseControlPolicy.php b/app/Models/PurchaseControlPolicy.php new file mode 100644 index 0000000..0702766 --- /dev/null +++ b/app/Models/PurchaseControlPolicy.php @@ -0,0 +1,36 @@ + '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.')); + } +} diff --git a/app/Models/PurchaseControlReview.php b/app/Models/PurchaseControlReview.php new file mode 100644 index 0000000..372d235 --- /dev/null +++ b/app/Models/PurchaseControlReview.php @@ -0,0 +1,16 @@ +pluck('user_id')) ->unique()->values()->all(); $ids = []; diff --git a/app/Services/PurchaseControlPolicyWorkflow.php b/app/Services/PurchaseControlPolicyWorkflow.php new file mode 100644 index 0000000..67e945a --- /dev/null +++ b/app/Services/PurchaseControlPolicyWorkflow.php @@ -0,0 +1,93 @@ +id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403); + } + + /** @param array $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; + } +} diff --git a/app/Services/PurchaseRevisionWorkflow.php b/app/Services/PurchaseRevisionWorkflow.php new file mode 100644 index 0000000..90c2008 --- /dev/null +++ b/app/Services/PurchaseRevisionWorkflow.php @@ -0,0 +1,142 @@ + $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; + }); + } +} diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php index b03e24a..d691141 100644 --- a/app/Services/PurchaseWorkflow.php +++ b/app/Services/PurchaseWorkflow.php @@ -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.']); + } + } } diff --git a/database/migrations/2026_10_28_000015_create_purchase_controls.php b/database/migrations/2026_10_28_000015_create_purchase_controls.php new file mode 100644 index 0000000..8e4fe53 --- /dev/null +++ b/database/migrations/2026_10_28_000015_create_purchase_controls.php @@ -0,0 +1,66 @@ +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.'); + } +}; diff --git a/docs/revisao-migracao-000015.json b/docs/revisao-migracao-000015.json new file mode 100644 index 0000000..5a25aa6 --- /dev/null +++ b/docs/revisao-migracao-000015.json @@ -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 +} diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index dd3ab20..453a13c 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -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. | diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 9282318..ce396c5 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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) => {page}; case name.startsWith('auth/'): return AuthLayout; case name.startsWith('settings/'): diff --git a/resources/js/components/sgc-shell.tsx b/resources/js/components/sgc-shell.tsx new file mode 100644 index 0000000..940ad63 --- /dev/null +++ b/resources/js/components/sgc-shell.tsx @@ -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 ( +
+ + Saltar para o conteúdo + + {demo && ( +
+ Demonstração · Dados fictícios · Sem operações reais +
+ )} +
+
+ + + SGC + + + + Sistema de Gestão de Compras + + + Compras com controlo e transparência + + + + {user && !demo ? ( + + + + + + + + + ) : ( + + )} +
+ +
+
+ {children} +
+
+
+ + + SGC + {' '} + · Sistema de Gestão de Compras + + + {demo + ? 'Ambiente de demonstração' + : 'Espaço de trabalho da sua empresa'} + • + + {demo ? 'Página inicial' : 'A minha conta'} + + +
+
+
+ ); +} diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index adbda71..a84fb72 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -41,7 +41,7 @@ export function UserMenuContent({ user }: Props) { onClick={cleanup} > - Settings + A minha conta @@ -55,7 +55,7 @@ export function UserMenuContent({ user }: Props) { data-test="logout-button" > - Log out + Terminar sessão diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx index d6f5c38..1e5eb6c 100644 --- a/resources/js/layouts/app/app-sidebar-layout.tsx +++ b/resources/js/layouts/app/app-sidebar-layout.tsx @@ -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 ( - - - - - {children} - - + + {breadcrumbs.length > 0 && ( +
+ +
+ )} + {children} +
); } diff --git a/resources/js/pages/preview/index.tsx b/resources/js/pages/preview/index.tsx index ebe13d0..e423918 100644 --- a/resources/js/pages/preview/index.tsx +++ b/resources/js/pages/preview/index.tsx @@ -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(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({ <>
- - {menu && ( - - - Consulta visual / - - {title} - - -
- - -
+
@@ -932,11 +834,7 @@ export default function Preview({ )} -
- SGC · Consulta pública de exemplos · Os processos - reais permanecem no acesso autenticado. -
-
+ + 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 ( + <> + +
+
+
+
+

+ O seu espaço de trabalho +

+

+ As compras, numa só vista. +

+

+ Veja o que precisa da sua atenção e avance para + a próxima tarefa. +

+
+ +
+
+ {[ + { + 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) => ( +
+
+ {card.label} + +
+

+ {card.value} +

+

+ {card.detail} +

+
+ ))} +
+
+
+
+
+

+ Requisições recentes +

+

+ Exemplos para avaliar a organização do + ecrã +

+
+ + + + + + {[ + 'Todos os estados', + 'Em aprovação', + 'Aprovada', + 'Rascunho', + ].map((status) => ( + + setFilter(status) + } + > + {status} + {filter === status && ( + + )} + + ))} + + +
+
+ {visible.map((request) => ( + +
+

+ {request.number} +

+

+ {request.description} +

+

+ {request.department} +

+
+
+

+ {request.amount} +

+ + {request.status} + +
+ + ))} +
+ + Ver todas as requisições + + +
+ +
+
+
+ + ); +} diff --git a/resources/js/pages/purchase-controls/index.tsx b/resources/js/pages/purchase-controls/index.tsx new file mode 100644 index 0000000..b659d63 --- /dev/null +++ b/resources/js/pages/purchase-controls/index.tsx @@ -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 = { + pending: 'Pendente', + approved: 'Aprovado', + rejected: 'Rejeitado', + cancelled: 'Cancelado', +}; + +function ReasonForm({ + url, + decisions = false, +}: { + url: string; + decisions?: boolean; +}) { + const form = useForm({ reason: '', decision: 'approved' }); + return ( +
{ + event.preventDefault(); + form.post(url, { preserveScroll: true }); + }} + className="mt-3 space-y-3" + > + + {decisions && ( + + )} + {Object.entries(form.errors).map(([key, message]) => ( +

+ {message} +

+ ))} + +
+ ); +} + +function PolicyForm({ previousId }: { previousId: number }) { + const form = useForm({ + previous_id: previousId, + mode: 'unissued_award', + enabled: false, + valid_from: '', + valid_until: '', + reason: '', + }); + return ( +
{ + event.preventDefault(); + form.post('/revisao-compras/politicas', { + preserveScroll: true, + }); + }} + className="grid gap-4 sm:grid-cols-2" + > + + + + + {Object.entries(form.errors).map(([key, message]) => ( +

+ {message} +

+ ))} + +
+ ); +} + +function RevisionForm({ id }: { id: number }) { + const form = useForm({ needed_by: '', justification: '' }); + return ( +
{ + event.preventDefault(); + form.post(`/cancelamentos/${id}/revisao`); + }} + className="mt-3 grid gap-3" + > + + + {Object.entries(form.errors).map(([key, message]) => ( +

+ {message} +

+ ))} + +
+ ); +} + +export default function PurchaseControls({ + role, + actorId, + policies, + reviews, + awards, + cancellations, + sources, +}: Props) { + return ( + <> + +
+ +

+ 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. +

+ {role === 'admin' && ( + + + + )} + {policies.map((policy) => ( + +

+ Assinaturas:{' '} + { + reviews.filter( + (review) => + review.purchase_control_policy_id === + policy.id && + review.decision === 'approved', + ).length + } + /2 +

+ {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, + ) && ( + + )} +
+ ))} + {awards.map((award) => { + const cancellation = cancellations.find( + (entry) => entry.purchase_award_id === award.id, + ); + return ( + + + Consultar processo + + {cancellation ? ( + <> +

+ Cancelamento{' '} + {states[cancellation.status]}:{' '} + {cancellation.reason} +

+ {cancellation.review_reason && ( +

+ Decisão:{' '} + {cancellation.review_reason} +

+ )} + {role === 'finance' && + cancellation.status === 'pending' && + cancellation.created_by !== actorId && ( + + )} + + ) : ( + role === 'procurement' && ( + + ) + )} +
+ ); + })} + + {sources.length === 0 && ( +

+ Sem cancelamentos aprovados para revisão. +

+ )} + {sources.map((source) => ( +
+

{source.number}

+ {source.revision_id ? ( + + Abrir nova requisição + + ) : ( + + )} +
+ ))} +
+
+ + ); +} diff --git a/resources/js/pages/purchases/award.tsx b/resources/js/pages/purchases/award.tsx index ab73c6b..0df1b35 100644 --- a/resources/js/pages/purchases/award.tsx +++ b/resources/js/pages/purchases/award.tsx @@ -247,7 +247,7 @@ export default function Award({ {award && (

{award.justification} diff --git a/resources/js/pages/purchases/index.tsx b/resources/js/pages/purchases/index.tsx index ed6ed3f..1ad4fd2 100644 --- a/resources/js/pages/purchases/index.tsx +++ b/resources/js/pages/purchases/index.tsx @@ -96,7 +96,9 @@ export default function Orders({ ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' - : 'Rejeitada'} + : award.status === 'cancelled' + ? 'Cancelada' + : 'Rejeitada'}

{money(award.total_minor)} diff --git a/resources/js/types/procurement.ts b/resources/js/types/procurement.ts index 894a5f1..86ed19c 100644 --- a/resources/js/types/procurement.ts +++ b/resources/js/types/procurement.ts @@ -74,6 +74,7 @@ export const statusLabels: Record = { pending: 'Em aprovação', approved: 'Aprovada', rejected: 'Rejeitada', + cancelled: 'Cancelada', }; export const priorityLabels: Record = { normal: 'Normal', diff --git a/routes/preview.php b/routes/preview.php index 70ba7e2..e453763 100644 --- a/routes/preview.php +++ b/routes/preview.php @@ -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']; diff --git a/routes/web.php b/routes/web.php index 7d8be91..f6b5fc8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/PurchaseAwardOrderTest.php b/tests/Feature/PurchaseAwardOrderTest.php index 95b9b71..c19735b 100644 --- a/tests/Feature/PurchaseAwardOrderTest.php +++ b/tests/Feature/PurchaseAwardOrderTest.php @@ -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); +}); diff --git a/tests/Support/apply-purchase-controls-migration.php b/tests/Support/apply-purchase-controls-migration.php new file mode 100644 index 0000000..1073ef6 --- /dev/null +++ b/tests/Support/apply-purchase-controls-migration.php @@ -0,0 +1,111 @@ +make(Kernel::class)->bootstrap(); +$connection = DB::connection(); +if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) { + throw new LogicException('Aplicação recusada fora de compras_dev/local.'); +} +$pdo = $connection->getPdo(); +$migration = '2026_10_28_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)); + } +}