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.
+
+ {!policy?.enabled
+ ? 'Sem política activa para nova adopção. A Administração configura a política da empresa.'
+ : 'O processo já adoptou a versão activa.'}
+
+ {adoption
+ ? `Adopção ${adoption.revision}: política ${adoption.snapshot.policy_revision}, ${adoption.snapshot.required_count} pessoa(s) distinta(s), ${adoption.snapshot.roles.map((r) => roleLabels[r] ?? r).join(', ')}.`
+ : 'Sem adopção: mantém-se o circuito de comparação e adjudicação existente.'}
+
+
+ Admissão não autoriza compra. Mantêm-se pontuação,
+ conflitos, orçamento, aprovações e o mínimo existente de
+ três fornecedores elegíveis com duas avaliações por
+ proposta. Esclarecimentos são registos internos, sem
+ envio de comunicações.
+
+ {closed && (
+
+ O processo está fechado para alterações de admissão.
+
+ >
+ );
+}
diff --git a/resources/js/pages/purchases/process.tsx b/resources/js/pages/purchases/process.tsx
index 9bf3b6c..cd6123b 100644
--- a/resources/js/pages/purchases/process.tsx
+++ b/resources/js/pages/purchases/process.tsx
@@ -50,6 +50,9 @@ const labels: Record = {
const events: Record = {
'qualification.defined': 'Versão dos requisitos documentais registada',
'qualification.assessed': 'Versão da análise documental registada',
+ 'admission.adopted': 'Política de admissão adoptada expressamente',
+ 'admission.proposed': 'Tentativa de decisão de admissão registada',
+ 'admission.reviewed': 'Conferência independente de admissão registada',
'opening.recorded': 'Versão da acta de abertura registada',
'requisition.created': 'Requisição criada',
'requisition.updated': 'Requisição actualizada',
diff --git a/resources/js/pages/purchases/qualification.tsx b/resources/js/pages/purchases/qualification.tsx
index b5a7eaf..b6a9bde 100644
--- a/resources/js/pages/purchases/qualification.tsx
+++ b/resources/js/pages/purchases/qualification.tsx
@@ -555,12 +555,20 @@ export default function QualificationPage(props: Props) {
)}
- Requisitos e resultados documentais para conferência. Não
- admitem ou excluem concorrentes, certificam evidências ou
- alteram a comparação ponderada e a adjudicação. Não
- aplicável exige fundamento e não dispensa automaticamente um
- requisito obrigatório.
+ Os requisitos e resultados documentais apoiam a conferência;
+ não constituem uma decisão formal nem certificam evidências.
+ Num processo que adoptou o regime de admissão, estas versões
+ integram a base da decisão e alterações exigem reapreciação.
+ Não aplicável exige fundamento e não dispensa
+ automaticamente um requisito obrigatório. As pontuações
+ mantêm o cálculo ponderado existente.
+
+ Consultar regime, decisões e conferências de admissão
+
+
+ {report.admission
+ ? `Regime de admissão adoptado: política ${report.admission.snapshot.policy_revision}, ${report.admission.snapshot.required_count} confirmações independentes. A elegibilidade da comparação inclui admissão confirmada na base actual.`
+ : 'Sem adopção do regime de admissão: mantém-se o circuito de comparação existente.'}{' '}
+
+ Consultar decisões e conferências de admissão
+
+
{r.status !== 'approved' && (
O pedido não está em estado aprovado. As pontuações
@@ -282,10 +303,9 @@ export default function ReviewReport({ report }: { report: Report }) {
) : (
- Sem impedimentos
- assinalados por esta
- matriz. Não equivale
- a admissão formal.
+ {report.admission
+ ? 'Sem impedimentos nesta matriz, incluindo admissão confirmada na base actual. Não autoriza compra.'
+ : 'Sem impedimentos assinalados por esta matriz. Não equivale a admissão formal.'}