Valida concorrencia PostgreSQL da revisao de compras

This commit is contained in:
2026-10-28 17:59:05 +02:00
parent ff403ea927
commit dc90129c59
6 changed files with 407 additions and 3 deletions
+55
View File
@@ -0,0 +1,55 @@
# Proposta para validação: cancelamento de ordens emitidas
Estado: proposta de produto, ainda não implementada nem activada.
Data: 28 de Outubro de 2026. Cada empresa aderente conserva os seus valores,
políticas, utilizadores e aprovações; não se criam limites financeiros comuns.
## Âmbito inicial proposto
Permitir cancelar uma ordem emitida apenas quando não exista qualquer
recepção, factura ou pagamento associado ao processo. Se existir algum desses
registos, encaminhar para um circuito futuro de devolução, estorno ou nota de
crédito; não apagar nem corrigir automaticamente documentos ou movimentos.
Compras apresenta fundamento e documentação de suporte. Finanças decide,
com utilizador distinto do solicitante original, do autor da adjudicação,
do emissor da ordem e do autor do pedido de cancelamento. A empresa activa
explicitamente este circuito através de uma política própria com vigência e
aprovações administrativas, seguindo o mecanismo de publicação já existente.
Sem política publicada aplicável, o cancelamento continua indisponível.
O pedido pendente suspende novas operações sobre a ordem. A rejeição mantém
a ordem e o compromisso financeiro originais. A aprovação marca a ordem e
o processo como cancelados, conservando número, linhas, versões, documentos,
decisões e histórico. Não elimina nem altera a fotografia original da ordem.
A libertação do compromisso será um novo lançamento de reversão, ligado à
ordem e ao cancelamento, exactamente uma vez e na mesma transacção da decisão.
O lançamento original permanece intacto. Não se simula um pagamento ou
reembolso e não há transferências bancárias.
Só o solicitante original pode depois preparar uma nova requisição ligada ao
processo cancelado, com nova data e justificação. O novo processo começa em
rascunho, sem copiar decisões, propostas, validações técnicas ou reserva.
Esta é uma proposta de circuito inicial suportado pelo software. A publicação
de uma política real e os seus parâmetros continuam a ser decisões de cada
empresa. A validação desta proposta não publica políticas nem cancela ordens.
## Verificações exigidas antes da entrega
- Isolamento entre empresas e segregação de funções, incluindo perfis alterados.
- Recusa após recepção, factura ou pagamento; nenhuma eliminação de histórico.
- Decisões imutáveis, repetição idempotente e reversão única do compromisso.
- Rollback conjunto de estado, lançamento financeiro e auditoria.
- Concorrência PostgreSQL entre cancelamento, recepção e outros movimentos.
- Revisão visual pública, autenticada e de impressão quando o navegador permitir.
## Decisão solicitada
Validar este primeiro âmbito: **cancelamento de ordens emitidas exclusivamente
sem recepção, factura ou pagamento, com decisão independente de Finanças e
reversão auditada do compromisso**, sujeito à activação explícita por empresa.
Não inclui cancelamento parcial, devoluções, reembolsos, estornos de facturas
ou pagamentos, nem operações com documentos posteriores já registados.
+58
View File
@@ -0,0 +1,58 @@
{
"tested_at_utc": "2026-10-28T15:58:21+00:00",
"separate_database": true,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"fixture": "Guarded synthetic fixtures; issuance controls and issue-versus-request races use service-selected and service-approved awards",
"scenarios": [
{
"name": "same_approval",
"passed": true,
"overlapping_lock_waits": 2,
"state": "approved",
"release_count": 1,
"revision_count": 0
},
{
"name": "conflicting_decisions",
"passed": true,
"overlapping_lock_waits": 2,
"state": "approved",
"release_count": 1,
"revision_count": 0
},
{
"name": "same_revision",
"passed": true,
"overlapping_lock_waits": 2,
"state": "approved",
"release_count": 1,
"revision_count": 1
},
{
"name": "pending_issuance",
"passed": true,
"overlapping_lock_waits": 2,
"state": "pending",
"release_count": 0,
"revision_count": 0
},
{
"name": "same_issue_control",
"passed": true,
"overlapping_lock_waits": 2,
"order_count": 1,
"cancellation_count": 0,
"committed_minor": 10000
},
{
"name": "issue_versus_request",
"passed": true,
"overlapping_lock_waits": 2,
"order_count": 0,
"cancellation_count": 1,
"committed_minor": 0
}
],
"passed": true,
"test_database_dropped": true
}
+11 -3
View File
@@ -42,9 +42,17 @@ criadas políticas ou operações fictícias na base real. Ver relatório
Sete cenários novos verificam política obrigatória, bloqueio pendente,
rejeição e retoma, histórico e rascunho único, recusa após emissão,
segregação após mudança de perfil, libertação única da reserva e rollback
conjunto quando falha a auditoria. A validação concorrente PostgreSQL deste
novo circuito continua pendente; os 32 cenários do incremento anterior não
comprovam a concorrência destas operações novas.
conjunto quando falha a auditoria. A validação concorrente deste circuito
foi acrescentada numa base PostgreSQL temporária: **6 cenários aprovados**,
cada um com duas ligações independentes e duas esperas de bloqueio simultâneas.
Abrange decisão repetida, decisões opostas, revisão repetida, emissão com
cancelamento pendente, emissão repetida e emissão em disputa com um pedido
de cancelamento. Nos três cenários de emissão, a adjudicação é seleccionada
e aprovada pelos serviços reais da aplicação. O controlo positivo emite uma
única ordem e um único compromisso; na disputa, apenas uma operação avança.
Os quatro primeiros cenários verificam também reserva e revisão únicas.
O relatório é `revisao-concorrencia-postgresql.json`; a base exclusiva de
testes foi eliminada no fim. Não houve alterações à base de negócio.
Resultado do incremento: **414 testes aprovados, 2 ignorados, 3099 asserções**.
PHPStan e Pint passaram; TypeScript, lint/formatação e build passaram.
@@ -0,0 +1,42 @@
<?php
use App\Models\AwardCancellation;
use App\Models\PurchaseAward;
use App\Models\User;
use App\Services\PurchaseRevisionWorkflow;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
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_purchase_controls_'.$argv[4]]);
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
$user = User::query()->findOrFail($argv[2]);
auth()->guard()->setUser($user);
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
$resultId = null;
try {
if ($payload['action'] === 'issue') {
$resultId = app(PurchaseWorkflow::class)->issue($user, PurchaseAward::query()->findOrFail($argv[3]))->id;
} elseif ($payload['action'] === 'request') {
$resultId = app(PurchaseRevisionWorkflow::class)->request($user, PurchaseAward::query()->findOrFail($argv[3]), 'Cancelamento concorrente exclusivamente fictício')->id;
} elseif ($payload['action'] === 'revise') {
$resultId = app(PurchaseRevisionWorkflow::class)->revise($user, AwardCancellation::query()->findOrFail($argv[3]), today()->addWeek()->format('Y-m-d'), 'Revisão concorrente exclusivamente fictícia')->id;
} else {
app(PurchaseRevisionWorkflow::class)->review($user, AwardCancellation::query()->findOrFail($argv[3]), $payload['decision'], 'Decisão concorrente exclusivamente fictícia');
}
$status = 200;
} catch (ValidationException) {
$status = 422;
}
echo json_encode(['status' => $status, 'backend_pid' => $pid, 'result_id' => $resultId], JSON_THROW_ON_ERROR);
+192
View File
@@ -0,0 +1,192 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AwardCancellation;
use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Requisition;
use App\Models\User;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow;
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/ReceiptFixture.php';
require __DIR__.'/../Support/PurchaseControlFixture.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', 'fixture' => 'Guarded synthetic fixtures; issuance controls and issue-versus-request races use service-selected and service-approved awards', '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_approval', 'conflicting_decisions', 'same_revision', 'pending_issuance', 'same_issue_control', 'issue_versus_request'] as $scenario) {
$f = receiptFixture();
// Only the guarded synthetic fixture is adjusted; no business order is deleted.
DB::table('purchase_order_items')->where('purchase_order_id', $f['order']->id)->delete();
DB::table('purchase_orders')->where('id', $f['order']->id)->delete();
$award = PurchaseAward::query()->findOrFail($f['order']->purchase_award_id);
$buyer = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Procurement]);
$finance = User::factory()->create(['tenant_id' => $buyer->tenant_id, 'purchasing_role' => PurchasingRole::Finance]);
$admins = [];
for ($index = 0; $index < 3; $index++) {
$admins[] = User::factory()->create(['tenant_id' => $buyer->tenant_id, 'purchasing_role' => PurchasingRole::Admin]);
}
auth()->guard()->setUser($admins[0]);
$policy = app(PurchaseControlPolicyWorkflow::class)->propose($admins[0], ['previous_id' => 0, 'mode' => 'unissued_award', 'enabled' => true, 'valid_from' => today()->format('Y-m-d'), 'valid_until' => today()->addMonth()->format('Y-m-d'), 'reason' => 'Política exclusivamente fictícia']);
foreach ([$admins[1], $admins[2]] as $reviewer) {
auth()->guard()->setUser($reviewer);
app(PurchaseControlPolicyWorkflow::class)->review($reviewer, $policy, 'approved', 'Conferência independente fictícia');
}
Budget::query()->create(['tenant_id' => $buyer->tenant_id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'kind' => 'operating', 'amount_minor' => 100000, 'includes_tax' => true, 'created_by' => $admins[0]->id, 'approved_by' => $finance->id, 'reason' => 'Dotação exclusivamente fictícia']);
auth()->guard()->setUser($f['issuer']);
DB::transaction(function () use ($f) {
app(ConflictGuard::class)->lockCompany($f['issuer']);
app(BudgetWorkflow::class)->record($f['issuer'], $f['r'], 'reserve', 10000, 10000);
});
$issuable = in_array($scenario, ['same_issue_control', 'issue_versus_request']);
$cancellation = null;
if ($issuable || $scenario === 'pending_issuance') {
$award = issuablePurchaseControlFixture($f, $buyer, $finance);
}
if (! $issuable) {
auth()->guard()->setUser($buyer);
$cancellation = app(PurchaseRevisionWorkflow::class)->request($buyer, $award, 'Cancelamento exclusivamente fictício');
}
if ($scenario === 'same_revision') {
auth()->guard()->setUser($finance);
app(PurchaseRevisionWorkflow::class)->review($finance, $cancellation, 'approved', 'Decisão concorrente exclusivamente fictícia');
}
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $buyer->tenant_id)->lockForUpdate()->first();
$processes = [];
foreach (['A', 'B'] as $index => $suffix) {
$actor = match ($scenario) {
'same_revision' => $f['issuer'], 'pending_issuance', 'same_issue_control', 'issue_versus_request' => $buyer, default => $finance
};
$target = $scenario === 'pending_issuance' || $issuable ? $award->id : $cancellation->id;
$payload = ['action' => match ($scenario) {
'same_revision' => 'revise', 'pending_issuance', 'same_issue_control' => 'issue', 'issue_versus_request' => $index === 0 ? 'issue' : 'request', default => 'review'
}, 'decision' => $scenario === 'conflicting_decisions' && $index === 1 ? 'rejected' : 'approved'];
$process = new Process([PHP_BINARY, __DIR__.'/purchase-controls-worker.php', $database, (string) $actor->id, (string) $target, $suffix], base_path(), $workerEnv);
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
$process->setTimeout(40);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_purchase_controls_A','sgc_purchase_controls_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);
$connection->commit();
if ($waits !== 2) {
throw new RuntimeException('Sem sobreposição de bloqueios.');
}
$results = [];
foreach ($processes as $process) {
$process->wait();
if (! $process->isSuccessful()) {
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
}
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
}
auth()->guard()->setUser($buyer);
$statuses = array_column($results, 'status');
sort($statuses);
if ($issuable) {
$orders = PurchaseOrder::query()->count();
$requests = AwardCancellation::query()->count();
$committed = (int) BudgetEntry::query()->sum('committed_minor');
$reserved = (int) BudgetEntry::query()->sum('reserved_minor');
if ($statuses !== ($scenario === 'same_issue_control' ? [200, 200] : [200, 422]) || count(array_unique(array_column($results, 'backend_pid'))) !== 2 || $orders + $requests !== 1 || $committed !== ($orders ? 10000 : 0) || $reserved !== ($orders ? 0 : 10000) || BudgetEntry::query()->where('kind', 'release')->exists() || PurchaseRevision::query()->exists()) {
throw new RuntimeException('Emissão e cancelamento não são mutuamente exclusivos.');
}
if ($scenario === 'same_issue_control' && ($orders !== 1 || count(array_unique(array_column($results, 'result_id'))) !== 1 || BudgetEntry::query()->where('kind', 'commit')->count() !== 1)) {
throw new RuntimeException('Emissão de controlo inválida ou duplicada.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'order_count' => $orders, 'cancellation_count' => $requests, 'committed_minor' => $committed];
continue;
}
$state = $cancellation->refresh()->status;
$released = BudgetEntry::query()->where('kind', 'release')->count();
$reserved = (int) BudgetEntry::query()->sum('reserved_minor');
$revisions = PurchaseRevision::query()->count();
$expected = match ($scenario) {
'conflicting_decisions' => [200, 422], 'pending_issuance' => [422, 422], default => [200, 200]
};
if ($statuses !== $expected || count(array_unique(array_column($results, 'backend_pid'))) !== 2 || PurchaseOrder::query()->exists() || $released !== ($state === 'approved' ? 1 : 0) || $reserved !== ($state === 'approved' ? 0 : 10000) || $revisions !== ($scenario === 'same_revision' ? 1 : 0) || $f['r']->refresh()->status !== ($state === 'approved' ? 'cancelled' : 'approved')) {
throw new RuntimeException('Resultado concorrente inesperado.');
}
if ($scenario === 'same_revision' && (count(array_unique(array_column($results, 'result_id'))) !== 1 || Requisition::query()->where('status', 'draft')->count() !== 1)) {
throw new RuntimeException('Revisão duplicada.');
}
if ($scenario === 'pending_issuance' && $state !== 'pending') {
throw new RuntimeException('Cancelamento pendente alterado.');
}
if (($scenario === 'same_approval' && $state !== 'approved') || ($scenario === 'conflicting_decisions' && ! in_array($state, ['approved', 'rejected']))) {
throw new RuntimeException('Decisão concorrente não concluída.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'state' => $state, 'release_count' => $released, 'revision_count' => $revisions];
}
$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/revisao-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;
+49
View File
@@ -0,0 +1,49 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\Quotation;
use App\Models\Supplier;
use App\Models\User;
use App\Services\PurchaseWorkflow;
use Illuminate\Support\Facades\DB;
/** Upgrade only a guarded synthetic fixture into a service-approved, issuable award. */
function issuablePurchaseControlFixture(array $f, User $buyer, User $finance): PurchaseAward
{
if (! app()->environment('testing') || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', DB::connection()->getDatabaseName())) {
throw new LogicException('Fixture proibida fora da base PostgreSQL isolada.');
}
auth()->guard()->setUser($buyer);
DB::table('purchase_awards')->where('id', $f['order']->purchase_award_id)->delete();
$request = $f['r'];
$request->update(['approval_snapshot' => ['first_limit_minor' => 2500000, 'second_limit_minor' => 15000000, 'approval_includes_tax' => true, 'rules_provisional' => true]]);
$head = User::factory()->create(['tenant_id' => $buyer->tenant_id, 'department_id' => $request->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$request->steps()->create(['tenant_id' => $buyer->tenant_id, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $head->id, 'decided_at' => now()]);
$first = Quotation::query()->where('requisition_id', $request->id)->firstOrFail();
$supplier = Supplier::query()->findOrFail($first->supplier_id);
$item = $request->items()->firstOrFail();
$quotes = [$first];
for ($index = 1; $index <= 2; $index++) {
$otherSupplier = $supplier->replicate();
$otherSupplier->fill(['nuit' => '12345678'.$index, 'name' => 'Fornecedor fictício '.$index])->save();
$quote = $first->replicate();
$quote->fill(['supplier_id' => $otherSupplier->id, 'reference' => 'PROP-TEST-'.$index])->save();
$quotes[] = $quote;
}
foreach ($quotes as $index => $quote) {
$amount = 10000 * ($index + 1);
$quote->update(['created_by' => $buyer->id, 'subtotal_minor' => $amount, 'total_minor' => $amount]);
$quote->items()->create(['tenant_id' => $buyer->tenant_id, 'requisition_item_id' => $item->id, 'description' => $item->description, 'quantity' => '1.000', 'unit_price_minor' => $amount, 'tax_rate' => '0', 'subtotal_minor' => $amount, 'tax_minor' => 0, 'total_minor' => $amount]);
foreach ([$buyer, $finance] as $evaluator) {
ProposalEvaluation::query()->create(['tenant_id' => $buyer->tenant_id, 'quotation_id' => $quote->id, 'evaluator_id' => $evaluator->id, 'quality' => '8', 'history' => '5', 'payment' => '8', 'no_conflict' => true, 'comment' => 'Avaliação exclusivamente fictícia']);
}
}
$workflow = app(PurchaseWorkflow::class);
$award = $workflow->select($buyer, $request, ['quotation_id' => $first->id, 'justification' => 'Melhor proposta fictícia, seleccionada pelo serviço.', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Armazém fictício']);
auth()->guard()->setUser($head);
$workflow->decide($head, $award, 'approved', 'Decisão final exclusivamente fictícia');
return $award->refresh();
}