diff --git a/app/Http/Controllers/FormalRecommendationController.php b/app/Http/Controllers/FormalRecommendationController.php new file mode 100644 index 0000000..84d891f --- /dev/null +++ b/app/Http/Controllers/FormalRecommendationController.php @@ -0,0 +1,124 @@ +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/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function configure(Request $request, FormalRecommendation $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('recommendations.policy'); + } + + public function show(Request $request, Requisition $requisition, FormalRecommendation $workflow): Response + { + $requisition = $this->access($request, $requisition->id); + $filter = $request->validate(['version' => 'nullable|integer|min:1']); + $adoption = $workflow->adoption($requisition); + $selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption; + $current = $selected && $selected->id === $adoption?->id; + $basis = $workflow->basis($requisition); + $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first(); + $closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists(); + $open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition); + $history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString(); + $history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) { + $fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash; + + return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), + 'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()]; + }); + Inertia::encryptHistory(); + $response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null, + 'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0, + 'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $basis['ready'] && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function adopt(Request $request, Requisition $requisition, FormalRecommendation $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('recommendations.show', $requisition); + } + + public function propose(Request $request, Requisition $requisition, FormalRecommendation $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', '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('recommendations.show', $requisition); + } + + public function review(Request $request, RecommendationAttempt $attempt, FormalRecommendation $workflow): RedirectResponse + { + $adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_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(), $attempt, $data['verdict'], $data['reason']); + + return to_route('recommendations.show', $adoption->requisition_id); + } + + public function print(Request $request, RecommendationAttempt $attempt, FormalRecommendation $workflow): Response + { + return DB::transaction(function () use ($request, $attempt, $workflow) { + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'); + } + $actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id); + $request->setUserResolver(fn () => $actor); + $adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id); + $requisition = $this->access($request, $adoption->requisition_id); + $basis = $workflow->basis($requisition); + $currentAdoption = $workflow->adoption($requisition); + $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->firstOrFail(); + + $history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]); + + return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']); + }); + } +} diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php index d93d7ed..f61e4d3 100644 --- a/app/Http/Controllers/PurchaseController.php +++ b/app/Http/Controllers/PurchaseController.php @@ -7,13 +7,16 @@ use App\Models\AuditLog; use App\Models\AwardCancellation; use App\Models\PurchaseAward; use App\Models\PurchaseOrder; +use App\Models\RecommendationAttempt; use App\Models\Requisition; +use App\Services\FormalRecommendation; use App\Services\ProposalComparison; use App\Services\PurchaseWorkflow; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; @@ -30,10 +33,25 @@ class PurchaseController extends Controller Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id); $award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first(); $frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists(); + $recommendation = app(FormalRecommendation::class); + $adoption = $recommendation->adoption($requisition); + $candidate = null; + if ($adoption) { + $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); + if ($latest) { + try { + $recommendation->selection($requisition, $latest->quotation_id); + $candidate = $latest->quotation_id; + } catch (ValidationException) { + // The POST revalidates again; stale recommendations cannot enable the form. + } + } + } return Inertia::render('purchases/award', [ 'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award, - 'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id, + 'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null), + 'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate], 'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition), 'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement, 'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']), diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php index 3fd8d16..9743fee 100644 --- a/app/Http/Controllers/PurchaseProcessController.php +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -40,6 +40,7 @@ class PurchaseProcessController extends Controller 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']; + $documents[] = ['label' => 'Regime, tentativas e conferências', 'kind' => 'Recomendação', 'href' => $base.'/recomendacao']; } $qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null; if ($qualification) { @@ -112,7 +113,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, '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']; + $actions = [...$actions, 'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed', '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'; @@ -127,7 +128,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' => ['Relatório de recomendação em etapas'], + 'future' => [], ])->toResponse($request); $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); diff --git a/app/Models/PurchaseAward.php b/app/Models/PurchaseAward.php index 8d12ebe..559986a 100644 --- a/app/Models/PurchaseAward.php +++ b/app/Models/PurchaseAward.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property list> $decision_matrix * @property array|null $investment_snapshot * @property array|null $admission_snapshot + * @property array|null $recommendation_snapshot */ class PurchaseAward extends Model { @@ -27,7 +28,7 @@ class PurchaseAward extends Model protected function casts(): array { - return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d']; + return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'recommendation_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d']; } /** @return HasMany */ diff --git a/app/Models/RecommendationAdoption.php b/app/Models/RecommendationAdoption.php new file mode 100644 index 0000000..193c4db --- /dev/null +++ b/app/Models/RecommendationAdoption.php @@ -0,0 +1,22 @@ +,required_count:int,policy_revision:int} $snapshot */ +class RecommendationAdoption extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array']; + } +} diff --git a/app/Models/RecommendationAttempt.php b/app/Models/RecommendationAttempt.php new file mode 100644 index 0000000..c113b50 --- /dev/null +++ b/app/Models/RecommendationAttempt.php @@ -0,0 +1,22 @@ + $snapshot */ +class RecommendationAttempt extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array']; + } +} diff --git a/app/Models/RecommendationPolicy.php b/app/Models/RecommendationPolicy.php new file mode 100644 index 0000000..90e0194 --- /dev/null +++ b/app/Models/RecommendationPolicy.php @@ -0,0 +1,22 @@ + $roles */ +class RecommendationPolicy 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/RecommendationReview.php b/app/Models/RecommendationReview.php new file mode 100644 index 0000000..cffa8ea --- /dev/null +++ b/app/Models/RecommendationReview.php @@ -0,0 +1,21 @@ + 'array']; + } +} diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php index dc52440..d5aece3 100644 --- a/app/Services/ConflictGuard.php +++ b/app/Services/ConflictGuard.php @@ -87,6 +87,7 @@ class ConflictGuard 'opening.recorded', 'qualification.defined', 'qualification.assessed', 'admission.adopted', 'admission.proposed', 'admission.reviewed', + 'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed', 'equipment.technical_approved', 'equipment.technical_rejected', 'invoice.created', 'invoice.validated', 'payment.registered', 'payment.confirmed', 'payment.rejected', diff --git a/app/Services/FormalRecommendation.php b/app/Services/FormalRecommendation.php new file mode 100644 index 0000000..c624cc2 --- /dev/null +++ b/app/Services/FormalRecommendation.php @@ -0,0 +1,214 @@ + $message]); + } + + public function adoption(Requisition $request): ?RecommendationAdoption + { + return RecommendationAdoption::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('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.'); + } + 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(); + ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get(); + + return $request; + } + + /** @param array $data */ + public function policy(User $actor, array $data): RecommendationPolicy + { + 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 perfis, quantidade de conferentes e fundamento próprios da recomendação.'); + } + + return DB::transaction(function () use ($actor, $data) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]); + $latest = RecommendationPolicy::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 = RecommendationPolicy::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, 'recommendation.policy', null, null, $policy->toArray()); + + return $policy; + }); + } + + public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): RecommendationAdoption + { + 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); + if (! app(FormalAdmission::class)->adoption($request)) { + $this->invalid('Adopte expressamente o regime de admissão antes da recomendação.'); + } + $previous = $this->adoption($request); + $policy = RecommendationPolicy::query()->latest('revision')->first(); + if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId || $previous?->recommendation_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) { + $this->invalid('Seleccione a política activa, a adopção actual e um fundamento próprio.'); + } + $adoption = RecommendationAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'recommendation_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, 'recommendation.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]); + + return $adoption; + }); + } + + /** @return array{hash:string,payload:array{admission_adoption:array|null,comparison_hash:string,ready:bool,top_ids:list,rows:list>,admissions:list>},ready:bool,top_ids:list} */ + public function basis(Requisition $request): array + { + $comparison = app(ProposalComparison::class)->inspect($request); + $admission = app(FormalAdmission::class); + $adoption = $admission->adoption($request); + $rows = []; + $admissions = []; + foreach ($comparison['rows'] as $row) { + // Freeze the decision inputs without supplier contacts or private documents. + $rows[] = [...array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'payment_terms', 'weights', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor', 'items', 'evaluations', 'qualified_evaluations'])), 'supplier_name' => $row['supplier']['name'] ?? 'Fornecedor indisponível']; + $decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id ?? 0)->where('quotation_id', $row['id'])->latest('revision')->first(); + $admissions[] = ['quotation_id' => $row['id'], 'basis' => $admission->basis($request, $row['id']), 'decision' => $decision?->toArray(), 'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id ?? 0)->orderBy('id')->get()->toArray()]; + } + $payload = ['admission_adoption' => $adoption?->toArray(), 'comparison_hash' => $comparison['fingerprint'], 'ready' => $comparison['ready'], 'top_ids' => $comparison['top_ids'], 'rows' => $rows, 'admissions' => $admissions]; + + return ['hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'payload' => $payload, 'ready' => $adoption !== null && $comparison['ready'], 'top_ids' => $comparison['top_ids']]; + } + + public function status(RecommendationAttempt $attempt, RecommendationAdoption $adoption): string + { + $reviews = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->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): RecommendationAttempt + { + if (mb_strlen(trim($data['reason'])) < 10) { + $this->invalid('Fundamente a recomendação, incluindo a escolha em caso de empate.'); + } + + 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('Adopte expressamente o regime de recomendação.'); + } + $quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail(); + $basis = $this->basis($request); + if (! $basis['ready'] || $basis['hash'] !== $data['basis_hash'] || ! in_array($quote->id, $basis['top_ids'], true)) { + $this->invalid('Exige-se matriz pronta, admissões confirmadas e escolha fundamentada entre as maiores pontuações da base actual.'); + } + $previous = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); + if ((int) $data['previous_id'] !== ($previous->id ?? 0)) { + $this->invalid('A tentativa mudou. Actualize antes de registar outra revisão.'); + } + $attempt = RecommendationAttempt::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_adoption_id' => $adoption->id, 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name']), 'policy' => $adoption->snapshot], 'reason' => $data['reason'], 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'recommendation.proposed', $request, null, ['attempt' => $attempt->toArray(), 'previous_id' => $previous?->id]); + + return $attempt; + }); + } + + public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): 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, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id); + } + + public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void + { + if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) { + $this->invalid('Registe uma conferência válida e fundamentada.'); + } + DB::transaction(function () use ($actor, $attempt, $verdict, $reason) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]); + $original = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id); + $request = $this->process($actor, $original->requisition_id); + $adoption = $this->adoption($request); + abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403); + $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); + $basis = $this->basis($request); + if ($attempt->recommendation_adoption_id !== $adoption->id || $latest?->id !== $attempt->id || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash) { + $this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.'); + } + $existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->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($attempt, $adoption) !== 'pending') { + $this->invalid('A tentativa já foi concluída ou devolvida.'); + } + if ($verdict === 'approved') { + foreach (RecommendationReview::query()->where('recommendation_attempt_id', $attempt->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, $attempt)) { + $this->invalid('Um conferente anterior deixou de ser elegível. Devolva e prepare outra tentativa.'); + } + } + } + $review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->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, 'recommendation.reviewed', $request, null, $review->toArray()); + }); + } + + /** @return array|null */ + public function selection(Requisition $request, int $quoteId): ?array + { + $adoption = $this->adoption($request); + if (! $adoption) { + return null; + } + $attempt = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); + $basis = $this->basis($request); + if (! $attempt || $attempt->quotation_id !== $quoteId || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash || $this->status($attempt, $adoption) !== 'confirmed') { + $this->invalid('A adjudicação exige a proposta da recomendação confirmada na base actual.'); + } + + return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->recommendation_policy_id, 'attempt_id' => $attempt->id, 'quotation_id' => $quoteId, 'basis_hash' => $attempt->basis_hash, 'review_ids' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->pluck('id')->all()]; + } +} diff --git a/app/Services/ProcurementReviewReport.php b/app/Services/ProcurementReviewReport.php index 8b615fd..aa8b8fa 100644 --- a/app/Services/ProcurementReviewReport.php +++ b/app/Services/ProcurementReviewReport.php @@ -67,6 +67,7 @@ class ProcurementReviewReport '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']), + 'recommendation' => app(FormalRecommendation::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/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php index ec96e31..587ba3b 100644 --- a/app/Services/PurchaseWorkflow.php +++ b/app/Services/PurchaseWorkflow.php @@ -39,6 +39,7 @@ class PurchaseWorkflow $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); + $recommendation = app(FormalRecommendation::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([ @@ -49,6 +50,7 @@ class PurchaseWorkflow 'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'], 'investment_snapshot' => $investment, 'admission_snapshot' => $admission, + 'recommendation_snapshot' => $recommendation, ]); $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) { @@ -152,6 +154,7 @@ class PurchaseWorkflow 'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate], 'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot, 'admission' => $award->admission_snapshot, + 'recommendation' => $award->recommendation_snapshot, 'estimate_approvals' => $requisition->steps()->get()->toArray(), 'final_approvals' => $award->approvals()->get()->toArray(), 'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines, @@ -186,6 +189,9 @@ class PurchaseWorkflow 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 (app(FormalRecommendation::class)->selection($requisition, $quote->id) !== $award->recommendation_snapshot) { + throw ValidationException::withMessages(['recommendation' => 'A recomendação já não corresponde à base da adjudicação. Prepare a revisão autorizada antes de emitir.']); + } 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_000020_create_formal_recommendations.php b/database/migrations/2026_10_28_000020_create_formal_recommendations.php new file mode 100644 index 0000000..6441782 --- /dev/null +++ b/database/migrations/2026_10_28_000020_create_formal_recommendations.php @@ -0,0 +1,66 @@ +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('recommendation_adoptions', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('requisition_id')->constrained()->restrictOnDelete(); + $table->foreignId('recommendation_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('recommendation_attempts', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('recommendation_adoption_id')->constrained()->restrictOnDelete(); + $table->foreignId('quotation_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->string('basis_hash', 64); + $table->json('snapshot'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['recommendation_adoption_id', 'revision'], 'recommendation_attempt_version_unique'); + }); + Schema::create('recommendation_reviews', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('recommendation_attempt_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(['recommendation_attempt_id', 'reviewed_by']); + }); + Schema::table('purchase_awards', fn (Blueprint $table) => $table->json('recommendation_snapshot')->nullable()); + } + + public function down(): void + { + throw new LogicException('Preservar políticas, adopções, decisões e conferências de recomendação.'); + } +}; diff --git a/docs/exemplos-orquestracao-compras.md b/docs/exemplos-orquestracao-compras.md index 1c30d18..e052b73 100644 --- a/docs/exemplos-orquestracao-compras.md +++ b/docs/exemplos-orquestracao-compras.md @@ -5,9 +5,10 @@ planeamento acrescenta itens por empresa, responsáveis, marcos e datas previstas/realizadas, com ligação posterior às requisições. O registo documental opcional da abertura e actas por versão também foi implementado. Seguiram-se qualificação documental, relatório de conferência e admissão/exclusão por -adopção expressa (`d020be5`). A recomendação formal está proposta para validação -em `docs/proposta-recomendacao.md`, com demonstração em -`/proposta/recomendacao`; não está activa na base de negócio. Avaliação em +adopção expressa (`d020be5`). O âmbito de recomendação formal foi depois +validado e implementado com política própria, adopção opcional e conferência +independente antes da adjudicação, sem activar políticas reais. A demonstração +`/proposta/recomendacao` continua separada do circuito autenticado. Avaliação em novas modalidades continua por implementar. Estes avanços não importam os processos históricos nem aprovam as regras de cancelamento de ordens emitidas. @@ -73,8 +74,8 @@ comportamento dos processos existentes. A visão do processo, o planeamento e os marcos, a abertura, a qualificação documental e a admissão/exclusão já foram entregues como incrementos próprios. -O próximo circuito proposto é a recomendação formal antes da adjudicação, -sujeita à validação de âmbito e sem alterar a comparação ponderada existente. +A recomendação formal antes da adjudicação também foi entregue após validação, +sem alterar a comparação ponderada existente. Novas modalidades, métodos de classificação, correcções de preços e dispensas continuam a exigir definição explícita. diff --git a/docs/proposta-recomendacao.md b/docs/proposta-recomendacao.md index 5be6b41..427f2fb 100644 --- a/docs/proposta-recomendacao.md +++ b/docs/proposta-recomendacao.md @@ -1,10 +1,12 @@ # Proposta de âmbito para recomendação antes da adjudicação -Estado: proposta para validação após a entrega de admissão/exclusão em -`d020be5`. A demonstração `/proposta/recomendacao` não implementa uma nova -decisão na base de negócio. Data: 28 de Outubro de 2026. +Estado: âmbito validado pelo utilizador e implementado em 28 de Outubro de +2026, após a entrega de admissão/exclusão em `d020be5`. O circuito real é +opcional por empresa e por processo. A demonstração `/proposta/recomendacao` +continua separada dos registos autenticados; nenhuma política real foi +publicada nesta entrega. -## Resultado preparado para revisão +## Demonstração preparada antes da validação Uma proposta navegável, com cabeçalho, rodapé e menus partilhados do Sistema de Gestão de Compras (SGC), permite simular: @@ -25,7 +27,7 @@ página não autentica outra pessoa e não demonstra segregação efectiva. As pontuações ilustram decisões de interface; não constituem um novo algoritmo de comparação. Não se reutilizam nomes ou valores dos exemplos históricos. -## Circuito inicial proposto para o produto +## Circuito inicial validado para o produto 1. Administração da empresa activa uma política de recomendação versionada, com fundamento, perfis autorizados e quantidade de pessoas distintas. @@ -98,7 +100,7 @@ decisões empresariais continuam a pertencer a cada aderente. - Tornar a recomendação obrigatória em todos os processos ou activar políticas reais por conta das empresas durante o desenvolvimento. -## Verificações exigidas para a futura implementação +## Verificações da implementação Isolamento e privacidade, identidade relida no servidor, autoria, segregação, perfil/departamento revogado, quórum sem duplicações, conflitos, matriz @@ -108,20 +110,24 @@ rollback de auditoria, vínculo à adjudicação/emissão e concorrência PostgreSQL com processos independentes. Uma demonstração local não substitui estas verificações do circuito autenticado. -## Validação necessária antes de implementar o circuito real +## Validação recebida e activação -Validar **recomendação manual por Compras, conferências independentes em +O utilizador validou **recomendação manual por Compras, conferências independentes em quantidade/perfis definidos pela empresa, regime opcional por adopção expressa após admissão e vínculo à adjudicação da proposta recomendada**. -A orientação de continuidade determina: «definir com a empresa o próximo -âmbito pendente antes de o implementar», em -`docs/orientacoes-continuidade.md`. A validação anterior autorizou admissão -e exclusão; não definiu uma etapa de recomendação com estes efeitos. A -implementação do circuito real aguarda a validação deste âmbito. As -alterações locais dos três documentos de continuidade ficam preservadas. +A Administração pode configurar `/politica-recomendacao`; Compras adopta a +versão activa em `/requisicoes/{requisition}/recomendacao`, após adopção do +regime de admissão e antes da adjudicação. Perfis e quantidade são escolhas +expressas da empresa. O limite de 50 no formulário é técnico, sem quórum +empresarial predefinido. A empresa deve assegurar pessoas elegíveis em número +suficiente. Consultar `docs/validacao-tecnica.md` para resultados e limitações. -## Verificações desta preparação +A validação é do âmbito do produto; não publica políticas por conta das +aderentes. As alterações locais dos três documentos de continuidade foram +preservadas. + +## Histórico: verificações da preparação da demonstração Passaram os 18 testes de entrada e consulta pública, com 290 asserções, PHPStan, Pint, TypeScript, build e lint/formatação do âmbito. O acesso HTTP diff --git a/docs/recomendacao-concorrencia-postgresql.json b/docs/recomendacao-concorrencia-postgresql.json new file mode 100644 index 0000000..32e1985 --- /dev/null +++ b/docs/recomendacao-concorrencia-postgresql.json @@ -0,0 +1,81 @@ +{ + "tested_at_utc": "2026-10-28T18:42: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", + "attempts": 1, + "awards": 0 + }, + { + "name": "distinct_people", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 2, + "state": "confirmed", + "attempts": 1, + "awards": 0 + }, + { + "name": "opposing_reviews", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 1, + "state": "pending", + "attempts": 1, + "awards": 0 + }, + { + "name": "revoked_role", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending", + "attempts": 1, + "awards": 0 + }, + { + "name": "changed_basis", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending", + "attempts": 1, + "awards": 0 + }, + { + "name": "new_adoption", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending", + "attempts": 1, + "awards": 0 + }, + { + "name": "competing_attempts", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 0, + "state": "pending", + "attempts": 2, + "awards": 0 + }, + { + "name": "recommendation_versus_award", + "passed": true, + "overlapping_lock_waits": 2, + "reviews": 2, + "state": "confirmed", + "attempts": 2, + "awards": 0 + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/recomendacao-migracao-000020.json b/docs/recomendacao-migracao-000020.json new file mode 100644 index 0000000..f599584 --- /dev/null +++ b/docs/recomendacao-migracao-000020.json @@ -0,0 +1,16 @@ +{ + "verified_at_utc": "2026-10-28T18:45:44+00:00", + "migration": "2026_10_28_000020_create_formal_recommendations", + "batch": 21, + "backup_created": true, + "backup_bytes": 326150, + "restore_verified": true, + "restored_tables": 61, + "restore_database_dropped": true, + "business_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "recommendation_policies_count": 0, + "recommendation_adoptions_count": 0, + "operational_validation": false +} diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index 0a86717..21e0bc2 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -1,5 +1,103 @@ # Validação técnica do SGC — 28 de Outubro de 2026 +## Incremento: recomendação formal antes da adjudicação + +O âmbito de `docs/proposta-recomendacao.md` foi validado pelo utilizador e +implementado. A Administração configura uma política própria de recomendação +em `/politica-recomendacao`, com perfis Finanças e/ou chefia do departamento, +quantidade de pessoas distintas, activação e fundamento expressos. Não há +perfis ou quantidade inicialmente seleccionados. O máximo de 50 confirmações +é um limite técnico e não define um quórum empresarial. + +Compras adopta a versão activa em `/requisicoes/{requisition}/recomendacao`, +num pedido aprovado que já adoptou o regime de admissão, antes de adjudicar. +Sem essa adopção, mantém-se o circuito existente. Uma versão geral nova ou +desactivada não reescreve processos; a desactivação impede novas adopções. +Trocar a versão aplicável exige adopção expressa e novas tentativas e +conferências. Uma política de admissão não autoriza, por si, a recomendação. + +A recomendação exige matriz pronta, admissões confirmadas na base actual, +três fornecedores distintos elegíveis e duas avaliações por proposta, sem +alteração do cálculo ponderado existente. Compras, diferente do solicitante, +escolhe expressamente entre as maiores pontuações e fundamenta, incluindo em +empate. Não há desempate automático, dispensa do mínimo, escolha abaixo da +maior pontuação ou autorização para corrigir preços. + +Cada tentativa preserva matriz, propostas/linhas, avaliações, referências e +bases das admissões/requisitos/análises, conferências, política e proponente. +Os dados de contacto ou documentos privados dos fornecedores não entram na +matriz preservada. Uma nova tentativa é imutável e não herda confirmações. +Uma devolução encerra a tentativa sem excluir o concorrente ou cancelar o +processo. Conferências repetidas idênticas são idempotentes e contam uma vez. +Identidades, empresa, perfil, departamento, segregação e conflitos são +revalidados dentro da transacção, após o bloqueio da empresa. As confirmações +anteriores são revalidadas até concluir o quórum; a história concluída conserva +a elegibilidade verificada na sua data, sem autorizar futuras operações com +perfil revogado. Uma pessoa não conta duas vezes após mudar de perfil. + +Nos processos com adopção, a adjudicação só selecciona a mesma proposta de +uma recomendação confirmada na base actual. A referência e a base ficam +preservadas na adjudicação e na ordem; são revalidadas antes de aprovar/emitir. +Uma alteração de avaliação ou uma nova admissão pode invalidar a recomendação +mesmo mantendo preços e pontuações. Não se reescrevem adjudicações/ordens +anteriores. Existindo adjudicação, a adopção e as tentativas/conferências +ficam fechadas; alterações exigem o circuito de revisão já suportado, quando +autorizado. A repetição de uma emissão concluída devolve a mesma ordem. +Recomendar não movimenta orçamento nem substitui as aprovações do valor final, +regras de investimento, conflitos, facturas ou pagamentos. + +As páginas usam o cabeçalho, rodapé, menus e identificação Sistema de Gestão +de Compras (SGC). Comparação, adjudicação e processo completo ligam ao novo +circuito; o processo apresenta os eventos de auditoria. O relatório de +conferência assinala a adopção e remete para o relatório preservado da +recomendação. A impressão autenticada em `/recomendacoes/{attempt}/imprimir` +mostra matriz/base originais, candidata, fundamentos, conferências, devoluções +e tentativas anteriores da mesma adopção, distinguindo dados históricos da +situação actual. As restantes adopções são consultáveis na página do processo. +O conteúdo é escapado e a resposta é `private, no-store`. Em PostgreSQL, a +impressão usa `REPEATABLE READ, READ ONLY`, relendo o operador; não cria +auditoria nem outros registos. Não é uma assinatura digital ou certificação +das evidências. A revisão visual da paginação continua pendente. + +Resultado da bateria completa: **498 testes aprovados, 2 ignorados, 4141 +asserções**. Inclui **17 cenários de recomendação**, com percurso genuíno até +adjudicação/emissão, empate e escolha da proposta empatada, escolha abaixo +da maior pontuação, matriz incompleta, admissão pendente/nova, mudanças de +base, devolução, repetição, perfis/departamentos revogados, mesma identidade +em perfis diferentes, política preservada, circuito sem adopção, segregação, +isolamento, relatório histórico sem escrita, escape de conteúdo e rollback +de auditoria de tentativa/conferência. + +Passaram **8 cenários PostgreSQL**, com processos PHP e ligações independentes +e dois bloqueios simultâneos observados em cada cenário: mesma pessoa, +pessoas distintas, respostas opostas, perfil revogado, avaliação alterada, +nova adopção durante a espera, tentativas concorrentes e nova recomendação +versus adjudicação. Conferiram-se quórum, versões, auditoria e vínculo à +tentativa; a base de teste foi eliminada. O resultado está em +`docs/recomendacao-concorrencia-postgresql.json`. Não se afirma que foram +observadas todas as ordens possíveis de escalonamento ou testada corrida de +impressão/emissão nesta bateria. + +Migração `000020`, lote **21**, aplicada em `compras_dev` após backup privado +de **326150 bytes**, verificado por restauro numa base separada e eliminada +depois da conferência. As **61 tabelas anteriores**, os registos de negócio +por projecção das colunas originais, restrições existentes, histórico de +migrações e `.env` foram preservados. São quatro tabelas novas e um campo +JSON anulável na adjudicação. As quatro tabelas reais de recomendação estão +vazias; não houve publicação de políticas, seeds ou compras reais. O relatório +sanitizado está em `docs/recomendacao-migracao-000020.json`; backup e manifesto +continuam privados e fora do Git. O rollback destrutivo automático é impedido. + +Pint, PHPStan, TypeScript, build e lint/formatação do âmbito passaram. A +formatação global de `docs/compatibilidade.md`, alteração local alheia, continua +pendente e fora do commit, juntamente com as alterações de desenvolvimento e +continuidade preservadas. O navegador voltou a devolver `The browser access +host is unavailable`: permanecem pendentes revisão visual pública, autenticada, +de impressão e interacções no navegador. Validação operacional integral das +empresas, novas modalidades/métodos de avaliação, dispensas, comunicação +externa, cancelamento de ordens emitidas, estornos/devoluções e agregação por +projecto/fraccionamento não são declarados concluídos por esta entrega. + ## Preparação: proposta de recomendação formal Após `d020be5`, foi preparada `/proposta/recomendacao`, ligada ao menu diff --git a/resources/js/components/sgc-shell.tsx b/resources/js/components/sgc-shell.tsx index 68fdea9..caf7494 100644 --- a/resources/js/components/sgc-shell.tsx +++ b/resources/js/components/sgc-shell.tsx @@ -94,6 +94,12 @@ const groups: { label: string; entries: Entry[] }[] = [ demo: '/proposta/admissao', roles: ['admin'], }, + { + label: 'Política de recomendação', + path: '/politica-recomendacao', + demo: '', + roles: ['admin'], + }, ], }, { diff --git a/resources/js/pages/purchases/award.tsx b/resources/js/pages/purchases/award.tsx index 0df1b35..608bd80 100644 --- a/resources/js/pages/purchases/award.tsx +++ b/resources/js/pages/purchases/award.tsx @@ -19,6 +19,7 @@ type Props = { canDecide: boolean; canIssue: boolean; order: { id: number; number: string } | null; + recommendation: { adopted: boolean; quotation_id: number | null }; }; export default function Award({ requisition, @@ -28,6 +29,7 @@ export default function Award({ canDecide, canIssue, order, + recommendation, }: Props) { const selection = useForm({ quotation_id: '', @@ -127,6 +129,19 @@ export default function Award({ +

+ {recommendation.adopted + ? recommendation.quotation_id + ? 'Regime de recomendação adoptado: a selecção deve coincidir com a proposta recomendada.' + : 'Conclua uma recomendação actual antes de seleccionar a adjudicação.' + : 'Sem adopção do regime de recomendação: mantém-se o circuito existente.'}{' '} + + Consultar recomendação + +

{canSelect && ( {comparison.rows - .filter((q) => - comparison.top_ids.includes(q.id), + .filter( + (q) => + comparison.top_ids.includes( + q.id, + ) && + (!recommendation.adopted || + recommendation.quotation_id === + q.id), ) .map((q) => ( - -
    - {future.map((stage) => ( -
  • {stage}
  • - ))} -
-
+ {future.length > 0 && ( + +
    + {future.map((stage) => ( +
  • {stage}
  • + ))} +
+
+ )} ); diff --git a/resources/js/pages/purchases/recommendation-policy.tsx b/resources/js/pages/purchases/recommendation-policy.tsx new file mode 100644 index 0000000..3c120bf --- /dev/null +++ b/resources/js/pages/purchases/recommendation-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 RecommendationPolicy({ 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-recomendacao'); + }} + > + + +
+ + Perfis autorizados a conferir + + {['finance', 'department_head'].map((role) => ( + + ))} +
+
+ + + form.setData( + 'required_count', + e.target.value, + ) + } + /> +
+
+ +