Implementa recomendação formal com adopção e conferência independente

This commit is contained in:
2026-10-28 20:49:30 +02:00
parent 29cd859385
commit ee17ff7764
33 changed files with 2233 additions and 37 deletions
@@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\PurchaseAward;
use App\Models\RecommendationAdoption;
use App\Models\RecommendationAttempt;
use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Requisition;
use App\Models\User;
use App\Services\ConflictGuard;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalRecommendationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
abort_unless(in_array($request->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']);
});
}
}
+19 -1
View File
@@ -7,13 +7,16 @@ use App\Models\AuditLog;
use App\Models\AwardCancellation; use App\Models\AwardCancellation;
use App\Models\PurchaseAward; use App\Models\PurchaseAward;
use App\Models\PurchaseOrder; use App\Models\PurchaseOrder;
use App\Models\RecommendationAttempt;
use App\Models\Requisition; use App\Models\Requisition;
use App\Services\FormalRecommendation;
use App\Services\ProposalComparison; use App\Services\ProposalComparison;
use App\Services\PurchaseWorkflow; use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -30,10 +33,25 @@ class PurchaseController extends Controller
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id); Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first(); $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(); $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', [ return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $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), 'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']), 'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
@@ -40,6 +40,7 @@ class PurchaseProcessController extends Controller
if ($purchases) { if ($purchases) {
$documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio']; $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, 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; $qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($qualification) { 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']; $actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) { 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) { if ($inventory) {
$actions[] = 'receipt.confirmed'; $actions[] = 'receipt.confirmed';
@@ -127,7 +128,7 @@ class PurchaseProcessController extends Controller
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related, 'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null, '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']), '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); ])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+2 -1
View File
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property list<array<string, mixed>> $decision_matrix * @property list<array<string, mixed>> $decision_matrix
* @property array<string,mixed>|null $investment_snapshot * @property array<string,mixed>|null $investment_snapshot
* @property array<string,mixed>|null $admission_snapshot * @property array<string,mixed>|null $admission_snapshot
* @property array<string,mixed>|null $recommendation_snapshot
*/ */
class PurchaseAward extends Model class PurchaseAward extends Model
{ {
@@ -27,7 +28,7 @@ class PurchaseAward extends Model
protected function casts(): array 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<PurchaseAwardApproval, $this> */ /** @return HasMany<PurchaseAwardApproval, $this> */
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array{roles:list<string>,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'];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array<string,mixed> $snapshot */
class RecommendationAttempt extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property list<string> $roles */
class RecommendationPolicy extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['roles' => 'array', 'enabled' => 'boolean'];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class RecommendationReview extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+1
View File
@@ -87,6 +87,7 @@ class ConflictGuard
'opening.recorded', 'opening.recorded',
'qualification.defined', 'qualification.assessed', 'qualification.defined', 'qualification.assessed',
'admission.adopted', 'admission.proposed', 'admission.reviewed', 'admission.adopted', 'admission.proposed', 'admission.reviewed',
'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed',
'equipment.technical_approved', 'equipment.technical_rejected', 'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated', 'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected', 'payment.registered', 'payment.confirmed', 'payment.rejected',
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\AdmissionDecision;
use App\Models\AdmissionReview;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\QuotationItem;
use App\Models\RecommendationAdoption;
use App\Models\RecommendationAttempt;
use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class FormalRecommendation
{
private function invalid(string $message): never
{
throw ValidationException::withMessages(['recommendation' => $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<string,mixed> $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<string,mixed>|null,comparison_hash:string,ready:bool,top_ids:list<int>,rows:list<array<string,mixed>>,admissions:list<array<string,mixed>>},ready:bool,top_ids:list<int>} */
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<string,mixed> $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<string,mixed>|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()];
}
}
+1
View File
@@ -67,6 +67,7 @@ class ProcurementReviewReport
'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows], 'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows],
'qualification' => $qualification, 'qualification' => $qualification,
'admission' => app(FormalAdmission::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']), '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, 'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
'award' => $award?->only(['id', 'status', 'total_minor']), 'award' => $award?->only(['id', 'status', 'total_minor']),
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']), 'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
+6
View File
@@ -39,6 +39,7 @@ class PurchaseWorkflow
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']); $quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
$result = $this->checkComparison($user, $requisition, $quote); $result = $this->checkComparison($user, $requisition, $quote);
$admission = app(FormalAdmission::class)->selection($requisition, $quote->id); $admission = app(FormalAdmission::class)->selection($requisition, $quote->id);
$recommendation = app(FormalRecommendation::class)->selection($requisition, $quote->id);
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote); $investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor); app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
$award = PurchaseAward::query()->create([ $award = PurchaseAward::query()->create([
@@ -49,6 +50,7 @@ class PurchaseWorkflow
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'], 'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
'investment_snapshot' => $investment, 'investment_snapshot' => $investment,
'admission_snapshot' => $admission, '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()); $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) { foreach ($roles as $position => $role) {
@@ -152,6 +154,7 @@ class PurchaseWorkflow
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate], 'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot, 'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
'admission' => $award->admission_snapshot, 'admission' => $award->admission_snapshot,
'recommendation' => $award->recommendation_snapshot,
'estimate_approvals' => $requisition->steps()->get()->toArray(), 'estimate_approvals' => $requisition->steps()->get()->toArray(),
'final_approvals' => $award->approvals()->get()->toArray(), 'final_approvals' => $award->approvals()->get()->toArray(),
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines, '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) { 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.']); 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 || 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) { $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.']); throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('recommendation_policies', function (Blueprint $table) {
$table->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.');
}
};
+6 -5
View File
@@ -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 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 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 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 adopção expressa (`d020be5`). O âmbito de recomendação formal foi depois
em `docs/proposta-recomendacao.md`, com demonstração em validado e implementado com política própria, adopção opcional e conferência
`/proposta/recomendacao`; não está activa na base de negócio. Avaliação em 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 novas modalidades continua por implementar. Estes avanços não importam os
processos históricos nem aprovam as regras de cancelamento de ordens emitidas. 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 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. 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, A recomendação formal antes da adjudicação também foi entregue após validação,
sujeita à validação de âmbito e sem alterar a comparação ponderada existente. sem alterar a comparação ponderada existente.
Novas modalidades, métodos de classificação, correcções de preços e dispensas Novas modalidades, métodos de classificação, correcções de preços e dispensas
continuam a exigir definição explícita. continuam a exigir definição explícita.
+21 -15
View File
@@ -1,10 +1,12 @@
# Proposta de âmbito para recomendação antes da adjudicação # 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 Estado: âmbito validado pelo utilizador e implementado em 28 de Outubro de
`d020be5`. A demonstração `/proposta/recomendacao` não implementa uma nova 2026, após a entrega de admissão/exclusão em `d020be5`. O circuito real é
decisão na base de negócio. Data: 28 de Outubro de 2026. 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 Uma proposta navegável, com cabeçalho, rodapé e menus partilhados do Sistema
de Gestão de Compras (SGC), permite simular: 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 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. 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, 1. Administração da empresa activa uma política de recomendação versionada,
com fundamento, perfis autorizados e quantidade de pessoas distintas. 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 - Tornar a recomendação obrigatória em todos os processos ou activar
políticas reais por conta das empresas durante o desenvolvimento. 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, Isolamento e privacidade, identidade relida no servidor, autoria, segregação,
perfil/departamento revogado, quórum sem duplicações, conflitos, matriz 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 PostgreSQL com processos independentes. Uma demonstração local não substitui
estas verificações do circuito autenticado. 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 quantidade/perfis definidos pela empresa, regime opcional por adopção
expressa após admissão e vínculo à adjudicação da proposta recomendada**. 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 A Administração pode configurar `/politica-recomendacao`; Compras adopta a
âmbito pendente antes de o implementar», em versão activa em `/requisicoes/{requisition}/recomendacao`, após adopção do
`docs/orientacoes-continuidade.md`. A validação anterior autorizou admissão regime de admissão e antes da adjudicação. Perfis e quantidade são escolhas
e exclusão; não definiu uma etapa de recomendação com estes efeitos. A expressas da empresa. O limite de 50 no formulário é técnico, sem quórum
implementação do circuito real aguarda a validação deste âmbito. As empresarial predefinido. A empresa deve assegurar pessoas elegíveis em número
alterações locais dos três documentos de continuidade ficam preservadas. 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, 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 PHPStan, Pint, TypeScript, build e lint/formatação do âmbito. O acesso HTTP
@@ -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
}
+16
View File
@@ -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
}
+98
View File
@@ -1,5 +1,103 @@
# Validação técnica do SGC — 28 de Outubro de 2026 # 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 ## Preparação: proposta de recomendação formal
Após `d020be5`, foi preparada `/proposta/recomendacao`, ligada ao menu Após `d020be5`, foi preparada `/proposta/recomendacao`, ligada ao menu
+6
View File
@@ -94,6 +94,12 @@ const groups: { label: string; entries: Entry[] }[] = [
demo: '/proposta/admissao', demo: '/proposta/admissao',
roles: ['admin'], roles: ['admin'],
}, },
{
label: 'Política de recomendação',
path: '/politica-recomendacao',
demo: '',
roles: ['admin'],
},
], ],
}, },
{ {
+23 -2
View File
@@ -19,6 +19,7 @@ type Props = {
canDecide: boolean; canDecide: boolean;
canIssue: boolean; canIssue: boolean;
order: { id: number; number: string } | null; order: { id: number; number: string } | null;
recommendation: { adopted: boolean; quotation_id: number | null };
}; };
export default function Award({ export default function Award({
requisition, requisition,
@@ -28,6 +29,7 @@ export default function Award({
canDecide, canDecide,
canIssue, canIssue,
order, order,
recommendation,
}: Props) { }: Props) {
const selection = useForm({ const selection = useForm({
quotation_id: '', quotation_id: '',
@@ -127,6 +129,19 @@ export default function Award({
</table> </table>
</div> </div>
</Panel> </Panel>
<p className="text-sm">
{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.'}{' '}
<Link
className="underline"
href={`/requisicoes/${requisition.id}/recomendacao`}
>
Consultar recomendação
</Link>
</p>
{canSelect && ( {canSelect && (
<Panel <Panel
title="Seleccionar proposta" title="Seleccionar proposta"
@@ -160,8 +175,14 @@ export default function Award({
Seleccione uma proposta no topo Seleccione uma proposta no topo
</option> </option>
{comparison.rows {comparison.rows
.filter((q) => .filter(
comparison.top_ids.includes(q.id), (q) =>
comparison.top_ids.includes(
q.id,
) &&
(!recommendation.adopted ||
recommendation.quotation_id ===
q.id),
) )
.map((q) => ( .map((q) => (
<option key={q.id} value={q.id}> <option key={q.id} value={q.id}>
+16 -10
View File
@@ -53,6 +53,10 @@ const events: Record<string, string> = {
'admission.adopted': 'Política de admissão adoptada expressamente', 'admission.adopted': 'Política de admissão adoptada expressamente',
'admission.proposed': 'Tentativa de decisão de admissão registada', 'admission.proposed': 'Tentativa de decisão de admissão registada',
'admission.reviewed': 'Conferência independente de admissão registada', 'admission.reviewed': 'Conferência independente de admissão registada',
'recommendation.adopted': 'Política de recomendação adoptada expressamente',
'recommendation.proposed': 'Tentativa de recomendação registada',
'recommendation.reviewed':
'Conferência independente da recomendação registada',
'opening.recorded': 'Versão da acta de abertura registada', 'opening.recorded': 'Versão da acta de abertura registada',
'requisition.created': 'Requisição criada', 'requisition.created': 'Requisição criada',
'requisition.updated': 'Requisição actualizada', 'requisition.updated': 'Requisição actualizada',
@@ -276,16 +280,18 @@ export default function PurchaseProcess({
</p> </p>
)} )}
</Panel> </Panel>
<Panel {future.length > 0 && (
title="Etapas previstas no roteiro" <Panel
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída." title="Etapas previstas no roteiro"
> description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2"> >
{future.map((stage) => ( <ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
<li key={stage}>{stage}</li> {future.map((stage) => (
))} <li key={stage}>{stage}</li>
</ul> ))}
</Panel> </ul>
</Panel>
)}
</div> </div>
</> </>
); );
@@ -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 (
<PolicyForm key={latest?.id ?? 0} latest={latest} policies={policies} />
);
}
function PolicyForm({ latest, policies }: Props) {
const form = useForm({
previous_id: latest?.id ?? 0,
enabled: false,
roles: [] as string[],
required_count: '',
reason: '',
});
return (
<>
<Head title="Política de recomendação" />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Configuração da empresa"
title="Política de recomendação"
description="Sistema de Gestão de Compras (SGC) · Configuração da empresa"
/>
<Panel title="Nova versão da política">
<p className="mb-4 text-sm text-muted-foreground">
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.
</p>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post('/politica-recomendacao');
}}
>
<FormErrors errors={form.errors} />
<Label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.enabled}
onChange={(e) =>
form.setData('enabled', e.target.checked)
}
/>
Activar para novas adopções
</Label>
<fieldset className="space-y-2">
<legend className="mb-2 text-sm font-medium">
Perfis autorizados a conferir
</legend>
{['finance', 'department_head'].map((role) => (
<Label
key={role}
className="flex items-center gap-2"
>
<input
type="checkbox"
checked={form.data.roles.includes(role)}
onChange={(e) =>
form.setData(
'roles',
e.target.checked
? [...form.data.roles, role]
: form.data.roles.filter(
(r) => r !== role,
),
)
}
/>
{roleLabels[role] ?? role}
{role === 'department_head' &&
' do departamento do processo'}
</Label>
))}
</fieldset>
<div className="space-y-2">
<Label htmlFor="required-count">
Quantidade de pessoas distintas exigida
</Label>
<Input
id="required-count"
type="number"
min={1}
max={50}
required
value={form.data.required_count}
onChange={(e) =>
form.setData(
'required_count',
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="policy-reason">
Fundamento desta versão
</Label>
<textarea
id="policy-reason"
className="min-h-24 w-full rounded-md border p-3"
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</div>
<Button disabled={form.processing}>
Registar versão {latest ? latest.revision + 1 : 1}
</Button>
</form>
</Panel>
<Panel title="Versões preservadas">
<div className="space-y-4">
{policies.data.length === 0 && (
<p>A empresa ainda não definiu uma política.</p>
)}
{policies.data.map((p) => (
<article
key={p.id}
className="rounded-md border p-4"
>
<h2 className="font-semibold">
Versão {p.revision} ·{' '}
{p.enabled ? 'Activada' : 'Desactivada'} ·{' '}
{date(p.created_at)}
</h2>
<p>
{p.required_count} pessoa(s) ·{' '}
{p.roles
.map((r) => roleLabels[r] ?? r)
.join(', ')}
</p>
<p className="text-sm whitespace-pre-wrap">
{p.reason}
</p>
</article>
))}
<div className="flex gap-4">
{policies.prev_page_url && (
<Link href={policies.prev_page_url}>
Anteriores
</Link>
)}
{policies.next_page_url && (
<Link href={policies.next_page_url}>
Seguintes
</Link>
)}
</div>
</div>
</Panel>
</div>
</>
);
}
@@ -0,0 +1,508 @@
import { Head, Link, router, useForm } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { date, roleLabels } from '@/types/procurement';
type Adoption = {
id: number;
revision: number;
recommendation_policy_id: number;
reason: string;
snapshot: {
roles: string[];
required_count: number;
policy_revision: number;
};
};
type Row = {
id: number;
reference: string;
supplier_name: string;
currency: string;
total_minor: number;
score_minor: number | null;
eligible: boolean;
ineligibility_reasons: string[];
evaluation_count: number;
};
type Review = {
id: number;
verdict: string;
reason: string;
created_at: string;
snapshot: { name: string; purchasing_role: string };
};
type Attempt = {
id: number;
revision: number;
quotation_id: number;
reason: string;
status: string;
current: boolean;
canReview: boolean;
created_at: string;
basis_hash: string;
snapshot: { proponent: { name: string }; basis: { rows: Row[] } };
reviews: Review[];
};
type Props = {
requisition: { id: number; number: string };
policy: { id: number; revision: number; enabled: boolean } | null;
adoption: Adoption | null;
selected: Adoption | null;
adoptions: Adoption[];
hasAdmission: boolean;
basis: { hash: string; ready: boolean; top_ids: number[]; rows: Row[] };
previousId: number;
history: {
data: Attempt[];
prev_page_url: string | null;
next_page_url: string | null;
};
canAdopt: boolean;
canPropose: boolean;
closed: boolean;
};
const statuses: Record<string, string> = {
pending: 'A aguardar conferência',
confirmed: 'Recomendação confirmada',
returned: 'Tentativa devolvida',
};
const amount = (minor: number) =>
(minor / 100).toLocaleString('pt-PT', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function Reason({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>Fundamento</Label>
<textarea
id={id}
required
minLength={10}
maxLength={4000}
className="min-h-24 w-full rounded-lg border p-3 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
function Adopt({
requisition,
policy,
adoption,
hasAdmission,
}: Pick<Props, 'requisition' | 'policy' | 'adoption' | 'hasAdmission'>) {
const form = useForm({
policy_id: policy?.id ?? 0,
previous_id: adoption?.id ?? 0,
reason: '',
});
if (!hasAdmission)
return (
<p>
É necessário adoptar expressamente o regime de admissão antes da
recomendação.
</p>
);
if (!policy?.enabled)
return (
<p>
A empresa ainda não disponibilizou uma política activa de
recomendação.
</p>
);
if (adoption?.recommendation_policy_id === policy.id)
return <p>O processo já adoptou a versão activa.</p>;
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/requisicoes/${requisition.id}/recomendacao/adoptar`,
);
}}
>
<FormErrors errors={form.errors} />
<p>
Adoptar expressamente a política de recomendação versão{' '}
{policy.revision}. Uma nova adopção exige novas tentativas e
conferências.
</p>
<Reason
id="recommendation-adopt-reason"
value={form.data.reason}
onChange={(v) => form.setData('reason', v)}
/>
<Button disabled={form.processing}>
Adoptar política neste processo
</Button>
</form>
);
}
function Propose({
requisition,
basis,
previousId,
}: Pick<Props, 'requisition' | 'basis' | 'previousId'>) {
const form = useForm({
previous_id: previousId,
basis_hash: basis.hash,
quotation_id: '',
reason: '',
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/requisicoes/${requisition.id}/recomendacao/tentativas`,
);
}}
>
<FormErrors errors={form.errors} />
<div className="space-y-2">
<Label htmlFor="recommendation-candidate">
Proposta a recomendar
</Label>
<select
id="recommendation-candidate"
className={controlClass}
required
value={form.data.quotation_id}
onChange={(e) =>
form.setData('quotation_id', e.target.value)
}
>
<option value="">Seleccione expressamente</option>
{basis.rows
.filter((row) => basis.top_ids.includes(row.id))
.map((row) => (
<option key={row.id} value={row.id}>
{row.supplier_name} · {row.reference}
</option>
))}
</select>
</div>
<p className="text-sm text-muted-foreground">
A escolha deve pertencer às maiores pontuações. Em empate,
fundamente a escolha entre as empatadas. Uma nova tentativa
preserva a anterior e começa sem conferências.
</p>
<Reason
id="recommendation-propose-reason"
value={form.data.reason}
onChange={(v) => form.setData('reason', v)}
/>
<Button disabled={form.processing}>
Registar nova tentativa de recomendação
</Button>
</form>
);
}
function Confer({ attempt }: { attempt: Attempt }) {
const form = useForm({ verdict: '', reason: '' });
return (
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/recomendacoes/${attempt.id}/conferir`);
}}
>
<FormErrors errors={form.errors} />
<div className="space-y-2">
<Label htmlFor={`recommendation-review-${attempt.id}`}>
Conferência independente
</Label>
<select
id={`recommendation-review-${attempt.id}`}
className={controlClass}
required
value={form.data.verdict}
onChange={(e) => form.setData('verdict', e.target.value)}
>
<option value="">Seleccione</option>
<option value="approved">Confirmar a recomendação</option>
<option value="returned">Devolver esta tentativa</option>
</select>
</div>
<Reason
id={`recommendation-review-reason-${attempt.id}`}
value={form.data.reason}
onChange={(v) => form.setData('reason', v)}
/>
<Button disabled={form.processing}>Registar conferência</Button>
</form>
);
}
function Matrix({ rows, topIds }: { rows: Row[]; topIds?: number[] }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b">
<tr>
<th className="p-2">Fornecedor e proposta</th>
<th className="p-2">Total</th>
<th className="p-2">Avaliações elegíveis</th>
<th className="p-2">Pontuação / 10</th>
<th className="p-2">Situação</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b">
<td className="p-2">
{row.supplier_name} · {row.reference}
</td>
<td className="p-2 whitespace-nowrap">
{amount(row.total_minor)} {row.currency}
</td>
<td className="p-2">{row.evaluation_count}</td>
<td className="p-2">
{row.score_minor === null
? 'Por concluir'
: amount(row.score_minor)}
</td>
<td className="p-2">
{row.eligible
? 'Elegível na comparação'
: row.ineligibility_reasons.join(' ')}
{topIds?.includes(row.id) &&
' · Maior pontuação'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function Recommendation(props: Props) {
const {
requisition,
policy,
adoption,
selected,
adoptions,
basis,
previousId,
history,
canAdopt,
canPropose,
closed,
hasAdmission,
} = props;
const base = `/requisicoes/${requisition.id}`;
return (
<>
<Head title={`Recomendação · ${requisition.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Compras"
title={`Recomendação · ${requisition.number}`}
description="Sistema de Gestão de Compras (SGC) · Decisão fundamentada antes da adjudicação"
/>
<nav className="flex flex-wrap gap-4 text-sm underline">
<Link href={`${base}/processo`}>Processo completo</Link>
<Link href={`${base}/admissao`}>Admissão</Link>
<Link href={`${base}/cotacoes`}>Comparação</Link>
<Link href={`${base}/adjudicacao`}>Adjudicação</Link>
</nav>
<Panel title="Regime do processo">
<p className="mb-4">
{adoption
? `Adopção ${adoption.revision} · política ${adoption.snapshot.policy_revision} · ${adoption.snapshot.required_count} pessoas distintas · ${adoption.snapshot.roles.map((r) => roleLabels[r] ?? r).join(', ')}.`
: 'Sem adopção do regime de recomendação: mantém-se o circuito existente.'}
</p>
<p className="mb-4 text-sm text-muted-foreground">
A recomendação não autoriza compra nem substitui
orçamento ou aprovações do valor final. Conserva o
cálculo ponderado e os mínimos existentes de três
fornecedores elegíveis e duas avaliações por proposta.
</p>
{closed && (
<p>
Existindo adjudicação, o processo está fechado para
novas adopções, tentativas e conferências. Uma base
alterada exige o circuito de revisão autorizado.
</p>
)}
{canAdopt && (
<Adopt
key={`${policy?.id}-${adoption?.id}`}
requisition={requisition}
policy={policy}
adoption={adoption}
hasAdmission={hasAdmission}
/>
)}
</Panel>
<Panel title="Comparação actual">
<p className="mb-4 text-sm">
{basis.ready
? 'Matriz pronta para recomendação fundamentada.'
: 'Resolva a admissão ou os impedimentos da comparação antes de recomendar.'}{' '}
Os valores desta matriz são os actuais; cada tentativa
conserva a sua própria base.
</p>
<Matrix
rows={basis.rows}
topIds={basis.ready ? basis.top_ids : []}
/>
</Panel>
{adoptions.length > 0 && (
<Panel title="Histórico de adopções">
<Label htmlFor="recommendation-adoption-version">
Versão consultada
</Label>
<select
id="recommendation-adoption-version"
className={`${controlClass} mt-2`}
value={selected?.id ?? ''}
onChange={(e) =>
router.get(`${base}/recomendacao`, {
version: e.target.value,
})
}
>
{adoptions.map((a) => (
<option key={a.id} value={a.id}>
Adopção {a.revision} · política{' '}
{a.snapshot.policy_revision}
{a.id === adoption?.id
? ' · actual'
: ' · histórica'}
</option>
))}
</select>
<p className="mt-3 text-sm whitespace-pre-wrap">
{selected?.reason}
</p>
</Panel>
)}
{canPropose && (
<Panel title="Proposta manual por Compras">
<Propose
key={`${selected?.id}-${previousId}-${basis.hash}`}
requisition={requisition}
basis={basis}
previousId={previousId}
/>
</Panel>
)}
<Panel title="Tentativas e conferências">
<div className="space-y-5">
{history.data.length === 0 && (
<p>Sem tentativas nesta adopção.</p>
)}
{history.data.map((attempt) => (
<article
className="rounded-lg border p-4"
key={attempt.id}
>
<h2 className="font-semibold">
Tentativa {attempt.revision} ·{' '}
{
attempt.snapshot.basis.rows.find(
(row) =>
row.id === attempt.quotation_id,
)?.supplier_name
}{' '}
· {statuses[attempt.status]}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{date(attempt.created_at)} ·{' '}
{attempt.snapshot.proponent.name}
{!attempt.current &&
' · histórica ou base desactualizada; não utilizável para adjudicar'}
</p>
<p className="my-4 whitespace-pre-wrap">
{attempt.reason}
</p>
<details className="mb-4 text-sm">
<summary className="cursor-pointer font-medium">
Matriz preservada nesta tentativa
</summary>
<Matrix
rows={attempt.snapshot.basis.rows}
/>
<p className="mt-2 text-xs break-all">
Base: {attempt.basis_hash}
</p>
</details>
{attempt.reviews.map((review) => (
<div
key={review.id}
className="mt-3 rounded-lg bg-muted p-3 text-sm"
>
<p>
{review.snapshot.name} ·{' '}
{roleLabels[
review.snapshot.purchasing_role
] ??
review.snapshot
.purchasing_role}{' '}
·{' '}
{review.verdict === 'approved'
? 'Confirmou'
: 'Devolveu'}{' '}
· {date(review.created_at)}
</p>
<p className="mt-2 whitespace-pre-wrap">
{review.reason}
</p>
</div>
))}
{attempt.canReview && (
<Confer attempt={attempt} />
)}
<a
className="mt-4 inline-block text-sm underline"
href={`/recomendacoes/${attempt.id}/imprimir`}
>
Consultar relatório para impressão
</a>
</article>
))}
<p className="text-sm text-muted-foreground">
Uma devolução encerra a tentativa sem excluir o
concorrente ou cancelar o processo. As conferências
não são transferidas para outra tentativa.
</p>
<div className="flex gap-4">
{history.prev_page_url && (
<Link href={history.prev_page_url}>
Anteriores
</Link>
)}
{history.next_page_url && (
<Link href={history.next_page_url}>
Seguintes
</Link>
)}
</div>
</div>
</Panel>
</div>
</>
);
}
@@ -54,6 +54,7 @@ type Report = {
requester: string; requester: string;
}; };
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] }; comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
recommendation: { id: number; revision: number } | null;
admission: { admission: {
id: number; id: number;
revision: number; revision: number;
@@ -169,6 +170,17 @@ export default function ReviewReport({ report }: { report: Report }) {
a qualificação documental são apresentadas separadamente. a qualificação documental são apresentadas separadamente.
</div> </div>
<Panel title="Identificação do pedido"> <Panel title="Identificação do pedido">
<p className="mb-4 text-sm">
{report.recommendation
? 'Regime de recomendação adoptado. A adjudicação exige recomendação confirmada e actual.'
: 'Sem adopção do regime de recomendação.'}{' '}
<Link
className="underline print:hidden"
href={`/requisicoes/${r.id}/recomendacao`}
>
Consultar recomendação e relatório preservado
</Link>
</p>
<p className="mb-4 text-sm"> <p className="mb-4 text-sm">
{report.admission {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.` ? `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.`
+6
View File
@@ -128,6 +128,12 @@ export default function Comparison({
> >
Admissão e exclusão de propostas Admissão e exclusão de propostas
</Link> </Link>
<Link
className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/recomendacao`}
>
Recomendação antes da adjudicação
</Link>
<Link <Link
className="text-sm font-medium underline" className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/relatorio`} href={`/requisicoes/${requisition.id}/relatorio`}
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recomendação · {{ $requisition->number }} · Sistema de Gestão de Compras (SGC)</title>
<style>
@page { size: A4 landscape; margin: 14mm; }
body { font: 12px Arial, sans-serif; color: #111; margin: 24px; }
header { border-bottom: 3px solid #ed1c24; margin-bottom: 18px; padding-bottom: 12px; }
h1 { font-size: 21px; } h2 { margin-top: 22px; font-size: 16px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #bbb; padding: 7px; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
th { background: #eee; } thead { display: table-header-group; }
tr, article { break-inside: avoid; } p { overflow-wrap: anywhere; }
.reason { white-space: pre-wrap; } .notice { border: 1px solid #999; padding: 12px; }
article { border-left: 3px solid #ddd; padding-left: 12px; margin: 14px 0; }
footer { border-top: 1px solid #999; margin-top: 24px; padding-top: 10px; }
@media print { .controls { display: none; } body { margin: 0; } }
</style>
</head>
<body>
<nav class="controls"><a href="{{ route('recommendations.show', $requisition) }}">Voltar ao processo</a> · <button onclick="window.print()">Imprimir</button></nav>
<header><strong>Sistema de Gestão de Compras (SGC)</strong><h1>Recomendação antes da adjudicação · {{ $requisition->number }}</h1><p>Adopção {{ $adoption->revision }} · política {{ $adoption->snapshot['policy_revision'] }} · tentativa {{ $attempt->revision }}</p></header>
@php
$states = ['pending' => 'A aguardar conferência', 'confirmed' => 'Recomendação confirmada', 'returned' => 'Tentativa devolvida'];
$rows = $attempt->snapshot['basis']['rows'];
$candidate = collect($rows)->firstWhere('id', $attempt->quotation_id);
@endphp
<p class="notice"><strong>{{ $states[$state] }} · {{ $current ? 'Base actual na consulta' : 'Histórica ou base desactualizada: não utilizável para adjudicar' }}</strong><br>Recomendação não autoriza compra, não substitui aprovações do valor final e não constitui assinatura digital ou certificação de evidências.</p>
<p>Consulta por {{ $generatedBy }} em {{ $generatedAt->format('d/m/Y H:i:s') }} · Africa/Johannesburg (UTC+2). A situação actual pode mudar após esta consulta.</p>
<h2>Proposta recomendada</h2><p><strong>{{ $candidate['supplier_name'] ?? 'Fornecedor indisponível' }}</strong> · {{ $candidate['reference'] ?? $attempt->quotation_id }}</p>
<p>Proponente: {{ $attempt->snapshot['proponent']['name'] }} · Registo: {{ $attempt->created_at }} UTC</p><p class="reason">{{ $attempt->reason }}</p>
<p>Política preservada: {{ $adoption->snapshot['required_count'] }} pessoas distintas; {{ implode(', ', array_map(fn ($role) => \App\Enums\PurchasingRole::from($role)->label(), $adoption->snapshot['roles'])) }}.</p>
<p>Base preservada: {{ $attempt->basis_hash }}</p>
<h2>Matriz preservada na tentativa</h2>
<table><thead><tr><th>Fornecedor e proposta</th><th>Total</th><th>Avaliações elegíveis</th><th>Pontuação / 10</th><th>Situação na base preservada</th></tr></thead><tbody>
@foreach($rows as $row)
<tr><td>{{ $row['supplier_name'] }} · {{ $row['reference'] }}</td><td>{{ number_format($row['total_minor'] / 100, 2, ',', ' ') }} {{ $row['currency'] }}</td><td>{{ $row['evaluation_count'] }}</td><td>{{ $row['score_minor'] === null ? 'Por concluir' : number_format($row['score_minor'] / 100, 2, ',', ' ') }}</td><td>{{ $row['eligible'] ? 'Elegível, com admissão confirmada' : implode(' ', $row['ineligibility_reasons']) }}{{ in_array($row['id'], $attempt->snapshot['basis']['top_ids'], true) ? ' · Maior pontuação' : '' }}</td></tr>
@endforeach
</tbody></table>
<h2>Referências de admissão preservadas</h2>
@foreach($attempt->snapshot['basis']['admissions'] as $admission)
<p>Proposta #{{ $admission['quotation_id'] }} · requisitos #{{ $admission['basis']['set_id'] ?? 'não disponíveis' }} · decisão #{{ $admission['decision']['id'] ?? 'não disponível' }} · conferências {{ implode(', ', array_column($admission['reviews'], 'id')) ?: 'sem registos' }}.</p>
@endforeach
<h2>Conferências desta tentativa</h2>
@forelse($reviews as $review)
<article><strong>{{ $review->snapshot['name'] }} · {{ \App\Enums\PurchasingRole::from($review->snapshot['purchasing_role'])->label() }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }}</strong><p>{{ $review->created_at }} UTC</p><p class="reason">{{ $review->reason }}</p></article>
@empty<p>Sem conferências registadas.</p>@endforelse
<h2>Histórico desta adopção até à tentativa impressa</h2>
@forelse($history as $entry)
<article><strong>Tentativa {{ $entry['attempt']->revision }} · proposta #{{ $entry['attempt']->quotation_id }} · {{ $states[$entry['state']] }} · histórica</strong><p class="reason">{{ $entry['attempt']->reason }}</p>
@foreach($entry['reviews'] as $review)<p class="reason">{{ $review->snapshot['name'] }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }} · {{ $review->created_at }} UTC · {{ $review->reason }}</p>@endforeach</article>
@empty<p>Sem tentativas anteriores nesta adopção.</p>@endforelse
<footer>Sistema de Gestão de Compras (SGC) · Registos preservados para conferência. Outras adopções são consultáveis no histórico autenticado do processo.</footer>
</body>
</html>
+8
View File
@@ -8,6 +8,7 @@ use App\Http\Controllers\DocumentaryQualificationController;
use App\Http\Controllers\EquipmentController; use App\Http\Controllers\EquipmentController;
use App\Http\Controllers\ExternalPaymentController; use App\Http\Controllers\ExternalPaymentController;
use App\Http\Controllers\FormalAdmissionController; use App\Http\Controllers\FormalAdmissionController;
use App\Http\Controllers\FormalRecommendationController;
use App\Http\Controllers\GoodsReceiptController; use App\Http\Controllers\GoodsReceiptController;
use App\Http\Controllers\InvestmentPolicyController; use App\Http\Controllers\InvestmentPolicyController;
use App\Http\Controllers\InvoiceController; use App\Http\Controllers\InvoiceController;
@@ -36,6 +37,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store'); Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () { Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy');
Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']);
Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show');
Route::post('requisicoes/{requisition}/recomendacao/adoptar', [FormalRecommendationController::class, 'adopt']);
Route::post('requisicoes/{requisition}/recomendacao/tentativas', [FormalRecommendationController::class, 'propose']);
Route::post('recomendacoes/{attempt}/conferir', [FormalRecommendationController::class, 'review']);
Route::get('recomendacoes/{attempt}/imprimir', [FormalRecommendationController::class, 'print'])->name('recommendations.print');
Route::get('politica-admissao', [FormalAdmissionController::class, 'policy'])->name('admissions.policy'); Route::get('politica-admissao', [FormalAdmissionController::class, 'policy'])->name('admissions.policy');
Route::post('politica-admissao', [FormalAdmissionController::class, 'configure']); Route::post('politica-admissao', [FormalAdmissionController::class, 'configure']);
Route::get('requisicoes/{requisition}/admissao', [FormalAdmissionController::class, 'show'])->name('admissions.show'); Route::get('requisicoes/{requisition}/admissao', [FormalAdmissionController::class, 'show'])->name('admissions.show');
@@ -0,0 +1,42 @@
<?php
use App\Models\RecommendationAttempt;
use App\Models\Requisition;
use App\Models\User;
use App\Services\FormalRecommendation;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
require __DIR__.'/../../vendor/autoload.php';
$database = $argv[1] ?? '';
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
throw new LogicException('Base de teste inválida.');
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
throw new LogicException('Worker não isolado.');
}
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_recommendation_'.$argv[4]]);
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
$actor = User::query()->findOrFail($argv[2]);
auth()->guard()->setUser($actor);
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
try {
if (($payload['action'] ?? 'review') === 'propose') {
app(FormalRecommendation::class)->propose($actor, Requisition::query()->findOrFail($payload['requisition_id']), $payload['data']);
} elseif ($payload['action'] === 'award') {
app(PurchaseWorkflow::class)->select($actor, Requisition::query()->findOrFail($payload['requisition_id']), $payload['data']);
} else {
app(FormalRecommendation::class)->review($actor, RecommendationAttempt::query()->findOrFail($argv[3]), $payload['verdict'], 'Conferência exclusivamente fictícia');
}
$status = 200;
} catch (ValidationException) {
$status = 422;
} catch (HttpExceptionInterface $exception) {
$status = $exception->getStatusCode();
}
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
+161
View File
@@ -0,0 +1,161 @@
<?php
use App\Models\AuditLog;
use App\Models\PurchaseAward;
use App\Models\RecommendationAttempt;
use App\Models\RecommendationReview;
use App\Services\FormalRecommendation;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require __DIR__.'/../Support/RecommendationFixture.php';
$original = DB::connection();
if ($original->getDriverName() !== 'pgsql') {
throw new LogicException('Exige PostgreSQL.');
}
$config = $original->getConfig();
$admin = $original->getPdo();
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
$created = false;
$processes = [];
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
try {
$admin->exec('CREATE DATABASE "'.$database.'"');
$created = true;
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
DB::purge('pgsql');
DB::setDefaultConnection('pgsql');
Schema::clearResolvedInstance('db.schema');
app()->forgetInstance('db.schema');
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
throw new LogicException('Migração fora do isolamento recusada.');
}
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
foreach (['same_person', 'distinct_people', 'opposing_reviews', 'revoked_role', 'changed_basis', 'new_adoption', 'competing_attempts', 'recommendation_versus_award'] as $scenario) {
$f = recommendationFixture(2);
$workflow = app(FormalRecommendation::class);
$decision = $workflow->propose($f['buyer'], $f['r'], $f['proposal']);
if ($scenario === 'recommendation_versus_award') {
foreach ([$f['reviewer'], $f['other_reviewer']] as $reviewer) {
auth()->guard()->setUser($reviewer);
$workflow->review($reviewer, $decision, 'approved', 'Confirmação fictícia antes da corrida');
}
auth()->guard()->setUser($f['buyer']);
}
$beforeReviews = RecommendationReview::query()->count();
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $f['tenant']->id)->lockForUpdate()->first();
$processes = [];
foreach (['A', 'B'] as $index => $suffix) {
$actor = $scenario === 'distinct_people' && $index === 1 ? $f['other_reviewer'] : $f['reviewer'];
$verdict = $scenario === 'opposing_reviews' && $index === 1 ? 'returned' : 'approved';
$payload = ['action' => 'review', 'verdict' => $verdict];
if (in_array($scenario, ['competing_attempts', 'recommendation_versus_award'], true)) {
$actor = $f['buyer'];
$award = $scenario === 'recommendation_versus_award' && $index === 1;
$payload = ['action' => $award ? 'award' : 'propose', 'requisition_id' => $f['r']->id, 'data' => $award ? $f['selection'] : [...$f['proposal'], 'previous_id' => $decision->id]];
}
$process = new Process([PHP_BINARY, __DIR__.'/recommendation-worker.php', $database, (string) $actor->id, (string) $decision->id, $suffix], base_path(), $workerEnv);
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
$process->setTimeout(40);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_recommendation_A','sgc_recommendation_B') AND wait_event_type = 'Lock'");
$deadline = microtime(true) + 20;
do {
$statement->execute([$database]);
$waits = (int) $statement->fetchColumn();
if ($waits === 2) {
break;
}
foreach ($processes as $process) {
if (! $process->isRunning()) {
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
}
}
usleep(20000);
} while (microtime(true) < $deadline);
if ($waits !== 2) {
throw new RuntimeException('Sem sobreposição dos dois bloqueios.');
}
if ($scenario === 'revoked_role') {
$connection->table('users')->where('id', $f['reviewer']->id)->update(['purchasing_role' => 'requester']);
}
if ($scenario === 'changed_basis') {
$connection->table('proposal_evaluations')->where('quotation_id', $f['quote']->id)->update(['comment' => 'Alteração fictícia durante a espera']);
}
if ($scenario === 'new_adoption') {
auth()->guard()->setUser($f['admin']);
$policy = $workflow->policy($f['admin'], [...$f['policy_data'], 'previous_id' => $f['policy']->id]);
auth()->guard()->setUser($f['buyer']);
$workflow->adopt($f['buyer'], $f['r'], $policy->id, $f['adoption']->id, 'Nova adopção enquanto os conferentes aguardam');
}
$connection->commit();
$results = [];
foreach ($processes as $process) {
$process->wait();
if (! $process->isSuccessful()) {
throw new RuntimeException('Worker falhou: '.$process->getErrorOutput());
}
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
}
$statuses = array_column($results, 'status');
sort($statuses);
$expected = match ($scenario) {
'revoked_role' => [403, 403], 'changed_basis', 'new_adoption' => [422, 422], 'opposing_reviews', 'competing_attempts', 'recommendation_versus_award' => [200, 422], default => [200, 200]
};
auth()->guard()->setUser($f['buyer']);
$reviews = RecommendationReview::query()->count();
$expectedReviews = match ($scenario) {
'distinct_people' => 2, 'same_person', 'opposing_reviews' => 1, default => 0
};
$expectedReviews += $beforeReviews;
if ($statuses !== $expected || $reviews !== $expectedReviews || AuditLog::query()->where('action', 'recommendation.reviewed')->count() !== $reviews || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
throw new RuntimeException('Resultado concorrente inconsistente: '.json_encode([$scenario, $statuses, $reviews], JSON_THROW_ON_ERROR));
}
$state = $workflow->status($decision, $f['adoption']);
$attempts = RecommendationAttempt::query()->count();
$awards = PurchaseAward::query()->count();
$newAttempt = $scenario === 'competing_attempts' || ($scenario === 'recommendation_versus_award' && $results[0]['status'] === 200);
if (($scenario === 'distinct_people' && $state !== 'confirmed') || ($scenario === 'same_person' && $state !== 'pending') || $attempts !== ($newAttempt ? 2 : 1) || $awards !== ($scenario === 'recommendation_versus_award' && ! $newAttempt ? 1 : 0)) {
throw new RuntimeException('Quórum ou tentativa alterados indevidamente.');
}
if ($awards && PurchaseAward::query()->sole()->recommendation_snapshot['attempt_id'] !== $decision->id) {
throw new RuntimeException('Adjudicação ligada à tentativa errada.');
}
if (AuditLog::query()->where('action', 'recommendation.proposed')->count() !== $attempts || AuditLog::query()->where('action', 'award.created')->count() !== $awards) {
throw new RuntimeException('Auditoria de tentativa/adjudicação inconsistente.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'reviews' => $reviews, 'state' => $state, 'attempts' => $attempts, 'awards' => $awards];
}
$report['passed'] = true;
} finally {
foreach ($processes as $process) {
if ($process->isRunning()) {
$process->stop();
}
}
if (isset($connection) && $connection->transactionLevel() > 0) {
$connection->rollBack();
}
DB::disconnect('pgsql');
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
$admin->exec('DROP DATABASE "'.$database.'"');
$report['test_database_dropped'] = true;
}
}
file_put_contents(base_path('docs/recomendacao-concorrencia-postgresql.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
+275
View File
@@ -0,0 +1,275 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AdmissionDecision;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\ConflictDeclaration;
use App\Models\Department;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\RecommendationAdoption;
use App\Models\RecommendationAttempt;
use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Tenant;
use App\Models\User;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use App\Services\ProposalComparison;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/RecommendationFixture.php';
beforeEach(function () {
$this->f = recommendationFixture();
$this->url = '/requisicoes/'.$this->f['r']->id.'/recomendacao';
$this->awardUrl = '/requisicoes/'.$this->f['r']->id.'/adjudicacao';
$this->actingAs($this->f['buyer']);
});
function recommendationAttempt(object $test, array $changes = []): RecommendationAttempt
{
$test->actingAs($test->f['buyer'])->post($test->url.'/tentativas', [...$test->f['proposal'], ...$changes])->assertSessionHasNoErrors();
return RecommendationAttempt::query()->latest('id')->firstOrFail();
}
function recommendationConfer(object $test, RecommendationAttempt $attempt, ?User $actor = null, string $verdict = 'approved'): void
{
$test->actingAs($actor ?? $test->f['reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => $verdict, 'reason' => 'Conferência de recomendação fictícia'])->assertSessionHasNoErrors();
}
test('company recommendation requires quorum and binds genuine award and issued order', function () {
$before = app(ProposalComparison::class)->inspect($this->f['r']);
$attempt = recommendationAttempt($this);
$this->get($this->awardUrl)->assertInertia(fn (Assert $p) => $p->where('canSelect', false));
$this->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
recommendationConfer($this, $attempt);
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('pending')->and(BudgetEntry::query()->count())->toBe(0);
recommendationConfer($this, $attempt, $this->f['head']);
expect(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
$award = PurchaseAward::query()->sole();
expect($award->recommendation_snapshot['attempt_id'])->toBe($attempt->id)->and($award->recommendation_snapshot['review_ids'])->toHaveCount(2);
$this->actingAs($this->f['head'])->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
$this->actingAs($this->f['buyer'])->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
$order = PurchaseOrder::query()->sole();
expect($order->snapshot['recommendation'])->toBe($award->recommendation_snapshot);
$this->f['quotes'][0]->update(['reference' => 'Alteração posterior à emissão']);
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->refresh()->snapshot['quotation']['reference'])->toBe('PROP-TEST');
$this->post($this->url.'/tentativas', [...$this->f['proposal'], 'previous_id' => $attempt->id])->assertSessionHasErrors('recommendation');
});
test('recommendation retries count one person once and returned attempt cannot authorize award', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt);
expect(RecommendationReview::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'recommendation.reviewed')->count())->toBe(1);
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Outra resposta para a mesma pessoa'])->assertSessionHasErrors('recommendation');
recommendationConfer($this, $attempt, $this->f['other_reviewer'], 'returned');
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('returned')->and(AdmissionDecision::query()->where('outcome', 'admit')->count())->toBe(3);
$next = recommendationAttempt($this, ['previous_id' => $attempt->id]);
expect($next->revision)->toBe(2)->and(RecommendationReview::query()->where('recommendation_attempt_id', $next->id)->count())->toBe(0);
$this->post($this->url.'/tentativas', $this->f['proposal'])->assertSessionHasErrors('recommendation');
$this->actingAs($this->f['head'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de tentativa anterior'])->assertSessionHasErrors('recommendation');
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
});
test('recommendation rejects lower score incomplete matrix and stale admission', function (string $case) {
if ($case === 'incomplete') {
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->where('evaluator_id', $this->f['analyst']->id)->delete();
} elseif ($case === 'admission') {
$old = AdmissionDecision::query()->where('quotation_id', $this->f['quotes'][0]->id)->latest('id')->firstOrFail();
app(FormalAdmission::class)->propose($this->f['buyer'], $this->f['r'], ['quotation_id' => $old->quotation_id, 'previous_id' => $old->id, 'basis_hash' => $old->basis_hash, 'outcome' => 'admit', 'reason' => 'Nova tentativa de admissão sem confirmação']);
}
$data = [...$this->f['proposal'], 'basis_hash' => app(FormalRecommendation::class)->basis($this->f['r'])['hash']];
if ($case === 'lower') {
$data['quotation_id'] = $this->f['quotes'][2]->id;
}
$this->post($this->url.'/tentativas', $data)->assertSessionHasErrors('recommendation');
expect(RecommendationAttempt::query()->count())->toBe(0);
})->with(['lower', 'incomplete', 'admission']);
test('tie permits reasoned recommendation and award only of the chosen tied proposal', function () {
// Only evaluations change, preserving documentary admission basis.
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->update(['quality' => '0', 'history' => '0', 'payment' => '0']);
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][1]->id)->update(['quality' => '5', 'history' => '5', 'payment' => '2.5']);
// Existing weights yield 5.50 for each of the first two proposals, without changing received prices.
$this->f['quotes'][2]->evaluations()->update(['quality' => '0', 'history' => '0', 'payment' => '0']);
$basis = app(FormalRecommendation::class)->basis($this->f['r']);
expect($basis['ready'])->toBeTrue()->and($basis['top_ids'])->toHaveCount(2)->toContain($this->f['quotes'][0]->id, $this->f['quotes'][1]->id);
$attempt = recommendationAttempt($this, ['basis_hash' => $basis['hash'], 'quotation_id' => $this->f['quotes'][1]->id, 'reason' => 'Escolha fundamentada da segunda proposta empatada']);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
$this->post($this->awardUrl, [...$this->f['selection'], 'quotation_id' => $this->f['quotes'][1]->id])->assertSessionHasNoErrors();
expect(PurchaseAward::query()->sole()->quotation_id)->toBe($this->f['quotes'][1]->id);
});
test('changed matrix invalidates pending reviews and blocks emission after award', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt, $this->f['head']);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
$award = PurchaseAward::query()->sole();
$this->actingAs($this->f['head'])->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][2]->id)->update(['comment' => 'Análise alterada antes da emissão']);
$this->actingAs($this->f['buyer'])->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('recommendation');
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('changed evaluation requires a new recommendation with no inherited approvals', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][1]->id)->update(['comment' => 'Nova fundamentação da avaliação']);
$this->actingAs($this->f['other_reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência sobre uma base anterior'])->assertSessionHasErrors('recommendation');
$next = recommendationAttempt($this, ['previous_id' => $attempt->id, 'basis_hash' => app(FormalRecommendation::class)->basis($this->f['r'])['hash']]);
expect($next->revision)->toBe(2)->and(RecommendationReview::query()->where('recommendation_attempt_id', $next->id)->count())->toBe(0)->and(RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->count())->toBe(1);
});
test('new confirmed admission invalidates recommendation even with identical scores and prices', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
$before = app(ProposalComparison::class)->inspect($this->f['r']);
$this->actingAs($this->f['buyer']);
$admission = app(FormalAdmission::class);
$old = AdmissionDecision::query()->where('quotation_id', $this->f['quotes'][1]->id)->latest('id')->firstOrFail();
$new = $admission->propose($this->f['buyer'], $this->f['r'], ['quotation_id' => $old->quotation_id, 'previous_id' => $old->id, 'basis_hash' => $old->basis_hash, 'outcome' => 'admit', 'reason' => 'Reapreciação de admissão fundamentada']);
$this->actingAs($this->f['analyst']);
$admission->review($this->f['analyst'], $new, 'approved', 'Nova conferência de admissão fictícia');
expect(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
expect(PurchaseAward::query()->count())->toBe(0);
});
test('one identity cannot count again after role change and review audit failure rolls back', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::DepartmentHead, 'department_id' => $this->f['r']->department_id])->save();
recommendationConfer($this, $attempt, $this->f['reviewer']->fresh());
expect(RecommendationReview::query()->count())->toBe(1)->and(RecommendationReview::query()->sole()->snapshot['purchasing_role'])->toBe('finance');
$this->actingAs($this->f['head']);
DB::statement("CREATE TRIGGER fail_recommendation_review BEFORE INSERT ON audit_logs WHEN NEW.action = 'recommendation.reviewed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
try {
app(FormalRecommendation::class)->review($this->f['head'], $attempt, 'approved', 'Confirmação que deve ser revertida');
$this->fail('Missing conference rollback');
} catch (QueryException) {
expect(RecommendationReview::query()->count())->toBe(1)->and(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('pending');
} finally {
DB::statement('DROP TRIGGER fail_recommendation_review');
}
$other = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra chefia']);
$this->f['reviewer']->forceFill(['department_id' => $other->id])->save();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferente anterior mudou de departamento'])->assertSessionHasErrors('recommendation');
});
test('current and earlier reviewer eligibility is revalidated while completed history remains', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->actingAs($this->f['other_reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Primeiro conferente perdeu o perfil'])->assertSessionHasErrors('recommendation');
$this->f['other_reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Perfil revogado durante a sessão'])->assertForbidden();
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
recommendationConfer($this, $attempt, $this->f['head']);
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
expect(app(FormalRecommendation::class)->selection($this->f['r'], $attempt->quotation_id)['review_ids'])->toHaveCount(2);
});
test('proponent requester and wrong department cannot confirm recommendations', function () {
$attempt = recommendationAttempt($this);
$this->f['buyer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
$this->actingAs($this->f['buyer']->fresh())->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Auto confirmação indevida'])->assertForbidden();
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
$this->actingAs($this->f['issuer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Solicitante não pode conferir'])->assertForbidden();
$other = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento distinto']);
$this->f['head']->forceFill(['department_id' => $other->id])->save();
$this->actingAs($this->f['head'])->get($this->url)->assertNotFound();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Chefia de outro departamento'])->assertNotFound();
expect(RecommendationReview::query()->count())->toBe(0);
});
test('policy changes preserve adoption until explicit new version and reset recommendations', function () {
$attempt = recommendationAttempt($this);
recommendationConfer($this, $attempt);
$workflow = app(FormalRecommendation::class);
$this->actingAs($this->f['admin']);
$disabled = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'required_count' => 1, 'enabled' => false]);
$this->actingAs($this->f['buyer'])->post($this->url.'/adoptar', ['previous_id' => $this->f['adoption']->id, 'policy_id' => $disabled->id, 'reason' => 'Adopção com política desactivada'])->assertSessionHasErrors('recommendation');
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
expect($workflow->status($attempt, $this->f['adoption']))->toBe('confirmed')->and($this->f['adoption']->refresh()->snapshot['required_count'])->toBe(2);
$this->actingAs($this->f['admin']);
$new = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $disabled->id, 'required_count' => 1]);
$this->actingAs($this->f['buyer']);
$workflow->adopt($this->f['buyer'], $this->f['r'], $new->id, $this->f['adoption']->id, 'Outra versão expressamente adoptada');
$this->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
expect(RecommendationAttempt::query()->count())->toBe(1);
});
test('policy is admin only explicit and independent from admission and absent adoption keeps legacy circuit', function () {
$this->post('/politica-recomendacao', $this->f['policy_data'])->assertForbidden();
$this->actingAs($this->f['admin'])->post('/politica-recomendacao', ['previous_id' => $this->f['policy']->id, 'enabled' => true, 'roles' => [], 'reason' => 'Sem valores definidos pela empresa'])->assertSessionHasErrors(['roles', 'required_count']);
$this->post('/politica-recomendacao', $this->f['policy_data'])->assertSessionHasErrors('recommendation');
$this->get('/politica-recomendacao')->assertInertia(fn (Assert $p) => $p->component('purchases/recommendation-policy')->has('policies.data', 1));
// Remove only synthetic adoption from the isolated test DB to exercise the legacy path.
DB::table('recommendation_adoptions')->delete();
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
$award = PurchaseAward::query()->sole();
expect($award->recommendation_snapshot)->toBeNull();
$this->post($this->url.'/adoptar', ['policy_id' => $this->f['policy']->id, 'previous_id' => 0, 'reason' => 'Adopção retroactiva impedida'])->assertSessionHasErrors('recommendation');
});
test('recommendation requires prior formal admission adoption and no implicit policy', function () {
// All fixture rows belong only to this in-memory database.
DB::table('recommendation_adoptions')->delete();
DB::table('admission_reviews')->delete();
DB::table('admission_decisions')->delete();
DB::table('admission_adoptions')->delete();
$this->post($this->url.'/adoptar', ['policy_id' => $this->f['policy']->id, 'previous_id' => 0, 'reason' => 'Sem adopção formal de admissão'])->assertSessionHasErrors('recommendation');
expect(RecommendationAdoption::query()->count())->toBe(0)->and(RecommendationPolicy::query()->count())->toBe(1);
});
test('report and history are restricted read only preserve old basis and escape user text', function () {
$attempt = recommendationAttempt($this, ['reason' => '<script>alert("ficticio")</script>Fundamento fictício']);
recommendationConfer($this, $attempt, null, 'returned');
$next = recommendationAttempt($this, ['previous_id' => $attempt->id]);
$before = AuditLog::query()->count();
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('purchases/recommendation')->where('history.data.0.id', $next->id)->where('history.data.1.status', 'returned')->where('basis.ready', true));
$this->get('/recomendacoes/'.$next->id.'/imprimir')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertSee('Sistema de Gestão de Compras (SGC)')->assertSee('Histórico desta adopção')->assertSee('&lt;script&gt;', false)->assertDontSee('<script>alert', false);
$this->f['quotes'][0]->update(['reference' => 'Referência actual diferente']);
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertOk()->assertSee('Histórica ou base desactualizada')->assertSee('PROP-TEST')->assertDontSee('Referência actual diferente');
expect(AuditLog::query()->count())->toBe($before);
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
foreach ([$this->f['issuer'], $this->f['receiver']] as $reader) {
$this->actingAs($reader)->get($this->url)->assertForbidden();
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertForbidden();
}
});
test('other tenant conflict and audit rollback cannot introduce recommendation decisions', function () {
$attempt = recommendationAttempt($this);
$tenant = Tenant::query()->create(['name' => 'Empresa fictícia distinta']);
$other = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($other)->get($this->url)->assertNotFound();
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertNotFound();
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de empresa distinta'])->assertNotFound();
$this->actingAs($this->f['buyer']);
DB::statement("CREATE TRIGGER fail_recommendation_audit BEFORE INSERT ON audit_logs WHEN NEW.action = 'recommendation.proposed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
try {
app(FormalRecommendation::class)->propose($this->f['buyer'], $this->f['r'], [...$this->f['proposal'], 'previous_id' => $attempt->id]);
$this->fail('Missing audit rollback');
} catch (QueryException) {
expect(RecommendationAttempt::query()->count())->toBe(1);
} finally {
DB::statement('DROP TRIGGER fail_recommendation_audit');
}
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['reviewer']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício documentado']);
$this->actingAs($this->f['reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict');
expect(RecommendationReview::query()->count())->toBe(0);
});
+1 -1
View File
@@ -21,7 +21,7 @@ test('process overview links records without declaring partial execution complet
->where('stages.7.state', 'recorded') ->where('stages.7.state', 'recorded')
->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.') ->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
->where('stages.8.state', 'not_started') ->where('stages.8.state', 'not_started')
->has('future', 1) ->has('future', 0)
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number))); ->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
}); });
+76
View File
@@ -0,0 +1,76 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\Supplier;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use App\Services\ProposalOpeningWorkflow;
require_once __DIR__.'/QualificationFixture.php';
/** Genuine three-quotation basis; underlying fixture refuses non-isolated databases. */
function recommendationFixture(int $quorum = 2): array
{
$f = qualificationFixture();
$tenantId = $f['tenant']->id;
$departmentId = $f['r']->department_id;
$admin = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Admin]);
$buyer = User::factory()->create(['tenant_id' => $tenantId, 'department_id' => $departmentId, 'purchasing_role' => PurchasingRole::Procurement]);
$head = User::factory()->create(['tenant_id' => $tenantId, 'department_id' => $departmentId, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$reviewer = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Finance]);
$otherReviewer = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Finance]);
auth()->guard()->setUser($buyer);
$f['r']->update(['approval_snapshot' => ['first_limit_minor' => 2500000, 'second_limit_minor' => 15000000, 'approval_includes_tax' => true, 'rules_provisional' => true]]);
$f['r']->steps()->create(['tenant_id' => $tenantId, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $head->id, 'decided_at' => now()]);
$supplier = Supplier::query()->findOrFail($f['quote']->supplier_id);
$quotes = [$f['quote']];
for ($i = 1; $i <= 2; $i++) {
$otherSupplier = $supplier->replicate();
$otherSupplier->fill(['nuit' => '12345678'.$i, 'name' => 'Fornecedor fictício '.$i])->save();
$quote = $f['quote']->replicate();
$quote->fill(['supplier_id' => $otherSupplier->id, 'reference' => 'PROP-FICTICIA-'.$i])->save();
$quotes[] = $quote;
}
$item = $f['r']->items()->firstOrFail();
foreach ($quotes as $index => $quote) {
$amount = 10000 * ($index + 1);
$quote->update(['created_by' => $buyer->id, 'subtotal_minor' => $amount, 'total_minor' => $amount]);
$quote->items()->create(['tenant_id' => $tenantId, 'requisition_item_id' => $item->id, 'description' => $item->description, 'quantity' => '1.000', 'unit_price_minor' => $amount, 'tax_rate' => '0', 'subtotal_minor' => $amount, 'tax_minor' => 0, 'total_minor' => $amount]);
foreach ([$buyer, $f['analyst']] as $evaluator) {
ProposalEvaluation::query()->create(['tenant_id' => $tenantId, 'quotation_id' => $quote->id, 'evaluator_id' => $evaluator->id, 'quality' => '8', 'history' => '5', 'payment' => '8', 'no_conflict' => true, 'comment' => 'Avaliação exclusivamente fictícia']);
}
}
$opening = app(ProposalOpeningWorkflow::class);
$documentary = app(DocumentaryQualificationWorkflow::class);
$set = $documentary->define($buyer, $f['r'], [...$f['definition'], 'basis_hash' => $opening->basis($opening->proposals($f['r']))]);
$answers = $f['assessment']['answers'];
$answers[1] = ['criterion_id' => 2, 'result' => 'met', 'evidence' => 'Documento fictício', 'reason' => 'Conformidade exclusivamente fictícia'];
foreach ($quotes as $quote) {
auth()->guard()->setUser($f['analyst']);
$documentary->assess($f['analyst'], $set, [...$f['assessment'], 'quotation_id' => $quote->id, 'answers' => $answers]);
}
$admission = app(FormalAdmission::class);
auth()->guard()->setUser($admin);
$admissionPolicy = $admission->policy($admin, ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance'], 'required_count' => 1, 'reason' => 'Política de admissão exclusivamente fictícia']);
auth()->guard()->setUser($buyer);
$admissionAdoption = $admission->adopt($buyer, $f['r'], $admissionPolicy->id, 0, 'Adopção de admissão exclusivamente fictícia');
foreach ($quotes as $quote) {
auth()->guard()->setUser($buyer);
$decision = $admission->propose($buyer, $f['r'], ['quotation_id' => $quote->id, 'previous_id' => 0, 'basis_hash' => $admission->basis($f['r'], $quote->id)['hash'], 'outcome' => 'admit', 'reason' => 'Proposta de admissão exclusivamente fictícia']);
auth()->guard()->setUser($f['analyst']);
$admission->review($f['analyst'], $decision, 'approved', 'Conferência de admissão exclusivamente fictícia');
}
$workflow = app(FormalRecommendation::class);
$policyData = ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance', 'department_head'], 'required_count' => $quorum, 'reason' => 'Política de recomendação exclusivamente fictícia'];
auth()->guard()->setUser($admin);
$policy = $workflow->policy($admin, $policyData);
auth()->guard()->setUser($buyer);
$adoption = $workflow->adopt($buyer, $f['r'], $policy->id, 0, 'Adopção de recomendação exclusivamente fictícia');
$proposal = ['previous_id' => 0, 'quotation_id' => $quotes[0]->id, 'basis_hash' => $workflow->basis($f['r'])['hash'], 'reason' => 'Recomendação exclusivamente fictícia'];
$selection = ['quotation_id' => $quotes[0]->id, 'justification' => 'Proposta de maior pontuação com recomendação.', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Armazém fictício'];
return [...$f, 'quotes' => $quotes, 'set' => $set, 'admin' => $admin, 'buyer' => $buyer, 'head' => $head, 'reviewer' => $reviewer, 'other_reviewer' => $otherReviewer, 'admission_policy' => $admissionPolicy, 'admission_adoption' => $admissionAdoption, 'policy' => $policy, 'policy_data' => $policyData, 'adoption' => $adoption, 'proposal' => $proposal, 'selection' => $selection];
}
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000020.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = DB::connection();
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
}
$pdo = $connection->getPdo();
$migration = '2026_10_28_000020_create_formal_recommendations';
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
throw new LogicException('Migração já aplicada; não repetir.');
}
$config = $connection->getConfig();
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
if (! is_file($bin.$tool)) {
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
}
}
$envHash = hash_file('sha256', base_path('.env'));
$directory = storage_path('app/private/backups/formal-recommendation-000020-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Não foi possível criar backup privado.');
}
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
$columns = [];
$fingerprints = function (PDO $db, array $tables) use ($quote, &$columns): array {
$result = [];
foreach ($tables as $table) {
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM (SELECT '.implode(', ', array_map($quote, $columns[$table])).' FROM public.'.$quote($table).') t')->fetchAll(PDO::FETCH_COLUMN);
sort($rows, SORT_STRING);
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
}
return $result;
};
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$statement = $pdo->prepare("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position");
$statement->execute([$table]);
$columns[$table] = $statement->fetchAll(PDO::FETCH_COLUMN);
}
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
$restoreCreated = false;
try {
// pg_dump and the manifest read exactly the same committed snapshot.
$pdo->beginTransaction();
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
$backupState = $fingerprints($pdo, $tables);
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
$dump->setTimeout(120);
$dump->run();
if (! $dump->isSuccessful()) {
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
}
$pdo->commit();
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
$restoreCreated = true;
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
$restore->setTimeout(120);
$restore->run();
if (! $restore->isSuccessful()) {
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
}
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
$restored = DB::connection('budget_restore')->getPdo();
if ($fingerprints($restored, $tables) !== $backupState) {
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
}
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'columns' => $columns, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
DB::disconnect('budget_restore');
unset($restored);
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
$restoreCreated = false;
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
$before = $fingerprints($pdo, $businessTables);
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
if ($exit !== 0) {
throw new RuntimeException('Migração falhou; consultar backup privado.');
}
$after = $fingerprints($pdo, $businessTables);
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
}
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'recommendation_policies_count' => (int) $pdo->query('SELECT count(*) FROM recommendation_policies')->fetchColumn(), 'recommendation_adoptions_count' => (int) $pdo->query('SELECT count(*) FROM recommendation_adoptions')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/recomendacao-migracao-000020.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
DB::disconnect('budget_restore');
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
}
}