@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\User;
|
||||
use App\Services\BudgetWorkflow;
|
||||
use App\Services\OrderCancellationPolicyWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
function issuedOrderCancellationFixture(object $test): void
|
||||
{
|
||||
config(['order_cancellation.enabled' => true]);
|
||||
$test->f = receiptFixture();
|
||||
$base = ['tenant_id' => $test->f['tenant']->id, 'department_id' => $test->f['department']->id];
|
||||
$test->buyer = User::factory()->create([...$base, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$test->finance = User::factory()->create([...$base, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$test->admins = [];
|
||||
foreach (range(1, 3) as $index) {
|
||||
$test->admins[] = User::factory()->create([...$base, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
}
|
||||
$test->actingAs($test->admins[0]);
|
||||
$flow = app(OrderCancellationPolicyWorkflow::class);
|
||||
$test->policy = $flow->propose($test->admins[0], ['previous_id' => 0, 'mode' => 'issued_order', 'enabled' => true,
|
||||
'valid_from' => today()->toDateString(), 'valid_until' => today()->addWeek()->toDateString(), 'reason' => 'Política exclusivamente fictícia para o circuito de testes.']);
|
||||
foreach ([$test->admins[1], $test->admins[2]] as $admin) {
|
||||
$test->actingAs($admin);
|
||||
$flow->review($admin, $test->policy, 'approved', 'Conferência independente da política fictícia.');
|
||||
}
|
||||
$test->actingAs($test->finance);
|
||||
$test->budget = Budget::query()->create(['tenant_id' => $test->f['tenant']->id, 'cost_center_id' => $test->f['r']->cost_center_id, 'year' => now()->year,
|
||||
'amount_minor' => 20000, 'includes_tax' => true, 'reason' => 'Dotação fictícia para ensaio da reversão.', 'created_by' => $test->finance->id,
|
||||
'approved_by' => $test->admins[0]->id, 'approved_at' => now()]);
|
||||
DB::transaction(function () use ($test) {
|
||||
foreach (['reserve', 'award', 'commit'] as $kind) {
|
||||
app(BudgetWorkflow::class)->record($test->finance, $test->f['r'], $kind, 10000, 10000);
|
||||
}
|
||||
});
|
||||
$test->data = ['operation_key' => (string) Str::uuid(), 'reason' => 'Cancelamento integral fictício antes de qualquer execução.', 'evidence' => 'Referência documental fictícia do fundamento do cancelamento.'];
|
||||
$test->actingAs($test->buyer);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierImprovement;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function reportEvaluation(array $fixture, ?int $department = null, int $revision = 1, ?int $previous = null): SupplierEvaluation
|
||||
{
|
||||
return SupplierEvaluation::query()->create(['tenant_id' => $fixture['tenant']->id, 'supplier_id' => $fixture['order']->supplier_id, 'department_id' => $department ?? $fixture['r']->department_id,
|
||||
'created_by' => $fixture['issuer']->id, 'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('b', 64), 'status' => 'approved', 'contributors' => [$fixture['issuer']->id],
|
||||
'revision' => $revision, 'previous_id' => $previous, 'supplier_snapshot' => ['id' => $fixture['order']->supplier_id, 'name' => 'Fornecedor preservado QA', 'nuit' => '123456789'],
|
||||
'payload' => ['title' => 'Avaliação QA', 'order_ids' => [], 'criteria' => [], 'period_start' => null, 'period_end' => null]]);
|
||||
}
|
||||
|
||||
function reportMeasure(SupplierEvaluation $evaluation, User $responsible, array $extra = []): SupplierImprovement
|
||||
{
|
||||
return SupplierImprovement::query()->create([...['tenant_id' => $evaluation->tenant_id, 'supplier_evaluation_id' => $evaluation->id, 'created_by' => $responsible->id, 'responsible_id' => $responsible->id,
|
||||
'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('c', 64), 'title' => 'Medida QA', 'description' => 'Objectivo fictício para o relatório.', 'due_on' => '2026-10-29', 'contributors' => [$responsible->id]], ...$extra]);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
function orderCancellationRaces(array $environment, array $config, string $database): array
|
||||
{
|
||||
config(['database.connections.cancel_race_read' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
$reader = DB::connection('cancel_race_read');
|
||||
$results = [];
|
||||
foreach (['request_receipt', 'receipt_request', 'double_approval'] as $scenario) {
|
||||
$worker = fn (array $args) => new Process([PHP_BINARY, 'tests/Support/order-cancellation-worker.php', ...$args], base_path(), $environment, timeout: 40);
|
||||
$seed = $worker(['seed']);
|
||||
$seed->mustRun();
|
||||
$data = json_decode($seed->getOutput(), true, 64, JSON_THROW_ON_ERROR);
|
||||
$data['barrier'] = storage_path('framework/testing/cancellation-race-'.bin2hex(random_bytes(8)));
|
||||
if (! is_dir(dirname($data['barrier'])) && ! mkdir(dirname($data['barrier']), 0700, true)) {
|
||||
throw new RuntimeException('Testing barrier directory unavailable.');
|
||||
}
|
||||
if ($scenario === 'double_approval') {
|
||||
$worker(['run', json_encode($data, JSON_THROW_ON_ERROR), 'request'])->mustRun();
|
||||
}
|
||||
$first = $worker(['hold', json_encode($data, JSON_THROW_ON_ERROR), $scenario === 'receipt_request' ? 'receipt' : ($scenario === 'double_approval' ? 'approve' : 'request')]);
|
||||
$second = $worker(['run', json_encode($data, JSON_THROW_ON_ERROR), $scenario === 'receipt_request' ? 'request' : ($scenario === 'double_approval' ? 'approve' : 'receipt')]);
|
||||
try {
|
||||
$first->start();
|
||||
$deadline = microtime(true) + 15;
|
||||
while ($first->isRunning() && ! str_contains($first->getOutput(), 'LOCKED') && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! str_contains($first->getOutput(), 'LOCKED')) {
|
||||
throw new RuntimeException('Company lock not acquired by first worker.');
|
||||
}
|
||||
$second->start();
|
||||
$waited = false;
|
||||
$deadline = microtime(true) + 15;
|
||||
while ($second->isRunning() && microtime(true) < $deadline) {
|
||||
$waited = $reader->selectOne("select count(*) as total from pg_stat_activity where datname = ? and application_name = 'cancel-race-run' and wait_event_type = 'Lock'", [$database])->total > 0;
|
||||
if ($waited) {
|
||||
break;
|
||||
} usleep(20000);
|
||||
}
|
||||
if (! $waited) {
|
||||
throw new RuntimeException('Company row lock wait not observed.');
|
||||
}
|
||||
file_put_contents($data['barrier'], 'release');
|
||||
$first->wait();
|
||||
$second->wait();
|
||||
if (! $first->isSuccessful() || ! $second->isSuccessful() || ! str_contains($first->getOutput(), 'OK')
|
||||
|| ! str_contains($second->getOutput(), $scenario === 'double_approval' ? 'OK' : 'BLOCKED')) {
|
||||
throw new RuntimeException('Concurrent outcome not expected.');
|
||||
}
|
||||
$receipts = $reader->table('goods_receipts')->where('purchase_order_id', $data['order'])->count();
|
||||
$cancellations = $reader->table('order_cancellations')->where('purchase_order_id', $data['order'])->get();
|
||||
$reversals = $reader->table('budget_entries')->where('kind', 'order-cancel-'.$data['order'])->count();
|
||||
if ($receipts !== ($scenario === 'receipt_request' ? 1 : 0) || $cancellations->count() !== ($scenario === 'receipt_request' ? 0 : 1)
|
||||
|| $reversals !== ($scenario === 'double_approval' ? 1 : 0)
|
||||
|| ($scenario === 'double_approval' && $cancellations->first()->status !== 'approved')) {
|
||||
throw new RuntimeException('Concurrent effects not unique.');
|
||||
}
|
||||
$results[] = ['scenario' => $scenario, 'company_lock_wait_observed' => true, 'receipts' => $receipts, 'cancellations' => $cancellations->count(), 'reversal_entries' => $reversals, 'result' => 'passed'];
|
||||
} finally {
|
||||
if ($first->isRunning()) {
|
||||
$first->stop();
|
||||
} if ($second->isRunning()) {
|
||||
$second->stop();
|
||||
}
|
||||
if (is_file($data['barrier'])) {
|
||||
unlink($data['barrier']);
|
||||
}
|
||||
}
|
||||
}
|
||||
DB::purge('cancel_race_read');
|
||||
|
||||
return $results;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\OrderCancellation;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\OrderCancellationWorkflow;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
|
||||
throw new LogicException('Disposable test database required.');
|
||||
}
|
||||
config(['order_cancellation.enabled' => true]);
|
||||
require_once __DIR__.'/IssuedOrderCancellationFixture.php';
|
||||
$mode = $argv[1];
|
||||
if ($mode === 'seed') {
|
||||
$fixture = new class extends stdClass
|
||||
{
|
||||
public function withoutVite(): void {}
|
||||
|
||||
public function actingAs(User $user): self
|
||||
{
|
||||
auth()->guard('web')->setUser($user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
};
|
||||
issuedOrderCancellationFixture($fixture);
|
||||
echo json_encode(['order' => $fixture->f['order']->id, 'buyer' => $fixture->buyer->id, 'finance' => $fixture->finance->id, 'receiver' => $fixture->f['receiver']->id,
|
||||
'warehouse' => $fixture->f['warehouse']->id, 'stock' => $fixture->f['stock']->id, 'line' => $fixture->f['line']->id, 'input' => $fixture->data], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
$data = json_decode($argv[2], true, 32, JSON_THROW_ON_ERROR);
|
||||
$action = $argv[3];
|
||||
DB::select("select set_config('application_name', ?, false)", ['cancel-race-'.$mode]);
|
||||
$actor = User::query()->findOrFail($data[$action === 'request' ? 'buyer' : ($action === 'receipt' ? 'receiver' : 'finance')]);
|
||||
auth()->guard('web')->setUser($actor);
|
||||
try {
|
||||
DB::transaction(function () use ($data, $actor, $action, $mode) {
|
||||
if ($mode === 'hold') {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
echo "LOCKED\n";
|
||||
flush();
|
||||
$deadline = microtime(true) + 20;
|
||||
while (! is_file($data['barrier']) && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! is_file($data['barrier'])) {
|
||||
throw new RuntimeException('Barrier not released.');
|
||||
}
|
||||
}
|
||||
$order = PurchaseOrder::query()->findOrFail($data['order']);
|
||||
if ($action === 'request') {
|
||||
app(OrderCancellationWorkflow::class)->request($actor, $order, $data['input']);
|
||||
} elseif ($action === 'receipt') {
|
||||
app(ReceiptWorkflow::class)->confirm($actor, $order, ['idempotency_key' => $data['input']['operation_key'], 'delivery_note' => 'RACE-GUIDE', 'received_on' => today()->toDateString(), 'warehouse_id' => $data['warehouse'],
|
||||
'items' => [['purchase_order_item_id' => $data['line'], 'received_quantity' => '0.100', 'accepted_quantity' => '0.100', 'quality_status' => 'compliant', 'stock_item_id' => $data['stock'], 'notes' => null]]]);
|
||||
} else {
|
||||
$record = OrderCancellation::query()->where('purchase_order_id', $order->id)->where('status', 'pending')->first()
|
||||
?? OrderCancellation::query()->where('purchase_order_id', $order->id)->where('status', 'approved')->firstOrFail();
|
||||
app(OrderCancellationWorkflow::class)->decide($actor, $record, 'approved', 'Decisão financeira independente concorrente fictícia.');
|
||||
}
|
||||
});
|
||||
echo "OK\n";
|
||||
} catch (ValidationException $error) {
|
||||
echo "BLOCKED\n";
|
||||
}
|
||||
Reference in New Issue
Block a user