Implementa admissão formal por política e adopção do processo
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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> */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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,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']);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user