From d020be53edd4458091e095c21aaea9fc94e34c45 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 20:28:15 +0200 Subject: [PATCH] =?UTF-8?q?Implementa=20admiss=C3=A3o=20formal=20por=20pol?= =?UTF-8?q?=C3=ADtica=20e=20adop=C3=A7=C3=A3o=20do=20processo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/FormalAdmissionController.php | 107 +++++ .../Controllers/PurchaseProcessController.php | 5 +- app/Models/AdmissionAdoption.php | 22 + app/Models/AdmissionDecision.php | 22 + app/Models/AdmissionPolicy.php | 22 + app/Models/AdmissionReview.php | 21 + app/Models/PurchaseAward.php | 3 +- app/Services/ConflictGuard.php | 1 + app/Services/FormalAdmission.php | 247 +++++++++++ app/Services/ProcurementReviewReport.php | 1 + app/Services/ProposalComparison.php | 4 + app/Services/PurchaseWorkflow.php | 6 + ...6_10_28_000019_create_formal_admission.php | 68 +++ docs/admissao-concorrencia-postgresql.json | 51 +++ docs/admissao-migracao-000019.json | 16 + docs/proposta-admissao-exclusao.md | 42 +- docs/validacao-tecnica.md | 88 ++++ resources/js/components/sgc-shell.tsx | 6 + .../js/pages/purchases/admission-policy.tsx | 186 ++++++++ resources/js/pages/purchases/admission.tsx | 416 ++++++++++++++++++ resources/js/pages/purchases/process.tsx | 3 + .../js/pages/purchases/qualification.tsx | 18 +- .../js/pages/purchases/review-report.tsx | 28 +- resources/js/pages/quotations/show.tsx | 6 + resources/views/app.blade.php | 2 +- routes/web.php | 7 + tests/Concurrency/admission-worker.php | 34 ++ tests/Concurrency/run-admission.php | 136 ++++++ tests/Feature/FormalAdmissionTest.php | 168 +++++++ tests/Feature/PurchaseAwardOrderTest.php | 76 ++++ tests/Feature/PurchaseProcessTest.php | 2 +- tests/Support/AdmissionFixture.php | 31 ++ tests/Support/apply-admission-migration.php | 111 +++++ 33 files changed, 1926 insertions(+), 30 deletions(-) create mode 100644 app/Http/Controllers/FormalAdmissionController.php create mode 100644 app/Models/AdmissionAdoption.php create mode 100644 app/Models/AdmissionDecision.php create mode 100644 app/Models/AdmissionPolicy.php create mode 100644 app/Models/AdmissionReview.php create mode 100644 app/Services/FormalAdmission.php create mode 100644 database/migrations/2026_10_28_000019_create_formal_admission.php create mode 100644 docs/admissao-concorrencia-postgresql.json create mode 100644 docs/admissao-migracao-000019.json create mode 100644 resources/js/pages/purchases/admission-policy.tsx create mode 100644 resources/js/pages/purchases/admission.tsx create mode 100644 tests/Concurrency/admission-worker.php create mode 100644 tests/Concurrency/run-admission.php create mode 100644 tests/Feature/FormalAdmissionTest.php create mode 100644 tests/Support/AdmissionFixture.php create mode 100644 tests/Support/apply-admission-migration.php diff --git a/app/Http/Controllers/FormalAdmissionController.php b/app/Http/Controllers/FormalAdmissionController.php new file mode 100644 index 0000000..bf6a038 --- /dev/null +++ b/app/Http/Controllers/FormalAdmissionController.php @@ -0,0 +1,107 @@ +user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); + + return Requisition::query()->visibleTo($request->user())->findOrFail($id); + } + + public function policy(Request $request): Response + { + abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403); + Inertia::encryptHistory(); + $response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function configure(Request $request, FormalAdmission $workflow): RedirectResponse + { + $data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']); + $data['enabled'] = (bool) $data['enabled']; + $workflow->policy($request->user(), $data); + + return to_route('admissions.policy'); + } + + public function show(Request $request, Requisition $requisition, FormalAdmission $workflow, ProposalOpeningWorkflow $opening): Response + { + $requisition = $this->access($request, $requisition->id); + $filter = $request->validate(['version' => 'nullable|integer|min:1']); + $adoption = $workflow->adoption($requisition); + $selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption; + $current = $selected && $selected->id === $adoption?->id; + $closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists(); + $open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition); + $decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get(); + $latestIds = $decisions->unique('quotation_id')->pluck('id'); + $proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) { + $basis = $workflow->basis($requisition, $quote['id']); + + return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0]; + }); + $history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString(); + $history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) { + $basis = $workflow->basis($requisition, $decision->quotation_id); + $fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash; + + return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh, + 'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(), + 'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()]; + }); + Inertia::encryptHistory(); + $response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function adopt(Request $request, Requisition $requisition, FormalAdmission $workflow): RedirectResponse + { + $this->access($request, $requisition->id); + $data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']); + $workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']); + + return to_route('admissions.show', $requisition); + } + + public function propose(Request $request, Requisition $requisition, FormalAdmission $workflow): RedirectResponse + { + $this->access($request, $requisition->id); + $data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'outcome' => 'required|in:admit,exclude,clarify', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']); + $workflow->propose($request->user(), $requisition, $data); + + return to_route('admissions.show', $requisition); + } + + public function review(Request $request, AdmissionDecision $decision, FormalAdmission $workflow): RedirectResponse + { + $adoption = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id); + $this->access($request, $adoption->requisition_id); + $data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']); + $workflow->review($request->user(), $decision, $data['verdict'], $data['reason']); + + return to_route('admissions.show', $adoption->requisition_id); + } +} diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php index b8e7443..3fd8d16 100644 --- a/app/Http/Controllers/PurchaseProcessController.php +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -39,6 +39,7 @@ class PurchaseProcessController extends Controller $documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]]; if ($purchases) { $documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio']; + $documents[] = ['label' => 'Regime, decisões e conferências', 'kind' => 'Admissão de propostas', 'href' => $base.'/admissao']; } $qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null; if ($qualification) { @@ -111,7 +112,7 @@ class PurchaseProcessController extends Controller } $actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created']; if ($purchases) { - $actions = [...$actions, 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected']; + $actions = [...$actions, 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected']; } if ($inventory) { $actions[] = 'receipt.confirmed'; @@ -126,7 +127,7 @@ class PurchaseProcessController extends Controller 'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related, 'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null, 'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']), - 'future' => ['Decisão formal de admissão ou exclusão', 'Relatório de recomendação em etapas'], + 'future' => ['Relatório de recomendação em etapas'], ])->toResponse($request); $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); diff --git a/app/Models/AdmissionAdoption.php b/app/Models/AdmissionAdoption.php new file mode 100644 index 0000000..12f20f5 --- /dev/null +++ b/app/Models/AdmissionAdoption.php @@ -0,0 +1,22 @@ +,required_count:int,policy_revision:int} $snapshot */ +class AdmissionAdoption extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array']; + } +} diff --git a/app/Models/AdmissionDecision.php b/app/Models/AdmissionDecision.php new file mode 100644 index 0000000..adcaeb8 --- /dev/null +++ b/app/Models/AdmissionDecision.php @@ -0,0 +1,22 @@ + $snapshot */ +class AdmissionDecision extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array']; + } +} diff --git a/app/Models/AdmissionPolicy.php b/app/Models/AdmissionPolicy.php new file mode 100644 index 0000000..4e8c1f6 --- /dev/null +++ b/app/Models/AdmissionPolicy.php @@ -0,0 +1,22 @@ + $roles */ +class AdmissionPolicy extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['roles' => 'array', 'enabled' => 'boolean']; + } +} diff --git a/app/Models/AdmissionReview.php b/app/Models/AdmissionReview.php new file mode 100644 index 0000000..af9167e --- /dev/null +++ b/app/Models/AdmissionReview.php @@ -0,0 +1,21 @@ + 'array']; + } +} diff --git a/app/Models/PurchaseAward.php b/app/Models/PurchaseAward.php index e797c8c..8d12ebe 100644 --- a/app/Models/PurchaseAward.php +++ b/app/Models/PurchaseAward.php @@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property array $approval_snapshot * @property list> $decision_matrix * @property array|null $investment_snapshot + * @property array|null $admission_snapshot */ class PurchaseAward extends Model { @@ -26,7 +27,7 @@ class PurchaseAward extends Model protected function casts(): array { - return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d']; + return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d']; } /** @return HasMany */ diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php index b71d144..dc52440 100644 --- a/app/Services/ConflictGuard.php +++ b/app/Services/ConflictGuard.php @@ -86,6 +86,7 @@ class ConflictGuard 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'opening.recorded', 'qualification.defined', 'qualification.assessed', + 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'equipment.technical_approved', 'equipment.technical_rejected', 'invoice.created', 'invoice.validated', 'payment.registered', 'payment.confirmed', 'payment.rejected', diff --git a/app/Services/FormalAdmission.php b/app/Services/FormalAdmission.php new file mode 100644 index 0000000..4240726 --- /dev/null +++ b/app/Services/FormalAdmission.php @@ -0,0 +1,247 @@ + $message]); + } + + public function adoption(Requisition $request): ?AdmissionAdoption + { + return AdmissionAdoption::query()->where('requisition_id', $request->id)->latest('revision')->first(); + } + + private function process(User $actor, int $id): Requisition + { + $request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id); + if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) { + $this->invalid('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.'); + } + app(ConflictGuard::class)->assertParticipation($actor, $request); + $quotes = Quotation::query()->where('requisition_id', $id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']); + Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get(); + QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get(); + + return $request; + } + + /** @param array $data */ + public function policy(User $actor, array $data): AdmissionPolicy + { + if (! is_bool($data['enabled']) || ! is_array($data['roles']) || $data['roles'] === [] || count(array_unique($data['roles'])) !== count($data['roles']) || array_diff($data['roles'], ['finance', 'department_head']) !== [] || filter_var($data['required_count'], FILTER_VALIDATE_INT) === false || $data['required_count'] < 1 || $data['required_count'] > 50 || mb_strlen(trim($data['reason'])) < 10) { + $this->invalid('Defina expressamente perfis, quantidade de conferentes e fundamento da política.'); + } + + return DB::transaction(function () use ($actor, $data) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]); + $latest = AdmissionPolicy::query()->latest('revision')->first(); + if ((int) $data['previous_id'] !== ($latest->id ?? 0)) { + $this->invalid('A política mudou. Actualize antes de criar outra versão.'); + } + $policy = AdmissionPolicy::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'enabled' => $data['enabled'], 'roles' => array_values($data['roles']), 'required_count' => $data['required_count'], 'reason' => $data['reason'], 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'admission.policy', null, null, $policy->toArray()); + + return $policy; + }); + } + + public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): AdmissionAdoption + { + return DB::transaction(function () use ($actor, $request, $policyId, $previousId, $reason) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]); + $request = $this->process($actor, $request->id); + $previous = $this->adoption($request); + $policy = AdmissionPolicy::query()->latest('revision')->first(); + if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId) { + $this->invalid('Seleccione a política activa e a adopção actual. Não se altera um regime implicitamente.'); + } + if ($previous?->admission_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) { + $this->invalid('A adopção exige outra versão de política e fundamento próprio.'); + } + $adoption = AdmissionAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'admission_policy_id' => $policy->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'snapshot' => ['roles' => $policy->roles, 'required_count' => $policy->required_count, 'policy_revision' => $policy->revision], 'reason' => $reason, 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'admission.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]); + + return $adoption; + }); + } + + /** @return array{set_id:int,payload:array,hash:string,clear:bool}|null */ + public function basis(Requisition $request, int $quoteId): ?array + { + $set = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first(); + if (! $set) { + return null; + } + $opening = app(ProposalOpeningWorkflow::class); + $quotes = $opening->proposals($request); + if ($set->basis_hash !== $opening->basis($quotes)) { + return null; + } + $quote = collect($quotes)->firstWhere('id', $quoteId); + if (! $quote) { + return null; + } + $ids = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('quotation_id', $quoteId)->selectRaw('MAX(id)')->groupBy('analyst_id'); + $records = QualificationAssessment::query()->whereIn('id', $ids)->orderBy('analyst_id')->get(); + $clear = $records->isNotEmpty(); + foreach ($set->criteria as $criterion) { + $results = []; + foreach ($records as $record) { + $answer = collect($record->answers)->firstWhere('criterion_id', $criterion['id']); + if (! $answer || ! in_array($answer['result'], ['met', 'not_met', 'not_applicable'], true) || ($criterion['required'] && $answer['result'] !== 'met')) { + $clear = false; + } + if ($answer) { + $results[] = $answer['result']; + } + } + if (count(array_unique($results)) !== 1) { + $clear = false; + } + } + $payload = ['set_id' => $set->id, 'criteria' => $set->criteria, 'quotes_hash' => $set->basis_hash, 'quote' => $quote, 'analyses' => $records->map(fn ($record) => $record->only(['id', 'analyst_id', 'revision', 'answers', 'snapshot']))->all()]; + + return ['set_id' => $set->id, 'payload' => $payload, 'hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'clear' => $clear]; + } + + public function status(AdmissionDecision $decision, AdmissionAdoption $adoption): string + { + $reviews = AdmissionReview::query()->where('admission_decision_id', $decision->id)->get(); + if ($reviews->contains('verdict', 'returned')) { + return 'returned'; + } + + return $reviews->where('verdict', 'approved')->count() >= $adoption->snapshot['required_count'] ? 'confirmed' : 'pending'; + } + + /** @param array $data */ + public function propose(User $actor, Requisition $request, array $data): AdmissionDecision + { + if (! in_array($data['outcome'], ['admit', 'exclude', 'clarify'], true) || mb_strlen(trim($data['reason'])) < 10) { + $this->invalid('Seleccione uma decisão e fundamente a tentativa.'); + } + + return DB::transaction(function () use ($actor, $request, $data) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]); + $request = $this->process($actor, $request->id); + abort_if($actor->id === $request->requester_id, 403); + $adoption = $this->adoption($request); + if (! $adoption) { + $this->invalid('O processo ainda não adoptou expressamente o regime.'); + } + $quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail(); + app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor); + $basis = $this->basis($request, $quote->id); + if (! $basis || $basis['hash'] !== $data['basis_hash']) { + $this->invalid('Confira requisitos e análises actuais antes de propor uma decisão.'); + } + if ($data['outcome'] === 'admit' && ! $basis['clear']) { + $this->invalid('A admissão exige análise sem pendências, divergências ou requisitos obrigatórios não resolvidos.'); + } + $previous = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quote->id)->latest('revision')->first(); + if ((int) $data['previous_id'] !== ($previous->id ?? 0)) { + $this->invalid('A tentativa mudou. Actualize a página para preservar o antecedente correcto.'); + } + $decision = AdmissionDecision::query()->create(['tenant_id' => $actor->tenant_id, 'admission_adoption_id' => $adoption->id, 'qualification_set_id' => $basis['set_id'], 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'outcome' => $data['outcome'], 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name'])], 'reason' => $data['reason'], 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'admission.proposed', $request, null, ['decision' => $decision->toArray(), 'previous_id' => $previous?->id]); + + return $decision; + }); + } + + public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool + { + return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id); + } + + public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void + { + if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) { + $this->invalid('Seleccione uma conferência válida e fundamente-a.'); + } + DB::transaction(function () use ($actor, $decision, $verdict, $reason) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]); + $original = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id); + $request = $this->process($actor, $original->requisition_id); + $adoption = $this->adoption($request); + abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403); + $latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first(); + $basis = $this->basis($request, $decision->quotation_id); + if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) { + $this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.'); + } + $existing = AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->first(); + if ($existing) { + if ($existing->verdict !== $verdict || $existing->reason !== $reason) { + $this->invalid('A conferência anterior é imutável.'); + } + + return; + } + if ($this->status($decision, $adoption) !== 'pending') { + $this->invalid('Esta tentativa já foi concluída ou devolvida.'); + } + if ($verdict === 'approved') { + foreach (AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('verdict', 'approved')->get() as $review) { + $reviewer = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($review->reviewed_by); + if (! $reviewer || ! $this->eligible($reviewer, $request, $adoption, $decision)) { + $this->invalid('Um conferente anterior deixou de ser elegível antes da conclusão. Devolva e prepare outra tentativa.'); + } + } + } + $review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray()); + }); + } + + /** @return array|null */ + public function selection(Requisition $request, int $quoteId): ?array + { + $adoption = $this->adoption($request); + if (! $adoption) { + return null; + } + $decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quoteId)->latest('revision')->first(); + $basis = $this->basis($request, $quoteId); + if (! $decision || $decision->outcome !== 'admit' || ! $basis || ! $basis['clear'] || $basis['hash'] !== $decision->basis_hash || $this->status($decision, $adoption) !== 'confirmed') { + $this->invalid('A proposta exige admissão confirmada na base actual do regime adoptado.'); + } + + return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->admission_policy_id, 'decision_id' => $decision->id, 'qualification_set_id' => $decision->qualification_set_id, 'basis_hash' => $decision->basis_hash, 'review_ids' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->pluck('id')->all()]; + } + + public function admitted(Requisition $request, int $quoteId): bool + { + try { + $this->selection($request, $quoteId); + + return true; + } catch (ValidationException) { + return false; + } + } +} diff --git a/app/Services/ProcurementReviewReport.php b/app/Services/ProcurementReviewReport.php index e6b2e7c..8b615fd 100644 --- a/app/Services/ProcurementReviewReport.php +++ b/app/Services/ProcurementReviewReport.php @@ -66,6 +66,7 @@ class ProcurementReviewReport 'requisition' => [...$request->only(['id', 'number', 'status', 'justification']), 'department' => $request->department?->name, 'requester' => $request->requester?->name], 'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows], 'qualification' => $qualification, + 'admission' => app(FormalAdmission::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']), 'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null, 'award' => $award?->only(['id', 'status', 'total_minor']), 'order' => $order?->only(['id', 'number', 'currency', 'total_minor']), diff --git a/app/Services/ProposalComparison.php b/app/Services/ProposalComparison.php index 7253444..1278305 100644 --- a/app/Services/ProposalComparison.php +++ b/app/Services/ProposalComparison.php @@ -21,9 +21,13 @@ class ProposalComparison ->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get(); $guard = app(ConflictGuard::class); $referenceWeights = $quotes->first()?->weights; + $adopted = app(FormalAdmission::class)->adoption($requisition) !== null; $rows = []; foreach ($quotes as $quote) { $reasons = []; + if ($adopted && ! app(FormalAdmission::class)->admitted($requisition, $quote->id)) { + $reasons[] = 'O regime adoptado exige admissão confirmada na base actual.'; + } if (! $quote->supplier?->eligible() || $quote->valid_until < today()) { $reasons[] = 'Fornecedor não elegível ou proposta expirada.'; } diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php index d691141..ec96e31 100644 --- a/app/Services/PurchaseWorkflow.php +++ b/app/Services/PurchaseWorkflow.php @@ -38,6 +38,7 @@ class PurchaseWorkflow $this->sourceApproved($requisition, $rules); $quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']); $result = $this->checkComparison($user, $requisition, $quote); + $admission = app(FormalAdmission::class)->selection($requisition, $quote->id); $investment = app(InvestmentWorkflow::class)->check($requisition, $quote); app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor); $award = PurchaseAward::query()->create([ @@ -47,6 +48,7 @@ class PurchaseWorkflow 'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor, 'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'], 'investment_snapshot' => $investment, + 'admission_snapshot' => $admission, ]); $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists()); foreach ($roles as $position => $role) { @@ -149,6 +151,7 @@ class PurchaseWorkflow 'quotation' => [...$quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days']), 'valid_until' => $validUntil], 'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate], 'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot, + 'admission' => $award->admission_snapshot, 'estimate_approvals' => $requisition->steps()->get()->toArray(), 'final_approvals' => $award->approvals()->get()->toArray(), 'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines, @@ -180,6 +183,9 @@ class PurchaseWorkflow throw ValidationException::withMessages(['investment' => 'A regra de investimento ou a base técnica/orçamental mudou. As aprovações anteriores não autorizam uma emissão com outra base.']); } $result = $this->checkComparison($user, $requisition, $quote); + if (app(FormalAdmission::class)->selection($requisition, $quote->id) !== $award->admission_snapshot) { + throw ValidationException::withMessages(['admission' => 'A base de admissão já não corresponde à adjudicação. Não se emite com outra base.']); + } if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules || $quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) { throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']); diff --git a/database/migrations/2026_10_28_000019_create_formal_admission.php b/database/migrations/2026_10_28_000019_create_formal_admission.php new file mode 100644 index 0000000..eeee3cc --- /dev/null +++ b/database/migrations/2026_10_28_000019_create_formal_admission.php @@ -0,0 +1,68 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->boolean('enabled'); + $table->json('roles'); + $table->unsignedInteger('required_count'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['tenant_id', 'revision']); + }); + Schema::create('admission_adoptions', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('requisition_id')->constrained()->restrictOnDelete(); + $table->foreignId('admission_policy_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->json('snapshot'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['requisition_id', 'revision']); + }); + Schema::create('admission_decisions', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('admission_adoption_id')->constrained()->restrictOnDelete(); + $table->foreignId('qualification_set_id')->constrained()->restrictOnDelete(); + $table->foreignId('quotation_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->string('outcome'); + $table->string('basis_hash', 64); + $table->json('snapshot'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['admission_adoption_id', 'quotation_id', 'revision'], 'admission_decision_version_unique'); + }); + Schema::create('admission_reviews', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('admission_decision_id')->constrained()->restrictOnDelete(); + $table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete(); + $table->string('verdict'); + $table->text('reason'); + $table->json('snapshot'); + $table->timestamp('created_at'); + $table->unique(['admission_decision_id', 'reviewed_by']); + }); + Schema::table('purchase_awards', fn (Blueprint $table) => $table->json('admission_snapshot')->nullable()); + } + + public function down(): void + { + throw new LogicException('Preservar políticas, adopções, decisões e conferências de admissão.'); + } +}; diff --git a/docs/admissao-concorrencia-postgresql.json b/docs/admissao-concorrencia-postgresql.json new file mode 100644 index 0000000..1508d51 --- /dev/null +++ b/docs/admissao-concorrencia-postgresql.json @@ -0,0 +1,51 @@ +{ + "tested_at_utc": "2026-10-28T18:21:31+00:00", + "separate_database": true, + "transport": "Independent PHP service transactions and PostgreSQL connections", + "scenarios": [ + { + "name": "same_person", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 1, + "state": "pending" + }, + { + "name": "distinct_people", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 2, + "state": "confirmed" + }, + { + "name": "opposing_reviews", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 1, + "state": "pending" + }, + { + "name": "revoked_role", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending" + }, + { + "name": "changed_basis", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending" + }, + { + "name": "new_adoption", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending" + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/admissao-migracao-000019.json b/docs/admissao-migracao-000019.json new file mode 100644 index 0000000..dcd006a --- /dev/null +++ b/docs/admissao-migracao-000019.json @@ -0,0 +1,16 @@ +{ + "verified_at_utc": "2026-10-28T18:23:41+00:00", + "migration": "2026_10_28_000019_create_formal_admission", + "batch": 20, + "backup_created": true, + "backup_bytes": 306342, + "restore_verified": true, + "restored_tables": 57, + "restore_database_dropped": true, + "business_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "admission_policies_count": 0, + "admission_adoptions_count": 0, + "operational_validation": false +} diff --git a/docs/proposta-admissao-exclusao.md b/docs/proposta-admissao-exclusao.md index 010fec5..432565f 100644 --- a/docs/proposta-admissao-exclusao.md +++ b/docs/proposta-admissao-exclusao.md @@ -1,10 +1,10 @@ # Proposta de âmbito para admissão e exclusão de propostas -Estado: proposta para validação. Não implementa decisões formais na base de -negócio e não activa regras de exclusão ou adjudicação. Data: 28 de Outubro -de 2026. +Estado: âmbito validado pelo utilizador e implementado em 28 de Outubro de 2026. O circuito real é opcional por empresa e por processo; nenhuma política +real foi criada ou activada durante esta entrega. A demonstração pública +continua separada dos registos autenticados. -## Resultado preparado para revisão +## Demonstração preparada antes da validação `/proposta/admissao` demonstra configuração de conferentes e quantidade de confirmações, proposta manual por Compras, conferência fundamentada por @@ -18,7 +18,7 @@ um quórum imposto às empresas. Nenhum valor financeiro dos exemplos locais é utilizado. Os casos A/B/C são exemplos de análise sem pendências, requisito obrigatório não cumprido e pendência/divergência, respectivamente. -## Primeiro circuito proposto +## Circuito inicial validado 1. Administração da empresa configura e activa expressamente uma política versionada: perfis de conferência e quantidade de pessoas distintas exigida. @@ -57,8 +57,11 @@ obrigatório não cumprido e pendência/divergência, respectivamente. aplicável podem entrar na selecção de adjudicação. Uma exclusão ou pedido de esclarecimento não substitui admissão. Admissão não autoriza compra: mantêm-se pontuação, conflitos, orçamento e aprovação do valor final. - A implementação deverá guardar a base de admissão na adjudicação e - verificar coerência antes da emissão. Não se alteram ordens já emitidas. + A implementação guarda a base de admissão na adjudicação e na ordem e + verifica coerência antes da emissão. Não se alteram ordens já emitidas. + Mantêm-se o mínimo existente de três fornecedores elegíveis e duas + avaliações por proposta. Neste regime, os três fornecedores precisam + também de propostas admitidas; não se implementou uma dispensa do mínimo. Estas são escolhas propostas para o primeiro circuito suportado, não normas legais ou obrigações universais deduzidas dos modelos históricos. Cada empresa @@ -76,28 +79,35 @@ validação solicitada é do âmbito do produto; não publica políticas reais. decisão de admissão/exclusão. - Cancelamento de ordens, devoluções, estornos ou reversões financeiras. -## Verificações previstas para a implementação +## Verificações da implementação Identidade e segregação no servidor; política/base preservadas; requisitos pendentes ou obrigatórios não resolvidos; conferências repetidas; devolução; perfil/departamento revogado; empresas diferentes; tentativas desactualizadas; admissão versus adjudicação/emissão; rollback de auditoria e concorrência -PostgreSQL. A demonstração não substitui estes testes da futura implementação. +PostgreSQL. Os resultados efectivos estão em `docs/validacao-tecnica.md`; +a demonstração não substitui os testes da implementação autenticada. -## Decisão solicitada +## Validação recebida e activação -Validar o circuito inicial: **decisão manual por Compras, confirmações +O utilizador validou o circuito inicial: **decisão manual por Compras, confirmações independentes em quantidade/perfis definidos pela empresa e integração na adjudicação apenas por adopção explícita do regime**, sem admitir propostas com pendências ou requisitos obrigatórios não resolvidos neste primeiro âmbito. -Pausa-se a implementação com dados reais para esta validação, conforme a -orientação de continuidade: «definir com a empresa o próximo âmbito pendente -antes de o implementar», em `docs/orientacoes-continuidade.md`. A autorização -anterior da qualificação era documental e não activava admissão/exclusão. +Esta validação autoriza a implementação do produto, sem publicar uma política +por conta das aderentes. A Administração configura `/politica-admissao` com +quantidade e perfis expressos. Compras adopta essa versão em +`/requisicoes/{requisition}/admissao` antes de adjudicar. Sem adopção, mantém-se +o circuito anterior. A desactivação geral impede novas adopções, preservando +as versões já adoptadas. Uma nova tentativa substitui a anterior para efeitos +de elegibilidade, sem apagar o histórico. O limite de 50 confirmações no +formulário é técnico, não um quórum empresarial; assegurar pessoas elegíveis +em número suficiente é responsabilidade da configuração da empresa. + As alterações locais dos três documentos de continuidade foram preservadas. -## Verificações da preparação +## Histórico: verificações da preparação da demonstração TypeScript, build, Pint e lint/formatação dos ficheiros desta proposta passaram. Os três testes existentes de consulta pública passaram, com 39 diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index 89d1088..d8ecfdd 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -1,5 +1,93 @@ # Validação técnica do SGC — 28 de Outubro de 2026 +## Incremento: admissão e exclusão por adopção expressa + +O utilizador validou `docs/proposta-admissao-exclusao.md`. A Administração da +empresa pode registar versões da política em `/politica-admissao`, indicando +expressamente perfis de conferência (Finanças e/ou chefia do departamento), +quantidade de pessoas distintas e fundamento. Não há quantidade nem perfis +pré-seleccionados. O máximo de 50 no formulário é um limite técnico, não uma +regra empresarial. A disponibilidade de pessoas elegíveis deve acompanhar a +configuração da empresa. + +Compras adopta a política activa em `/requisicoes/{requisition}/admissao`, +num pedido aprovado e antes de adjudicar. Sem adopção, mantém-se o circuito +existente. A política adoptada fica preservada; desactivar a política geral +impede novas adopções e não reescreve processos existentes. Aplicar outra +versão exige nova adopção expressa e novas decisões/conferências. Não há +regresso implícito ao circuito sem admissão. + +Compras propõe admitir, excluir ou pedir esclarecimento interno por proposta, +com fundamento e base preservada de requisitos, propostas e últimas análises +de cada analista. O proponente não pode ser o solicitante. Admitir exige +análise existente sem pendências, respostas em falta ou divergências e com +todos os requisitos obrigatórios cumpridos. Um requisito facultativo +uniformemente não cumprido ou não aplicável não constitui, por si, uma +dispensa de requisito obrigatório. + +Os conferentes são pessoas distintas, da mesma empresa, com perfil e +departamento ainda elegíveis, diferentes do solicitante e do proponente, +sem conflito de interesses. Uma conferência repetida com conteúdo idêntico +é idempotente e conta uma vez; outra resposta não reescreve a assinatura. +Uma devolução termina a tentativa sem excluir automaticamente o concorrente. +Corrigir cria nova tentativa, sem copiar confirmações. Antes de concluir o +quórum, as confirmações anteriores são revalidadas; uma decisão concluída +conserva a elegibilidade verificada na sua data. Uma alteração de base, +tentativa ou adopção invalida a utilização de conferências antigas. + +A comparação usa o cálculo ponderado existente. Nos processos que adoptaram +o regime, só propostas com admissão confirmada e actual são elegíveis. +Mantêm-se os mínimos existentes de três fornecedores elegíveis e duas +avaliações por proposta, sem dispensas novas. A base da admissão fica na +adjudicação e na ordem; é revalidada antes de aprovar/emitir. Não se aplicam +regras retroactivas a adjudicações/ordens existentes nem se reemitem ordens +já emitidas. A admissão, sozinha, não movimenta orçamento, facturas ou +pagamentos. O relatório de conferência identifica o regime adoptado; o +processo completo liga às decisões e apresenta os eventos de auditoria. + +As novas páginas usam o cabeçalho, rodapé e navegação partilhados. O título +HTML inicial foi corrigido de `ProcuraFlow` para **Sistema de Gestão de +Compras (SGC)**, sem modificar `.env`. O nome visível das novas páginas +segue a mesma identificação. + +Migração `000019`, lote **20**, aplicada em `compras_dev` após backup privado +de **306342 bytes**, verificado por restauro numa base separada e eliminada +após a conferência. Foram verificadas **57 tabelas anteriores**, registos de +negócio por projecção das colunas originais, restrições existentes, histórico +de migrações e integridade de `.env`. A migração acrescenta quatro tabelas e +um campo JSON anulável na adjudicação. As quatro tabelas reais estão vazias; +nenhuma política foi publicada por conta das aderentes. O relatório sanitizado +está em `docs/admissao-migracao-000019.json`; o backup/manifesto permanecem +privados, fora do Git. O rollback automático destrutivo está impedido. + +Os seis cenários em `docs/admissao-concorrencia-postgresql.json` passaram +com ligações/processos PHP independentes e dois bloqueios simultâneos +observados no PostgreSQL: mesma pessoa, pessoas distintas, respostas +opostas, perfil revogado, base alterada e nova adopção durante a espera. +Conferências, quórum e auditoria foram conferidos; a base de teste foi +eliminada. Não se apresenta esta bateria como um teste de corrida de emissão. + +Resultado final do servidor: **478 testes aprovados, 2 ignorados e 3887 +asserções**. A bateria inclui os 13 cenários novos de admissão e quatro de +integração real com adjudicação/emissão: base preservada na ordem, +pontuações mantidas, exclusão e mínimo de fornecedores, alterações antes da +emissão, repetição de emissão e adjudicação anterior sem adopção retroactiva. +Os 94 testes dos módulos directamente relacionados também passaram após a +integração do histórico e do campo preservado na ordem. PHPStan e Pint +passaram. TypeScript, build e lint/formatação dos ficheiros deste âmbito +passaram; a verificação global continua limitada pela formatação alheia +indicada abaixo. HTTP 200 confirmado para início, demonstração de admissão +e login, todos com o título inicial Sistema de Gestão de Compras (SGC). + +O navegador continua a devolver `The browser access host is unavailable`. +Permanecem pendentes a revisão visual pública, autenticada e de impressão e +as interacções no navegador. As verificações HTTP e automatizadas não a +substituem. Recomendação formal em etapas, excepções/dispensas, comunicação +externa e cancelamento de ordens emitidas ficam fora desta entrega. +Os três documentos locais de continuidade foram preservados sem os incluir +no commit. A formatação global de `docs/compatibilidade.md`, alteração alheia, +continua pendente; a verificação de âmbito exclui esse ficheiro. + ## Incremento: relatório de conferência e impressão As propostas e a visão única do processo incluem o acesso a diff --git a/resources/js/components/sgc-shell.tsx b/resources/js/components/sgc-shell.tsx index 1448460..d2a3ccd 100644 --- a/resources/js/components/sgc-shell.tsx +++ b/resources/js/components/sgc-shell.tsx @@ -82,6 +82,12 @@ const groups: { label: string; entries: Entry[] }[] = [ demo: '/consulta/investimento', roles: ['admin', 'finance', 'procurement'], }, + { + label: 'Política de admissão', + path: '/politica-admissao', + demo: '/proposta/admissao', + roles: ['admin'], + }, ], }, { diff --git a/resources/js/pages/purchases/admission-policy.tsx b/resources/js/pages/purchases/admission-policy.tsx new file mode 100644 index 0000000..6e70310 --- /dev/null +++ b/resources/js/pages/purchases/admission-policy.tsx @@ -0,0 +1,186 @@ +import { Head, Link, useForm } from '@inertiajs/react'; +import { FormErrors, PageTitle, Panel } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { date, roleLabels } from '@/types/procurement'; + +type Policy = { + id: number; + revision: number; + enabled: boolean; + roles: string[]; + required_count: number; + reason: string; + created_at: string; +}; +type Props = { + latest: Policy | null; + policies: { + data: Policy[]; + prev_page_url: string | null; + next_page_url: string | null; + }; +}; +export default function AdmissionPolicy({ latest, policies }: Props) { + return ( + + ); +} + +function PolicyForm({ latest, policies }: Props) { + const form = useForm({ + previous_id: latest?.id ?? 0, + enabled: false, + roles: [] as string[], + required_count: '', + reason: '', + }); + return ( + <> + +
+ + +

+ Defina os perfis e a quantidade de pessoas distintas. + Activar permite novas adopções expressas pelos + processos; desactivar impede novas adopções. As versões + já adoptadas mantêm-se. O limite técnico de 50 + confirmações não define uma regra da empresa. +

+
{ + e.preventDefault(); + form.post('/politica-admissao'); + }} + > + + +
+ + Perfis autorizados a conferir + + {['finance', 'department_head'].map((role) => ( + + ))} +
+
+ + + form.setData( + 'required_count', + e.target.value, + ) + } + /> +
+
+ +