Implementa admissão formal por política e adopção do processo

This commit is contained in:
2026-10-28 20:28:15 +02:00
parent 03ab308e02
commit d020be53ed
33 changed files with 1926 additions and 30 deletions
@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\AdmissionAdoption;
use App\Models\AdmissionDecision;
use App\Models\AdmissionPolicy;
use App\Models\AdmissionReview;
use App\Models\PurchaseAward;
use App\Models\Requisition;
use App\Services\ConflictGuard;
use App\Services\FormalAdmission;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalAdmissionController 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/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function configure(Request $request, FormalAdmission $workflow): RedirectResponse
{
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
$data['enabled'] = (bool) $data['enabled'];
$workflow->policy($request->user(), $data);
return to_route('admissions.policy');
}
public function show(Request $request, Requisition $requisition, FormalAdmission $workflow, ProposalOpeningWorkflow $opening): Response
{
$requisition = $this->access($request, $requisition->id);
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
$adoption = $workflow->adoption($requisition);
$selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
$current = $selected && $selected->id === $adoption?->id;
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get();
$latestIds = $decisions->unique('quotation_id')->pluck('id');
$proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) {
$basis = $workflow->basis($requisition, $quote['id']);
return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0];
});
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString();
$history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) {
$basis = $workflow->basis($requisition, $decision->quotation_id);
$fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash;
return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh,
'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(),
'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
});
Inertia::encryptHistory();
$response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function adopt(Request $request, Requisition $requisition, FormalAdmission $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
return to_route('admissions.show', $requisition);
}
public function propose(Request $request, Requisition $requisition, FormalAdmission $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'outcome' => 'required|in:admit,exclude,clarify', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
$workflow->propose($request->user(), $requisition, $data);
return to_route('admissions.show', $requisition);
}
public function review(Request $request, AdmissionDecision $decision, FormalAdmission $workflow): RedirectResponse
{
$adoption = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
$this->access($request, $adoption->requisition_id);
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
$workflow->review($request->user(), $decision, $data['verdict'], $data['reason']);
return to_route('admissions.show', $adoption->requisition_id);
}
}
@@ -39,6 +39,7 @@ class PurchaseProcessController extends Controller
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
if ($purchases) {
$documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio'];
$documents[] = ['label' => 'Regime, decisões e conferências', 'kind' => 'Admissão de propostas', 'href' => $base.'/admissao'];
}
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($qualification) {
@@ -111,7 +112,7 @@ class PurchaseProcessController extends Controller
}
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) {
$actions = [...$actions, 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
$actions = [...$actions, 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
}
if ($inventory) {
$actions[] = 'receipt.confirmed';
@@ -126,7 +127,7 @@ class PurchaseProcessController extends Controller
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
'future' => ['Decisão formal de admissão ou exclusão', 'Relatório de recomendação em etapas'],
'future' => ['Relatório de recomendação em etapas'],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+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 AdmissionAdoption 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 AdmissionDecision 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 AdmissionPolicy 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 AdmissionReview extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+2 -1
View File
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property array<string, mixed> $approval_snapshot
* @property list<array<string, mixed>> $decision_matrix
* @property array<string,mixed>|null $investment_snapshot
* @property array<string,mixed>|null $admission_snapshot
*/
class PurchaseAward extends Model
{
@@ -26,7 +27,7 @@ class PurchaseAward extends Model
protected function casts(): array
{
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
}
/** @return HasMany<PurchaseAwardApproval, $this> */
+1
View File
@@ -86,6 +86,7 @@ class ConflictGuard
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
'opening.recorded',
'qualification.defined', 'qualification.assessed',
'admission.adopted', 'admission.proposed', 'admission.reviewed',
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected',
+247
View File
@@ -0,0 +1,247 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\AdmissionAdoption;
use App\Models\AdmissionDecision;
use App\Models\AdmissionPolicy;
use App\Models\AdmissionReview;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Quotation;
use App\Models\QuotationItem;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class FormalAdmission
{
private function invalid(string $message): never
{
throw ValidationException::withMessages(['admission' => $message]);
}
public function adoption(Requisition $request): ?AdmissionAdoption
{
return AdmissionAdoption::query()->where('requisition_id', $request->id)->latest('revision')->first();
}
private function process(User $actor, int $id): Requisition
{
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
$this->invalid('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.');
}
app(ConflictGuard::class)->assertParticipation($actor, $request);
$quotes = Quotation::query()->where('requisition_id', $id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
return $request;
}
/** @param array<string,mixed> $data */
public function policy(User $actor, array $data): AdmissionPolicy
{
if (! is_bool($data['enabled']) || ! is_array($data['roles']) || $data['roles'] === [] || count(array_unique($data['roles'])) !== count($data['roles']) || array_diff($data['roles'], ['finance', 'department_head']) !== [] || filter_var($data['required_count'], FILTER_VALIDATE_INT) === false || $data['required_count'] < 1 || $data['required_count'] > 50 || mb_strlen(trim($data['reason'])) < 10) {
$this->invalid('Defina expressamente perfis, quantidade de conferentes e fundamento da política.');
}
return DB::transaction(function () use ($actor, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]);
$latest = AdmissionPolicy::query()->latest('revision')->first();
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
$this->invalid('A política mudou. Actualize antes de criar outra versão.');
}
$policy = AdmissionPolicy::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'enabled' => $data['enabled'], 'roles' => array_values($data['roles']), 'required_count' => $data['required_count'], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.policy', null, null, $policy->toArray());
return $policy;
});
}
public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): AdmissionAdoption
{
return DB::transaction(function () use ($actor, $request, $policyId, $previousId, $reason) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$request = $this->process($actor, $request->id);
$previous = $this->adoption($request);
$policy = AdmissionPolicy::query()->latest('revision')->first();
if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId) {
$this->invalid('Seleccione a política activa e a adopção actual. Não se altera um regime implicitamente.');
}
if ($previous?->admission_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) {
$this->invalid('A adopção exige outra versão de política e fundamento próprio.');
}
$adoption = AdmissionAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'admission_policy_id' => $policy->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'snapshot' => ['roles' => $policy->roles, 'required_count' => $policy->required_count, 'policy_revision' => $policy->revision], 'reason' => $reason, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]);
return $adoption;
});
}
/** @return array{set_id:int,payload:array<string,mixed>,hash:string,clear:bool}|null */
public function basis(Requisition $request, int $quoteId): ?array
{
$set = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
if (! $set) {
return null;
}
$opening = app(ProposalOpeningWorkflow::class);
$quotes = $opening->proposals($request);
if ($set->basis_hash !== $opening->basis($quotes)) {
return null;
}
$quote = collect($quotes)->firstWhere('id', $quoteId);
if (! $quote) {
return null;
}
$ids = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('quotation_id', $quoteId)->selectRaw('MAX(id)')->groupBy('analyst_id');
$records = QualificationAssessment::query()->whereIn('id', $ids)->orderBy('analyst_id')->get();
$clear = $records->isNotEmpty();
foreach ($set->criteria as $criterion) {
$results = [];
foreach ($records as $record) {
$answer = collect($record->answers)->firstWhere('criterion_id', $criterion['id']);
if (! $answer || ! in_array($answer['result'], ['met', 'not_met', 'not_applicable'], true) || ($criterion['required'] && $answer['result'] !== 'met')) {
$clear = false;
}
if ($answer) {
$results[] = $answer['result'];
}
}
if (count(array_unique($results)) !== 1) {
$clear = false;
}
}
$payload = ['set_id' => $set->id, 'criteria' => $set->criteria, 'quotes_hash' => $set->basis_hash, 'quote' => $quote, 'analyses' => $records->map(fn ($record) => $record->only(['id', 'analyst_id', 'revision', 'answers', 'snapshot']))->all()];
return ['set_id' => $set->id, 'payload' => $payload, 'hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'clear' => $clear];
}
public function status(AdmissionDecision $decision, AdmissionAdoption $adoption): string
{
$reviews = AdmissionReview::query()->where('admission_decision_id', $decision->id)->get();
if ($reviews->contains('verdict', 'returned')) {
return 'returned';
}
return $reviews->where('verdict', 'approved')->count() >= $adoption->snapshot['required_count'] ? 'confirmed' : 'pending';
}
/** @param array<string,mixed> $data */
public function propose(User $actor, Requisition $request, array $data): AdmissionDecision
{
if (! in_array($data['outcome'], ['admit', 'exclude', 'clarify'], true) || mb_strlen(trim($data['reason'])) < 10) {
$this->invalid('Seleccione uma decisão e fundamente a tentativa.');
}
return DB::transaction(function () use ($actor, $request, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$request = $this->process($actor, $request->id);
abort_if($actor->id === $request->requester_id, 403);
$adoption = $this->adoption($request);
if (! $adoption) {
$this->invalid('O processo ainda não adoptou expressamente o regime.');
}
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor);
$basis = $this->basis($request, $quote->id);
if (! $basis || $basis['hash'] !== $data['basis_hash']) {
$this->invalid('Confira requisitos e análises actuais antes de propor uma decisão.');
}
if ($data['outcome'] === 'admit' && ! $basis['clear']) {
$this->invalid('A admissão exige análise sem pendências, divergências ou requisitos obrigatórios não resolvidos.');
}
$previous = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quote->id)->latest('revision')->first();
if ((int) $data['previous_id'] !== ($previous->id ?? 0)) {
$this->invalid('A tentativa mudou. Actualize a página para preservar o antecedente correcto.');
}
$decision = AdmissionDecision::query()->create(['tenant_id' => $actor->tenant_id, 'admission_adoption_id' => $adoption->id, 'qualification_set_id' => $basis['set_id'], 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'outcome' => $data['outcome'], 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name'])], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.proposed', $request, null, ['decision' => $decision->toArray(), 'previous_id' => $previous?->id]);
return $decision;
});
}
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
{
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
}
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
{
if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) {
$this->invalid('Seleccione uma conferência válida e fundamente-a.');
}
DB::transaction(function () use ($actor, $decision, $verdict, $reason) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$original = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
$request = $this->process($actor, $original->requisition_id);
$adoption = $this->adoption($request);
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403);
$latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
$basis = $this->basis($request, $decision->quotation_id);
if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) {
$this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.');
}
$existing = AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->first();
if ($existing) {
if ($existing->verdict !== $verdict || $existing->reason !== $reason) {
$this->invalid('A conferência anterior é imutável.');
}
return;
}
if ($this->status($decision, $adoption) !== 'pending') {
$this->invalid('Esta tentativa já foi concluída ou devolvida.');
}
if ($verdict === 'approved') {
foreach (AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('verdict', 'approved')->get() as $review) {
$reviewer = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($review->reviewed_by);
if (! $reviewer || ! $this->eligible($reviewer, $request, $adoption, $decision)) {
$this->invalid('Um conferente anterior deixou de ser elegível antes da conclusão. Devolva e prepare outra tentativa.');
}
}
}
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray());
});
}
/** @return array<string,mixed>|null */
public function selection(Requisition $request, int $quoteId): ?array
{
$adoption = $this->adoption($request);
if (! $adoption) {
return null;
}
$decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $quoteId)->latest('revision')->first();
$basis = $this->basis($request, $quoteId);
if (! $decision || $decision->outcome !== 'admit' || ! $basis || ! $basis['clear'] || $basis['hash'] !== $decision->basis_hash || $this->status($decision, $adoption) !== 'confirmed') {
$this->invalid('A proposta exige admissão confirmada na base actual do regime adoptado.');
}
return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->admission_policy_id, 'decision_id' => $decision->id, 'qualification_set_id' => $decision->qualification_set_id, 'basis_hash' => $decision->basis_hash, 'review_ids' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->pluck('id')->all()];
}
public function admitted(Requisition $request, int $quoteId): bool
{
try {
$this->selection($request, $quoteId);
return true;
} catch (ValidationException) {
return false;
}
}
}
+1
View File
@@ -66,6 +66,7 @@ class ProcurementReviewReport
'requisition' => [...$request->only(['id', 'number', 'status', 'justification']), 'department' => $request->department?->name, 'requester' => $request->requester?->name],
'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows],
'qualification' => $qualification,
'admission' => app(FormalAdmission::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']),
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
'award' => $award?->only(['id', 'status', 'total_minor']),
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
+4
View File
@@ -21,9 +21,13 @@ class ProposalComparison
->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get();
$guard = app(ConflictGuard::class);
$referenceWeights = $quotes->first()?->weights;
$adopted = app(FormalAdmission::class)->adoption($requisition) !== null;
$rows = [];
foreach ($quotes as $quote) {
$reasons = [];
if ($adopted && ! app(FormalAdmission::class)->admitted($requisition, $quote->id)) {
$reasons[] = 'O regime adoptado exige admissão confirmada na base actual.';
}
if (! $quote->supplier?->eligible() || $quote->valid_until < today()) {
$reasons[] = 'Fornecedor não elegível ou proposta expirada.';
}
+6
View File
@@ -38,6 +38,7 @@ class PurchaseWorkflow
$this->sourceApproved($requisition, $rules);
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
$result = $this->checkComparison($user, $requisition, $quote);
$admission = app(FormalAdmission::class)->selection($requisition, $quote->id);
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
$award = PurchaseAward::query()->create([
@@ -47,6 +48,7 @@ class PurchaseWorkflow
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
'investment_snapshot' => $investment,
'admission_snapshot' => $admission,
]);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
foreach ($roles as $position => $role) {
@@ -149,6 +151,7 @@ class PurchaseWorkflow
'quotation' => [...$quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days']), 'valid_until' => $validUntil],
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
'admission' => $award->admission_snapshot,
'estimate_approvals' => $requisition->steps()->get()->toArray(),
'final_approvals' => $award->approvals()->get()->toArray(),
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
@@ -180,6 +183,9 @@ class PurchaseWorkflow
throw ValidationException::withMessages(['investment' => 'A regra de investimento ou a base técnica/orçamental mudou. As aprovações anteriores não autorizam uma emissão com outra base.']);
}
$result = $this->checkComparison($user, $requisition, $quote);
if (app(FormalAdmission::class)->selection($requisition, $quote->id) !== $award->admission_snapshot) {
throw ValidationException::withMessages(['admission' => 'A base de admissão já não corresponde à adjudicação. Não se emite com outra base.']);
}
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
@@ -0,0 +1,68 @@
<?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('admission_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('admission_adoptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
$table->foreignId('admission_policy_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->unsignedInteger('revision');
$table->json('snapshot');
$table->text('reason');
$table->timestamp('created_at');
$table->unique(['requisition_id', 'revision']);
});
Schema::create('admission_decisions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('admission_adoption_id')->constrained()->restrictOnDelete();
$table->foreignId('qualification_set_id')->constrained()->restrictOnDelete();
$table->foreignId('quotation_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->unsignedInteger('revision');
$table->string('outcome');
$table->string('basis_hash', 64);
$table->json('snapshot');
$table->text('reason');
$table->timestamp('created_at');
$table->unique(['admission_adoption_id', 'quotation_id', 'revision'], 'admission_decision_version_unique');
});
Schema::create('admission_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('admission_decision_id')->constrained()->restrictOnDelete();
$table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete();
$table->string('verdict');
$table->text('reason');
$table->json('snapshot');
$table->timestamp('created_at');
$table->unique(['admission_decision_id', 'reviewed_by']);
});
Schema::table('purchase_awards', fn (Blueprint $table) => $table->json('admission_snapshot')->nullable());
}
public function down(): void
{
throw new LogicException('Preservar políticas, adopções, decisões e conferências de admissão.');
}
};
@@ -0,0 +1,51 @@
{
"tested_at_utc": "2026-10-28T18:21:31+00:00",
"separate_database": true,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
{
"name": "same_person",
"passed": true,
"overlapping_lock_waits": 2,
"reviews": 1,
"state": "pending"
},
{
"name": "distinct_people",
"passed": true,
"overlapping_lock_waits": 2,
"reviews": 2,
"state": "confirmed"
},
{
"name": "opposing_reviews",
"passed": true,
"overlapping_lock_waits": 2,
"reviews": 1,
"state": "pending"
},
{
"name": "revoked_role",
"passed": true,
"overlapping_lock_waits": 2,
"reviews": 0,
"state": "pending"
},
{
"name": "changed_basis",
"passed": true,
"overlapping_lock_waits": 2,
"reviews": 0,
"state": "pending"
},
{
"name": "new_adoption",
"passed": true,
"overlapping_lock_waits": 2,
"reviews": 0,
"state": "pending"
}
],
"passed": true,
"test_database_dropped": true
}
+16
View File
@@ -0,0 +1,16 @@
{
"verified_at_utc": "2026-10-28T18:23:41+00:00",
"migration": "2026_10_28_000019_create_formal_admission",
"batch": 20,
"backup_created": true,
"backup_bytes": 306342,
"restore_verified": true,
"restored_tables": 57,
"restore_database_dropped": true,
"business_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"admission_policies_count": 0,
"admission_adoptions_count": 0,
"operational_validation": false
}
+26 -16
View File
@@ -1,10 +1,10 @@
# Proposta de âmbito para admissão e exclusão de propostas
Estado: proposta para validação. Não implementa decisões formais na base de
negócio e não activa regras de exclusão ou adjudicação. Data: 28 de Outubro
de 2026.
Estado: âmbito validado pelo utilizador e implementado em 28 de Outubro de 2026. O circuito real é opcional por empresa e por processo; nenhuma política
real foi criada ou activada durante esta entrega. A demonstração pública
continua separada dos registos autenticados.
## Resultado preparado para revisão
## Demonstração preparada antes da validação
`/proposta/admissao` demonstra configuração de conferentes e quantidade de
confirmações, proposta manual por Compras, conferência fundamentada por
@@ -18,7 +18,7 @@ um quórum imposto às empresas. Nenhum valor financeiro dos exemplos locais
é utilizado. Os casos A/B/C são exemplos de análise sem pendências, requisito
obrigatório não cumprido e pendência/divergência, respectivamente.
## Primeiro circuito proposto
## Circuito inicial validado
1. Administração da empresa configura e activa expressamente uma política
versionada: perfis de conferência e quantidade de pessoas distintas exigida.
@@ -57,8 +57,11 @@ obrigatório não cumprido e pendência/divergência, respectivamente.
aplicável podem entrar na selecção de adjudicação. Uma exclusão ou pedido
de esclarecimento não substitui admissão. Admissão não autoriza compra:
mantêm-se pontuação, conflitos, orçamento e aprovação do valor final.
A implementação deverá guardar a base de admissão na adjudicação e
verificar coerência antes da emissão. Não se alteram ordens já emitidas.
A implementação guarda a base de admissão na adjudicação e na ordem e
verifica coerência antes da emissão. Não se alteram ordens já emitidas.
Mantêm-se o mínimo existente de três fornecedores elegíveis e duas
avaliações por proposta. Neste regime, os três fornecedores precisam
também de propostas admitidas; não se implementou uma dispensa do mínimo.
Estas são escolhas propostas para o primeiro circuito suportado, não normas
legais ou obrigações universais deduzidas dos modelos históricos. Cada empresa
@@ -76,28 +79,35 @@ validação solicitada é do âmbito do produto; não publica políticas reais.
decisão de admissão/exclusão.
- Cancelamento de ordens, devoluções, estornos ou reversões financeiras.
## Verificações previstas para a implementação
## Verificações da implementação
Identidade e segregação no servidor; política/base preservadas; requisitos
pendentes ou obrigatórios não resolvidos; conferências repetidas; devolução;
perfil/departamento revogado; empresas diferentes; tentativas desactualizadas;
admissão versus adjudicação/emissão; rollback de auditoria e concorrência
PostgreSQL. A demonstração não substitui estes testes da futura implementação.
PostgreSQL. Os resultados efectivos estão em `docs/validacao-tecnica.md`;
a demonstração não substitui os testes da implementação autenticada.
## Decisão solicitada
## Validação recebida e activação
Validar o circuito inicial: **decisão manual por Compras, confirmações
O utilizador validou o circuito inicial: **decisão manual por Compras, confirmações
independentes em quantidade/perfis definidos pela empresa e integração na
adjudicação apenas por adopção explícita do regime**, sem admitir propostas
com pendências ou requisitos obrigatórios não resolvidos neste primeiro âmbito.
Pausa-se a implementação com dados reais para esta validação, conforme a
orientação de continuidade: «definir com a empresa o próximo âmbito pendente
antes de o implementar», em `docs/orientacoes-continuidade.md`. A autorização
anterior da qualificação era documental e não activava admissão/exclusão.
Esta validação autoriza a implementação do produto, sem publicar uma política
por conta das aderentes. A Administração configura `/politica-admissao` com
quantidade e perfis expressos. Compras adopta essa versão em
`/requisicoes/{requisition}/admissao` antes de adjudicar. Sem adopção, mantém-se
o circuito anterior. A desactivação geral impede novas adopções, preservando
as versões já adoptadas. Uma nova tentativa substitui a anterior para efeitos
de elegibilidade, sem apagar o histórico. O limite de 50 confirmações no
formulário é técnico, não um quórum empresarial; assegurar pessoas elegíveis
em número suficiente é responsabilidade da configuração da empresa.
As alterações locais dos três documentos de continuidade foram preservadas.
## Verificações da preparação
## Histórico: verificações da preparação da demonstração
TypeScript, build, Pint e lint/formatação dos ficheiros desta proposta
passaram. Os três testes existentes de consulta pública passaram, com 39
+88
View File
@@ -1,5 +1,93 @@
# Validação técnica do SGC — 28 de Outubro de 2026
## Incremento: admissão e exclusão por adopção expressa
O utilizador validou `docs/proposta-admissao-exclusao.md`. A Administração da
empresa pode registar versões da política em `/politica-admissao`, indicando
expressamente perfis de conferência (Finanças e/ou chefia do departamento),
quantidade de pessoas distintas e fundamento. Não há quantidade nem perfis
pré-seleccionados. O máximo de 50 no formulário é um limite técnico, não uma
regra empresarial. A disponibilidade de pessoas elegíveis deve acompanhar a
configuração da empresa.
Compras adopta a política activa em `/requisicoes/{requisition}/admissao`,
num pedido aprovado e antes de adjudicar. Sem adopção, mantém-se o circuito
existente. A política adoptada fica preservada; desactivar a política geral
impede novas adopções e não reescreve processos existentes. Aplicar outra
versão exige nova adopção expressa e novas decisões/conferências. Não há
regresso implícito ao circuito sem admissão.
Compras propõe admitir, excluir ou pedir esclarecimento interno por proposta,
com fundamento e base preservada de requisitos, propostas e últimas análises
de cada analista. O proponente não pode ser o solicitante. Admitir exige
análise existente sem pendências, respostas em falta ou divergências e com
todos os requisitos obrigatórios cumpridos. Um requisito facultativo
uniformemente não cumprido ou não aplicável não constitui, por si, uma
dispensa de requisito obrigatório.
Os conferentes são pessoas distintas, da mesma empresa, com perfil e
departamento ainda elegíveis, diferentes do solicitante e do proponente,
sem conflito de interesses. Uma conferência repetida com conteúdo idêntico
é idempotente e conta uma vez; outra resposta não reescreve a assinatura.
Uma devolução termina a tentativa sem excluir automaticamente o concorrente.
Corrigir cria nova tentativa, sem copiar confirmações. Antes de concluir o
quórum, as confirmações anteriores são revalidadas; uma decisão concluída
conserva a elegibilidade verificada na sua data. Uma alteração de base,
tentativa ou adopção invalida a utilização de conferências antigas.
A comparação usa o cálculo ponderado existente. Nos processos que adoptaram
o regime, só propostas com admissão confirmada e actual são elegíveis.
Mantêm-se os mínimos existentes de três fornecedores elegíveis e duas
avaliações por proposta, sem dispensas novas. A base da admissão fica na
adjudicação e na ordem; é revalidada antes de aprovar/emitir. Não se aplicam
regras retroactivas a adjudicações/ordens existentes nem se reemitem ordens
já emitidas. A admissão, sozinha, não movimenta orçamento, facturas ou
pagamentos. O relatório de conferência identifica o regime adoptado; o
processo completo liga às decisões e apresenta os eventos de auditoria.
As novas páginas usam o cabeçalho, rodapé e navegação partilhados. O título
HTML inicial foi corrigido de `ProcuraFlow` para **Sistema de Gestão de
Compras (SGC)**, sem modificar `.env`. O nome visível das novas páginas
segue a mesma identificação.
Migração `000019`, lote **20**, aplicada em `compras_dev` após backup privado
de **306342 bytes**, verificado por restauro numa base separada e eliminada
após a conferência. Foram verificadas **57 tabelas anteriores**, registos de
negócio por projecção das colunas originais, restrições existentes, histórico
de migrações e integridade de `.env`. A migração acrescenta quatro tabelas e
um campo JSON anulável na adjudicação. As quatro tabelas reais estão vazias;
nenhuma política foi publicada por conta das aderentes. O relatório sanitizado
está em `docs/admissao-migracao-000019.json`; o backup/manifesto permanecem
privados, fora do Git. O rollback automático destrutivo está impedido.
Os seis cenários em `docs/admissao-concorrencia-postgresql.json` passaram
com ligações/processos PHP independentes e dois bloqueios simultâneos
observados no PostgreSQL: mesma pessoa, pessoas distintas, respostas
opostas, perfil revogado, base alterada e nova adopção durante a espera.
Conferências, quórum e auditoria foram conferidos; a base de teste foi
eliminada. Não se apresenta esta bateria como um teste de corrida de emissão.
Resultado final do servidor: **478 testes aprovados, 2 ignorados e 3887
asserções**. A bateria inclui os 13 cenários novos de admissão e quatro de
integração real com adjudicação/emissão: base preservada na ordem,
pontuações mantidas, exclusão e mínimo de fornecedores, alterações antes da
emissão, repetição de emissão e adjudicação anterior sem adopção retroactiva.
Os 94 testes dos módulos directamente relacionados também passaram após a
integração do histórico e do campo preservado na ordem. PHPStan e Pint
passaram. TypeScript, build e lint/formatação dos ficheiros deste âmbito
passaram; a verificação global continua limitada pela formatação alheia
indicada abaixo. HTTP 200 confirmado para início, demonstração de admissão
e login, todos com o título inicial Sistema de Gestão de Compras (SGC).
O navegador continua a devolver `The browser access host is unavailable`.
Permanecem pendentes a revisão visual pública, autenticada e de impressão e
as interacções no navegador. As verificações HTTP e automatizadas não a
substituem. Recomendação formal em etapas, excepções/dispensas, comunicação
externa e cancelamento de ordens emitidas ficam fora desta entrega.
Os três documentos locais de continuidade foram preservados sem os incluir
no commit. A formatação global de `docs/compatibilidade.md`, alteração alheia,
continua pendente; a verificação de âmbito exclui esse ficheiro.
## Incremento: relatório de conferência e impressão
As propostas e a visão única do processo incluem o acesso a
+6
View File
@@ -82,6 +82,12 @@ const groups: { label: string; entries: Entry[] }[] = [
demo: '/consulta/investimento',
roles: ['admin', 'finance', 'procurement'],
},
{
label: 'Política de admissão',
path: '/politica-admissao',
demo: '/proposta/admissao',
roles: ['admin'],
},
],
},
{
@@ -0,0 +1,186 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { date, roleLabels } from '@/types/procurement';
type Policy = {
id: number;
revision: number;
enabled: boolean;
roles: string[];
required_count: number;
reason: string;
created_at: string;
};
type Props = {
latest: Policy | null;
policies: {
data: Policy[];
prev_page_url: string | null;
next_page_url: string | null;
};
};
export default function AdmissionPolicy({ latest, policies }: Props) {
return (
<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 admissã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 admissã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-admissao');
}}
>
<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>
</>
);
}
+416
View File
@@ -0,0 +1,416 @@
import { Head, Link, router, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } 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;
admission_policy_id: number;
reason: string;
snapshot: {
roles: string[];
required_count: number;
policy_revision: number;
};
};
type Proposal = {
id: number;
reference: string;
supplier: { name: string } | null;
basisHash: string | null;
clear: boolean;
previousId: number;
};
type Review = {
id: number;
verdict: string;
reason: string;
created_at: string;
snapshot: { name: string; purchasing_role: string };
};
type Decision = {
id: number;
revision: number;
quotation_id: number;
outcome: string;
reason: string;
status: string;
current: boolean;
canReview: boolean;
created_at: string;
snapshot: { proponent: { name: string } };
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[];
proposals: Proposal[];
history: {
data: Decision[];
prev_page_url: string | null;
next_page_url: string | null;
};
canAdopt: boolean;
canPropose: boolean;
closed: boolean;
};
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
const outcomes: Record<string, string> = {
admit: 'Admitir',
exclude: 'Excluir',
clarify: 'Solicitar esclarecimento interno',
};
const statuses: Record<string, string> = {
pending: 'A aguardar conferência',
confirmed: 'Decisão confirmada',
returned: 'Tentativa devolvida',
};
function Reason({
value,
onChange,
id,
}: {
value: string;
onChange: (value: string) => void;
id: string;
}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>Fundamento</Label>
<textarea
className="min-h-24 w-full rounded-md border p-3"
id={id}
required
minLength={10}
maxLength={4000}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
function Adopt({
requisition,
policy,
adoption,
}: Pick<Props, 'requisition' | 'policy' | 'adoption'>) {
const form = useForm({
policy_id: policy?.id ?? 0,
previous_id: adoption?.id ?? 0,
reason: '',
});
if (!policy?.enabled || adoption?.admission_policy_id === policy.id)
return (
<p className="text-sm">
{!policy?.enabled
? 'Sem política activa para nova adopção. A Administração configura a política da empresa.'
: 'O processo já adoptou a versão activa.'}
</p>
);
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/requisicoes/${requisition.id}/admissao/adoptar`);
}}
>
<FormErrors errors={form.errors} />
<p>
Adoptar expressamente a política versão {policy.revision}. A
adopção exige decisões e conferências próprias; não permite
regressar ao circuito sem admissão.
</p>
<Reason
id="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({ proposal, id }: { proposal: Proposal; id: number }) {
const form = useForm({
quotation_id: proposal.id,
previous_id: proposal.previousId,
basis_hash: proposal.basisHash ?? '',
outcome: '',
reason: '',
});
if (!proposal.basisHash)
return (
<p className="text-sm">
Actualize a qualificação documental para poder propor uma
decisão.
</p>
);
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/requisicoes/${id}/admissao/decisoes`);
}}
>
<FormErrors errors={form.errors} />
<div className="space-y-2">
<Label htmlFor={`outcome-${proposal.id}`}>
Proposta de decisão
</Label>
<select
id={`outcome-${proposal.id}`}
className={selectClass}
required
value={form.data.outcome}
onChange={(e) => form.setData('outcome', e.target.value)}
>
<option value="">Seleccione</option>
{Object.entries(outcomes).map(([value, label]) => (
<option
key={value}
value={value}
disabled={value === 'admit' && !proposal.clear}
>
{label}
</option>
))}
</select>
</div>
{!proposal.clear && (
<p className="text-sm text-muted-foreground">
A admissão está impedida: faltam análises, há
pendências/divergências ou requisitos obrigatórios por
resolver.
</p>
)}
<Reason
id={`propose-reason-${proposal.id}`}
value={form.data.reason}
onChange={(v) => form.setData('reason', v)}
/>
<Button disabled={form.processing}>Registar nova tentativa</Button>
</form>
);
}
function Confer({ decision }: { decision: Decision }) {
const form = useForm({ verdict: '', reason: '' });
return (
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/admissoes/${decision.id}/conferir`);
}}
>
<FormErrors errors={form.errors} />
<Label htmlFor={`review-${decision.id}`}>
Conferência independente
</Label>
<select
id={`review-${decision.id}`}
required
className={selectClass}
value={form.data.verdict}
onChange={(e) => form.setData('verdict', e.target.value)}
>
<option value="">Seleccione</option>
<option value="approved">Confirmar a decisão proposta</option>
<option value="returned">Devolver esta tentativa</option>
</select>
<Reason
id={`review-reason-${decision.id}`}
value={form.data.reason}
onChange={(v) => form.setData('reason', v)}
/>
<Button disabled={form.processing}>Registar conferência</Button>
</form>
);
}
export default function Admission({
requisition,
policy,
adoption,
selected,
adoptions,
proposals,
history,
canAdopt,
canPropose,
closed,
}: Props) {
const base = `/requisicoes/${requisition.id}`;
return (
<>
<Head title={`Admissão · ${requisition.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Compras"
title={`Admissão de propostas · ${requisition.number}`}
description="Sistema de Gestão de Compras (SGC) · Decisões e conferências preservadas"
/>
<nav className="mb-5 flex flex-wrap gap-4 text-sm">
<Link href={`${base}/processo`}>Processo completo</Link>
<Link href={`${base}/qualificacao`}>
Qualificação documental
</Link>
<Link href={`${base}/cotacoes`}>Comparaçã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} pessoa(s) distinta(s), ${adoption.snapshot.roles.map((r) => roleLabels[r] ?? r).join(', ')}.`
: 'Sem adopção: mantém-se o circuito de comparação e adjudicação existente.'}
</p>
<p className="mb-4 text-sm text-muted-foreground">
Admissão não autoriza compra. Mantêm-se pontuação,
conflitos, orçamento, aprovações e o mínimo existente de
três fornecedores elegíveis com duas avaliações por
proposta. Esclarecimentos são registos internos, sem
envio de comunicações.
</p>
{closed && (
<p>
O processo está fechado para alterações de admissão.
</p>
)}
{canAdopt && (
<Adopt
key={policy?.id}
requisition={requisition}
policy={policy}
adoption={adoption}
/>
)}
</Panel>
{adoptions.length > 0 && (
<Panel title="Versão consultada">
<Label htmlFor="adoption-version">
Histórico de adopções
</Label>
<select
id="adoption-version"
className={`${selectClass} mt-2`}
value={selected?.id ?? ''}
onChange={(e) =>
router.get(`${base}/admissao`, {
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="Propor decisões">
<div className="space-y-5">
{proposals.map((p) => (
<article
key={`${p.id}-${p.previousId}-${p.basisHash}`}
className="rounded-md border p-4"
>
<h2 className="mb-4 font-semibold">
{p.supplier?.name ?? 'Fornecedor'} ·{' '}
{p.reference}
</h2>
<Propose proposal={p} id={requisition.id} />
</article>
))}
</div>
</Panel>
)}
<Panel title="Tentativas e conferências">
<div className="space-y-5">
{history.data.length === 0 && (
<p>Ainda não há tentativas nesta adopção.</p>
)}
{history.data.map((d) => (
<article
key={d.id}
className="rounded-md border p-4"
>
<h2 className="font-semibold">
{proposals.find(
(p) => p.id === d.quotation_id,
)?.supplier?.name ??
`Proposta ${d.quotation_id}`}{' '}
· tentativa {d.revision} ·{' '}
{outcomes[d.outcome]}
</h2>
<p className="text-sm">
{statuses[d.status]}
{!d.current &&
' · histórica ou base desactualizada'}{' '}
· {date(d.created_at)} ·{' '}
{d.snapshot.proponent.name}
</p>
<p className="my-3 whitespace-pre-wrap">
{d.reason}
</p>
{d.reviews.map((r) => (
<div
key={r.id}
className="mt-2 rounded-md bg-muted p-3 text-sm"
>
<p>
{r.snapshot.name} ·{' '}
{roleLabels[
r.snapshot.purchasing_role
] ??
r.snapshot.purchasing_role}{' '}
·{' '}
{r.verdict === 'approved'
? 'Confirmou'
: 'Devolveu'}{' '}
· {date(r.created_at)}
</p>
<p className="whitespace-pre-wrap">
{r.reason}
</p>
</div>
))}
{d.canReview && <Confer decision={d} />}
</article>
))}
<p className="text-sm text-muted-foreground">
Devolver impede a conclusão da tentativa e exige
nova proposta fundamentada. Não exclui
automaticamente o concorrente.
</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>
</>
);
}
+3
View File
@@ -50,6 +50,9 @@ const labels: Record<string, string> = {
const events: Record<string, string> = {
'qualification.defined': 'Versão dos requisitos documentais registada',
'qualification.assessed': 'Versão da análise documental registada',
'admission.adopted': 'Política de admissão adoptada expressamente',
'admission.proposed': 'Tentativa de decisão de admissão registada',
'admission.reviewed': 'Conferência independente de admissão registada',
'opening.recorded': 'Versão da acta de abertura registada',
'requisition.created': 'Requisição criada',
'requisition.updated': 'Requisição actualizada',
+13 -5
View File
@@ -555,12 +555,20 @@ export default function QualificationPage(props: Props) {
</p>
)}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Requisitos e resultados documentais para conferência. Não
admitem ou excluem concorrentes, certificam evidências ou
alteram a comparação ponderada e a adjudicação. Não
aplicável exige fundamento e não dispensa automaticamente um
requisito obrigatório.
Os requisitos e resultados documentais apoiam a conferência;
não constituem uma decisão formal nem certificam evidências.
Num processo que adoptou o regime de admissão, estas versões
integram a base da decisão e alterações exigem reapreciação.
Não aplicável exige fundamento e não dispensa
automaticamente um requisito obrigatório. As pontuações
mantêm o cálculo ponderado existente.
</div>
<Link
className="text-sm underline"
href={`/requisicoes/${requisition.id}/admissao`}
>
Consultar regime, decisões e conferências de admissão
</Link>
<Link
className="text-sm underline"
href={`/requisicoes/${requisition.id}/cotacoes`}
+24 -4
View File
@@ -54,6 +54,16 @@ type Report = {
requester: string;
};
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
admission: {
id: number;
revision: number;
reason: string;
snapshot: {
policy_revision: number;
required_count: number;
roles: string[];
};
} | null;
qualification: {
id: number;
revision: number;
@@ -159,6 +169,17 @@ export default function ReviewReport({ report }: { report: Report }) {
a qualificação documental são apresentadas separadamente.
</div>
<Panel title="Identificação do pedido">
<p className="mb-4 text-sm">
{report.admission
? `Regime de admissão adoptado: política ${report.admission.snapshot.policy_revision}, ${report.admission.snapshot.required_count} confirmações independentes. A elegibilidade da comparação inclui admissão confirmada na base actual.`
: 'Sem adopção do regime de admissão: mantém-se o circuito de comparação existente.'}{' '}
<Link
className="underline print:hidden"
href={`/requisicoes/${r.id}/admissao`}
>
Consultar decisões e conferências de admissão
</Link>
</p>
{r.status !== 'approved' && (
<p className="mb-3 text-sm text-muted-foreground">
O pedido não está em estado aprovado. As pontuações
@@ -282,10 +303,9 @@ export default function ReviewReport({ report }: { report: Report }) {
</ul>
) : (
<p>
Sem impedimentos
assinalados por esta
matriz. Não equivale
a admissão formal.
{report.admission
? 'Sem impedimentos nesta matriz, incluindo admissão confirmada na base actual. Não autoriza compra.'
: 'Sem impedimentos assinalados por esta matriz. Não equivale a admissão formal.'}
</p>
)}
</td>
+6
View File
@@ -122,6 +122,12 @@ export default function Comparison({
>
Qualificação documental
</Link>
<Link
className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/admissao`}
>
Admissão e exclusão de propostas
</Link>
<Link
className="text-sm font-medium underline"
href={`/requisicoes/${requisition.id}/relatorio`}
+1 -1
View File
@@ -39,7 +39,7 @@
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
<x-inertia::head>
<title>{{ config('app.name', 'Laravel') }}</title>
<title>Sistema de Gestão de Compras (SGC)</title>
</x-inertia::head>
</head>
<body class="font-sans antialiased">
+7
View File
@@ -7,6 +7,7 @@ use App\Http\Controllers\ConflictDeclarationController;
use App\Http\Controllers\DocumentaryQualificationController;
use App\Http\Controllers\EquipmentController;
use App\Http\Controllers\ExternalPaymentController;
use App\Http\Controllers\FormalAdmissionController;
use App\Http\Controllers\GoodsReceiptController;
use App\Http\Controllers\InvestmentPolicyController;
use App\Http\Controllers\InvoiceController;
@@ -35,6 +36,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::get('politica-admissao', [FormalAdmissionController::class, 'policy'])->name('admissions.policy');
Route::post('politica-admissao', [FormalAdmissionController::class, 'configure']);
Route::get('requisicoes/{requisition}/admissao', [FormalAdmissionController::class, 'show'])->name('admissions.show');
Route::post('requisicoes/{requisition}/admissao/adoptar', [FormalAdmissionController::class, 'adopt']);
Route::post('requisicoes/{requisition}/admissao/decisoes', [FormalAdmissionController::class, 'propose']);
Route::post('admissoes/{decision}/conferir', [FormalAdmissionController::class, 'review']);
Route::get('requisicoes/{requisition}/relatorio', [ProcurementReviewReportController::class, 'show'])->name('purchases.report');
Route::get('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'show'])->name('qualifications.show');
Route::post('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'define']);
+34
View File
@@ -0,0 +1,34 @@
<?php
use App\Models\AdmissionDecision;
use App\Models\User;
use App\Services\FormalAdmission;
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_admission_'.$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 {
app(FormalAdmission::class)->review($actor, AdmissionDecision::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);
+136
View File
@@ -0,0 +1,136 @@
<?php
use App\Models\AdmissionDecision;
use App\Models\AdmissionReview;
use App\Models\AuditLog;
use App\Services\FormalAdmission;
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/AdmissionFixture.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'] as $scenario) {
$f = admissionFixture(2);
$workflow = app(FormalAdmission::class);
$decision = $workflow->propose($f['buyer'], $f['r'], $f['proposal']);
$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_analyst'] : $f['analyst'];
$verdict = $scenario === 'opposing_reviews' && $index === 1 ? 'returned' : 'approved';
$process = new Process([PHP_BINARY, __DIR__.'/admission-worker.php', $database, (string) $actor->id, (string) $decision->id, $suffix], base_path(), $workerEnv);
$process->setInput(json_encode(['verdict' => $verdict], 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_admission_A','sgc_admission_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['analyst']->id)->update(['purchasing_role' => 'requester']);
}
if ($scenario === 'changed_basis') {
$connection->table('quotations')->where('id', $f['quote']->id)->update(['reference' => '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' => [200, 422], default => [200, 200]
};
auth()->guard()->setUser($f['buyer']);
$reviews = AdmissionReview::query()->count();
$expectedReviews = match ($scenario) {
'distinct_people' => 2, 'same_person', 'opposing_reviews' => 1, default => 0
};
if ($statuses !== $expected || $reviews !== $expectedReviews || AuditLog::query()->where('action', 'admission.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']);
if (($scenario === 'distinct_people' && $state !== 'confirmed') || ($scenario === 'same_person' && $state !== 'pending') || AdmissionDecision::query()->count() !== 1) {
throw new RuntimeException('Quórum ou tentativa alterados indevidamente.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'reviews' => $reviews, 'state' => $state];
}
$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/admissao-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;
+168
View File
@@ -0,0 +1,168 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AdmissionDecision;
use App\Models\AdmissionPolicy;
use App\Models\AdmissionReview;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\ConflictDeclaration;
use App\Models\Department;
use App\Models\Tenant;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\FormalAdmission;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/AdmissionFixture.php';
beforeEach(function () {
$this->f = admissionFixture();
$this->url = '/requisicoes/'.$this->f['r']->id.'/admissao';
$this->actingAs($this->f['buyer']);
});
function admissionAttempt(object $test, array $changes = []): AdmissionDecision
{
$test->actingAs($test->f['buyer'])->post($test->url.'/decisoes', [...$test->f['proposal'], ...$changes])->assertSessionHasNoErrors();
return AdmissionDecision::query()->latest('id')->firstOrFail();
}
function admissionConfer(object $test, AdmissionDecision $decision, ?User $actor = null, string $verdict = 'approved'): void
{
$test->actingAs($actor ?? $test->f['analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => $verdict, 'reason' => 'Conferência exclusivamente fictícia'])->assertSessionHasNoErrors();
}
test('formal admission requires independent company quorum and preserves snapshots without financial writes', function () {
$decision = admissionAttempt($this);
$workflow = app(FormalAdmission::class);
expect($workflow->status($decision, $this->f['adoption']))->toBe('pending')->and($workflow->admitted($this->f['r'], $decision->quotation_id))->toBeFalse();
admissionConfer($this, $decision);
expect($workflow->status($decision, $this->f['adoption']))->toBe('pending');
admissionConfer($this, $decision, $this->f['other_analyst']);
$snapshot = $workflow->selection($this->f['r'], $decision->quotation_id);
expect($snapshot['review_ids'])->toHaveCount(2)->and($snapshot['decision_id'])->toBe($decision->id)->and(BudgetEntry::query()->count())->toBe(0);
$this->f['analyst']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
expect($workflow->selection($this->f['r'], $decision->quotation_id))->toBe($snapshot);
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('purchases/admission')->where('history.data.0.status', 'confirmed')->has('history.data.0.reviews', 2));
});
test('identical conference retries count once and changed retries cannot overwrite', function () {
$decision = admissionAttempt($this);
admissionConfer($this, $decision);
admissionConfer($this, $decision);
expect(AdmissionReview::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'admission.reviewed')->count())->toBe(1);
$this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Outro fundamento fictício'])->assertSessionHasErrors('admission');
expect(AdmissionReview::query()->sole()->verdict)->toBe('approved');
});
test('return ends attempt without excluding and a new attempt starts with no reviews', function () {
$decision = admissionAttempt($this);
admissionConfer($this, $decision, null, 'returned');
expect(app(FormalAdmission::class)->status($decision, $this->f['adoption']))->toBe('returned')->and($decision->refresh()->outcome)->toBe('admit');
$this->actingAs($this->f['other_analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Outra conferência fictícia'])->assertSessionHasErrors('admission');
$next = admissionAttempt($this, ['previous_id' => $decision->id]);
expect($next->revision)->toBe(2)->and(AdmissionReview::query()->where('admission_decision_id', $next->id)->count())->toBe(0);
$this->post($this->url.'/decisoes', $this->f['proposal'])->assertSessionHasErrors('admission');
});
test('pending divergent and unmet mandatory qualification cannot be admitted', function (string $result) {
$answers = $this->f['assessment']['answers'];
$answers[0]['result'] = $result;
$this->actingAs($this->f['analyst']);
app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $this->f['set'], [...$this->f['assessment'], 'previous_id' => $this->f['record']->id, 'answers' => $answers]);
$basis = app(FormalAdmission::class)->basis($this->f['r'], $this->f['quote']->id);
$this->actingAs($this->f['buyer'])->post($this->url.'/decisoes', [...$this->f['proposal'], 'basis_hash' => $basis['hash']])->assertSessionHasErrors('admission');
$decision = admissionAttempt($this, ['basis_hash' => $basis['hash'], 'outcome' => 'clarify']);
admissionConfer($this, $decision);
admissionConfer($this, $decision, $this->f['other_analyst']);
expect(app(FormalAdmission::class)->admitted($this->f['r'], $decision->quotation_id))->toBeFalse();
})->with(['pending', 'not_met', 'not_applicable']);
test('optional uniform non compliance is permitted but analyst divergence is not', function () {
$answers = $this->f['assessment']['answers'];
$answers[1]['result'] = 'not_met';
$this->actingAs($this->f['analyst']);
app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $this->f['set'], [...$this->f['assessment'], 'previous_id' => $this->f['record']->id, 'answers' => $answers]);
expect(app(FormalAdmission::class)->basis($this->f['r'], $this->f['quote']->id)['clear'])->toBeTrue();
$this->actingAs($this->f['other_analyst']);
app(DocumentaryQualificationWorkflow::class)->assess($this->f['other_analyst'], $this->f['set'], $this->f['assessment']);
expect(app(FormalAdmission::class)->basis($this->f['r'], $this->f['quote']->id)['clear'])->toBeFalse();
});
test('qualification changes invalidate pending reviews and confirmed selection', function () {
$decision = admissionAttempt($this);
admissionConfer($this, $decision);
$this->f['quote']->update(['reference' => 'Base modificada no teste']);
$this->actingAs($this->f['other_analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência sobre base antiga'])->assertSessionHasErrors('admission');
expect(app(FormalAdmission::class)->admitted($this->f['r'], $decision->quotation_id))->toBeFalse()->and(AdmissionReview::query()->count())->toBe(1);
});
test('new company policy never rewrites a pinned process and new adoption resets decisions', function () {
$decision = admissionAttempt($this);
admissionConfer($this, $decision);
$this->actingAs($this->f['admin']);
$workflow = app(FormalAdmission::class);
$new = $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', ['policy_id' => $new->id, 'previous_id' => $this->f['adoption']->id, 'reason' => 'Tentativa fictícia desactivada'])->assertSessionHasErrors('admission');
admissionConfer($this, $decision, $this->f['other_analyst']);
expect($workflow->admitted($this->f['r'], $decision->quotation_id))->toBeTrue()->and($this->f['adoption']->refresh()->snapshot['required_count'])->toBe(2);
$this->actingAs($this->f['admin']);
$enabled = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $new->id, 'required_count' => 1]);
$this->actingAs($this->f['buyer']);
$next = $workflow->adopt($this->f['buyer'], $this->f['r'], $enabled->id, $this->f['adoption']->id, 'Nova versão expressamente adoptada');
expect($next->snapshot['required_count'])->toBe(1)->and($workflow->admitted($this->f['r'], $decision->quotation_id))->toBeFalse()->and(AdmissionDecision::query()->count())->toBe(1);
});
test('self confirmation and stale reviewer roles are rejected and prior approvals revalidated', function () {
$decision = admissionAttempt($this);
$this->f['buyer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
$this->actingAs($this->f['buyer']->fresh())->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Tentativa fictícia de auto aprovação'])->assertForbidden();
admissionConfer($this, $decision);
$this->f['analyst']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->actingAs($this->f['other_analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência com primeiro revogado'])->assertSessionHasErrors('admission');
$this->f['other_analyst']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Conferente entretanto revogado'])->assertForbidden();
expect(AdmissionReview::query()->count())->toBe(1);
});
test('company policy requires explicit values and admin role and optimistic version', function () {
$this->post('/politica-admissao', $this->f['policy_data'])->assertForbidden();
$this->actingAs($this->f['admin'])->post('/politica-admissao', ['previous_id' => $this->f['policy']->id, 'enabled' => true, 'roles' => [], 'reason' => 'Configuração sem quórum definido'])->assertSessionHasErrors(['roles', 'required_count']);
$this->post('/politica-admissao', $this->f['policy_data'])->assertSessionHasErrors('admission');
$this->get('/politica-admissao')->assertOk()->assertInertia(fn (Assert $p) => $p->component('purchases/admission-policy')->has('policies.data', 1));
expect(AdmissionPolicy::query()->count())->toBe(1);
});
test('head department scope requester segregation cross tenant and audit rollback are enforced', function () {
$decision = admissionAttempt($this);
$this->actingAs($this->f['issuer'])->post($this->url.'/decisoes', $this->f['proposal'])->assertForbidden();
$tenant = Tenant::query()->create(['name' => 'Empresa distinta fictícia']);
$outsider = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($outsider)->get($this->url)->assertNotFound();
$this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de outra empresa'])->assertNotFound();
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($head)->get($this->url)->assertNotFound();
$this->actingAs($this->f['buyer']);
$before = AdmissionDecision::query()->count();
DB::statement("CREATE TRIGGER fail_admission_audit BEFORE INSERT ON audit_logs WHEN NEW.action = 'admission.proposed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
try {
app(FormalAdmission::class)->propose($this->f['buyer'], $this->f['r'], [...$this->f['proposal'], 'previous_id' => $decision->id]);
$this->fail('Missing rollback');
} catch (QueryException) {
expect(AdmissionDecision::query()->count())->toBe($before);
} finally {
DB::statement('DROP TRIGGER fail_admission_audit');
}
});
test('active process conflict prevents conference without creating approval', function () {
$decision = admissionAttempt($this);
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['analyst']->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['analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict');
expect(AdmissionReview::query()->count())->toBe(0);
});
+76
View File
@@ -1,6 +1,8 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AdmissionAdoption;
use App\Models\AdmissionDecision;
use App\Models\AuditLog;
use App\Models\AwardCancellation;
use App\Models\Budget;
@@ -27,7 +29,9 @@ use App\Models\User;
use App\Models\Warehouse;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\EquipmentWorkflow;
use App\Services\FormalAdmission;
use App\Services\ProcurementReviewReport;
use App\Services\ProposalComparison;
use App\Services\ProposalOpeningWorkflow;
@@ -57,6 +61,29 @@ function selectPurchaseAward(object $test): PurchaseAward
return PurchaseAward::query()->firstOrFail();
}
function prepareFormalAwardAdmission(object $test): void
{
$workflow = app(FormalAdmission::class);
$test->actingAs($test->admin);
$policy = $workflow->policy($test->admin, ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance', 'department_head'], 'required_count' => 2, 'reason' => 'Política fictícia para adjudicação']);
$test->actingAs($test->buyer);
$workflow->adopt($test->buyer, $test->r, $policy->id, 0, 'Adopção fictícia antes da adjudicação');
$opening = app(ProposalOpeningWorkflow::class);
$documentary = app(DocumentaryQualificationWorkflow::class);
$set = $documentary->define($test->buyer, $test->r, ['previous_id' => 0, 'basis_hash' => $opening->basis($opening->proposals($test->r)), 'reason' => 'Requisito exclusivamente fictício', 'criteria' => [['label' => 'Conformidade documentada', 'category' => 'technical', 'required' => true]]]);
foreach ($test->quotes as $quote) {
$test->actingAs($test->finance);
$documentary->assess($test->finance, $set, ['previous_id' => 0, 'quotation_id' => $quote->id, 'reason' => 'Análise exclusivamente fictícia', 'answers' => [['criterion_id' => 1, 'result' => 'met', 'evidence' => 'Documento fictício', 'reason' => 'Conformidade confirmada no teste']]]);
$test->actingAs($test->buyer);
$decision = $workflow->propose($test->buyer, $test->r, ['quotation_id' => $quote->id, 'previous_id' => 0, 'basis_hash' => $workflow->basis($test->r, $quote->id)['hash'], 'outcome' => 'admit', 'reason' => 'Proposta fundamentada no teste']);
foreach ([$test->finance, $test->head] as $reviewer) {
$test->actingAs($reviewer);
$workflow->review($reviewer, $decision, 'approved', 'Conferência independente fictícia');
}
}
$test->actingAs($test->buyer);
}
function approvePurchaseAward(object $test, PurchaseAward $award): void
{
foreach ($award->approvals()->get() as $step) {
@@ -644,3 +671,52 @@ test('review report reflects tied source scores and existing frozen orders witho
$report = app(ProcurementReviewReport::class)->build($this->buyer, $this->r->id);
expect($report['order']['number'])->toBe($order->number)->and($report['order']['total_minor'])->toBe(10000);
});
test('adopted formal admission preserves scoring and binds award and issued order', function () {
$before = app(ProposalComparison::class)->inspect($this->r);
prepareFormalAwardAdmission($this);
$after = app(ProposalComparison::class)->inspect($this->r);
expect($after['ready'])->toBeTrue()->and(array_column($after['rows'], 'score_minor'))->toBe(array_column($before['rows'], 'score_minor'));
$award = selectPurchaseAward($this);
expect($award->admission_snapshot['review_ids'])->toHaveCount(2)->and($award->admission_snapshot['adoption_id'])->toBeGreaterThan(0);
$order = issuePurchaseOrder($this, $award);
expect($order->snapshot['admission'])->toBe($award->admission_snapshot);
$this->suppliers[0]->update(['name' => 'Alteração posterior à emissão']);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->refresh()->snapshot['supplier']['name'])->toBe('Fornecedor 0');
$this->post('/requisicoes/'.$this->r->id.'/admissao/adoptar', ['policy_id' => 1, 'previous_id' => 1, 'reason' => 'Tentativa após a emissão'])->assertSessionHasErrors('admission');
});
test('formal exclusions cannot enter selection and minimum three eligible suppliers remains', function () {
prepareFormalAwardAdmission($this);
$workflow = app(FormalAdmission::class);
$adoption = $workflow->adoption($this->r);
$previous = AdmissionDecision::query()->where('quotation_id', $this->quotes[0]->id)->latest('id')->firstOrFail();
$decision = $workflow->propose($this->buyer, $this->r, ['quotation_id' => $this->quotes[0]->id, 'previous_id' => $previous->id, 'basis_hash' => $previous->basis_hash, 'outcome' => 'exclude', 'reason' => 'Exclusão fictícia fundamentada']);
foreach ([$this->finance, $this->head] as $reviewer) {
$this->actingAs($reviewer);
$workflow->review($reviewer, $decision, 'approved', 'Conferência de exclusão fictícia');
}
expect($workflow->status($decision, $adoption))->toBe('confirmed')->and(app(ProposalComparison::class)->inspect($this->r)['ready'])->toBeFalse();
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors();
expect(PurchaseAward::query()->count())->toBe(0)->and(BudgetEntry::query()->count())->toBe(0);
});
test('admission basis changes block emission and existing legacy award cannot adopt retroactively', function () {
prepareFormalAwardAdmission($this);
$award = selectPurchaseAward($this);
approvePurchaseAward($this, $award);
$this->suppliers[0]->update(['name' => 'Nome alterado antes de emitir']);
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors();
expect(PurchaseOrder::query()->count())->toBe(0);
});
test('legacy award keeps null admission basis and refuses later adoption', function () {
$award = selectPurchaseAward($this);
expect($award->admission_snapshot)->toBeNull();
$this->actingAs($this->admin);
$policy = app(FormalAdmission::class)->policy($this->admin, ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance'], 'required_count' => 1, 'reason' => 'Política fictícia posterior']);
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/admissao/adoptar', ['policy_id' => $policy->id, 'previous_id' => 0, 'reason' => 'Adopção retroactiva impedida'])->assertSessionHasErrors('admission');
expect(AdmissionAdoption::query()->count())->toBe(0);
issuePurchaseOrder($this, $award);
});
+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.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
->where('stages.8.state', 'not_started')
->has('future', 2)
->has('future', 1)
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
});
+31
View File
@@ -0,0 +1,31 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\FormalAdmission;
require_once __DIR__.'/QualificationFixture.php';
/** Synthetic data only; the underlying fixture enforces an isolated database. */
function admissionFixture(int $quorum = 2): array
{
$f = qualificationFixture();
$admin = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
$buyer = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Procurement]);
auth()->guard()->setUser($f['issuer']);
$set = app(DocumentaryQualificationWorkflow::class)->define($f['issuer'], $f['r'], $f['definition']);
$assessment = $f['assessment'];
$assessment['answers'][1] = ['criterion_id' => 2, 'result' => 'met', 'evidence' => 'Referência fictícia 2', 'reason' => 'Conformidade conferida em teste isolado'];
auth()->guard()->setUser($f['analyst']);
$record = app(DocumentaryQualificationWorkflow::class)->assess($f['analyst'], $set, $assessment);
$workflow = app(FormalAdmission::class);
$policyData = ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance'], 'required_count' => $quorum, 'reason' => 'Configuraçã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 exclusivamente fictícia');
$proposal = ['quotation_id' => $f['quote']->id, 'previous_id' => 0, 'basis_hash' => $workflow->basis($f['r'], $f['quote']->id)['hash'], 'outcome' => 'admit', 'reason' => 'Proposta exclusivamente fictícia'];
return [...$f, 'admin' => $admin, 'buyer' => $buyer, 'set' => $set, 'record' => $record, 'assessment' => $assessment, 'policy' => $policy, 'policy_data' => $policyData, 'adoption' => $adoption, 'proposal' => $proposal];
}
+111
View File
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000019.
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_000019_create_formal_admission';
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-admission-000019-'.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, 'admission_policies_count' => (int) $pdo->query('SELECT count(*) FROM admission_policies')->fetchColumn(), 'admission_adoptions_count' => (int) $pdo->query('SELECT count(*) FROM admission_adoptions')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/admissao-migracao-000019.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));
}
}