216 lines
13 KiB
PHP
216 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\StockOperationController;
|
|
use App\Models\CriticalityLevel;
|
|
use App\Models\Equipment;
|
|
use App\Models\EquipmentSparePart;
|
|
use App\Models\SparePart;
|
|
use App\Models\StockMovement;
|
|
use App\Models\StockOperation;
|
|
use App\Models\Warehouse;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\StockWorkflow;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
require __DIR__.'/../../vendor/autoload.php';
|
|
|
|
// Process-only settings: the project's .env and live database are never rewritten.
|
|
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';
|
|
|
|
$original = DB::connection();
|
|
if ($original->getDriverName() !== 'pgsql') {
|
|
throw new LogicException('Este ensaio exige a ligação PostgreSQL local.');
|
|
}
|
|
$originalDatabase = $original->getDatabaseName();
|
|
$effectiveConfig = $original->getConfig();
|
|
$admin = $original->getPdo();
|
|
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
|
if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
|
throw new LogicException('Nome de base de testes não autorizado.');
|
|
}
|
|
$liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
|
$created = false;
|
|
$processes = [];
|
|
$report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []];
|
|
try {
|
|
$admin->exec('CREATE DATABASE "'.$database.'"');
|
|
$created = true;
|
|
$isolated = $effectiveConfig;
|
|
$isolated['url'] = null;
|
|
$isolated['database'] = $database;
|
|
// Replace both PostgreSQL aliases in this process and discard any schema facade
|
|
// resolved by providers while the live configuration was being read.
|
|
config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]);
|
|
DB::purge('pgsql');
|
|
DB::setDefaultConnection('receipt_concurrency');
|
|
Schema::clearResolvedInstance('db.schema');
|
|
app()->forgetInstance('db.schema');
|
|
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
|
throw new LogicException('Migração recusada: ligação não isolada.');
|
|
}
|
|
if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) {
|
|
throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.');
|
|
}
|
|
Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]);
|
|
// Hydrated models retain their connection name. Use one alias for the entire
|
|
// fixture/workflow transaction so FK checks never wait on our other connection.
|
|
DB::disconnect('receipt_concurrency');
|
|
DB::setDefaultConnection('pgsql');
|
|
DB::statement("SET lock_timeout = '25s'");
|
|
$workerEnv = [
|
|
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database,
|
|
'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'],
|
|
'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'],
|
|
'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer',
|
|
'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array',
|
|
];
|
|
foreach (['over_issue', 'same_issue', 'same_transfer', 'opposing_transfers', 'same_adjustment', 'stale_adjustment', 'spare_part_issue'] as $scenario) {
|
|
$fixture = receiptFixture();
|
|
$equipment = null;
|
|
if ($scenario === 'spare_part_issue') {
|
|
$level = CriticalityLevel::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'CRIT-PG', 'name' => 'Critério fictício', 'criteria' => 'Somente para concorrência isolada', 'is_critical' => true, 'created_at' => now()]);
|
|
$equipment = Equipment::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'department_id' => $fixture['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-PG', 'description' => 'Equipamento fictício', 'area' => 'Linha fictícia', 'status' => 'stopped']);
|
|
$part = SparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'stock_item_id' => $fixture['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]);
|
|
EquipmentSparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
|
}
|
|
app(ReceiptWorkflow::class)->confirm($fixture['receiver'], $fixture['order'], receiptPayload($fixture, '1.000'));
|
|
$destination = Warehouse::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'DEST', 'name' => 'Destino fictício']);
|
|
$first = ['kind' => 'issue', 'reference' => 'SAI-A', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $fixture['warehouse']->id, 'stock_item_id' => $fixture['stock']->id, 'responsible_id' => $fixture['issuer']->id, 'destination' => 'Produção fictícia', 'reason' => 'Ensaio isolado de concorrência', 'quantity' => '0.600'];
|
|
if ($equipment) {
|
|
$first['equipment_id'] = $equipment->id;
|
|
}
|
|
$second = [...$first, 'reference' => 'SAI-B', 'idempotency_key' => (string) Str::uuid()];
|
|
if ($scenario === 'same_issue') {
|
|
$second = $first;
|
|
} elseif (in_array($scenario, ['same_transfer', 'opposing_transfers'])) {
|
|
$first['kind'] = 'transfer';
|
|
$first['destination_warehouse_id'] = $destination->id;
|
|
$second = $first;
|
|
if ($scenario === 'opposing_transfers') {
|
|
$second['warehouse_id'] = $destination->id;
|
|
$second['destination_warehouse_id'] = $fixture['warehouse']->id;
|
|
$second['reference'] = 'TR-B';
|
|
$second['idempotency_key'] = (string) Str::uuid();
|
|
$second['quantity'] = '0.100';
|
|
}
|
|
} elseif (in_array($scenario, ['same_adjustment', 'stale_adjustment'])) {
|
|
$countPayload = [...$first, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => (int) StockMovement::query()->max('id')];
|
|
// Use the HTTP validation/canonicalisation path for the count as well.
|
|
$request = Request::create('/stock/operacoes', 'POST', $countPayload, server: ['HTTP_ACCEPT' => 'application/json']);
|
|
$request->setUserResolver(fn () => $fixture['receiver']);
|
|
$response = app(StockOperationController::class)->store($request, app(StockWorkflow::class));
|
|
$countId = json_decode($response->getContent(), true)['id'];
|
|
$first = [...$first, 'kind' => 'adjustment', 'reference' => 'AJ-A', 'count_id' => $countId, 'idempotency_key' => (string) Str::uuid()];
|
|
$second = $first;
|
|
if ($scenario === 'stale_adjustment') {
|
|
$issue = [...$countPayload, 'kind' => 'issue', 'reference' => 'SAI-BEFORE', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '0.100'];
|
|
$request = Request::create('/stock/operacoes', 'POST', $issue, server: ['HTTP_ACCEPT' => 'application/json']);
|
|
$request->setUserResolver(fn () => $fixture['receiver']);
|
|
app(StockOperationController::class)->store($request, app(StockWorkflow::class));
|
|
}
|
|
}
|
|
$connection = DB::connection();
|
|
$connection->beginTransaction();
|
|
$connection->table('tenants')->where('id', $fixture['tenant']->id)->lockForUpdate()->first();
|
|
$processes = [];
|
|
foreach ([$first, $second] as $index => $payload) {
|
|
$label = $index === 0 ? 'A' : 'B';
|
|
$user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver'];
|
|
$process = new Process([PHP_BINARY, __DIR__.'/stock-worker.php', $database, (string) $user->id, '0', $label], base_path(), $workerEnv);
|
|
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
|
$process->setTimeout(45);
|
|
$process->start();
|
|
$processes[] = $process;
|
|
}
|
|
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('procura_stock_A','procura_stock_B') AND wait_event_type = 'Lock'");
|
|
$deadline = microtime(true) + 20;
|
|
$waits = 0;
|
|
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 da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput());
|
|
}
|
|
}
|
|
usleep(20000);
|
|
} while (microtime(true) < $deadline);
|
|
$connection->commit();
|
|
if ($waits < 2) {
|
|
throw new RuntimeException('Sobreposição de bloqueios não demonstrada.');
|
|
}
|
|
$results = [];
|
|
foreach ($processes as $process) {
|
|
$process->wait();
|
|
if (! $process->isSuccessful()) {
|
|
throw new RuntimeException($process->getErrorOutput());
|
|
}$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
$statuses = array_column($results, 'status');
|
|
sort($statuses);
|
|
$expected = match ($scenario) {
|
|
'over_issue', 'spare_part_issue' => [201, 422],'stale_adjustment' => [422, 422],'opposing_transfers' => null,default => [200, 201]
|
|
};
|
|
if (($expected !== null && $statuses !== $expected) || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
|
throw new RuntimeException('Respostas inesperadas: '.json_encode($results));
|
|
}
|
|
$source = app(StockWorkflow::class)->balance($fixture['stock']->id, $fixture['warehouse']->id)['quantity'];
|
|
$target = app(StockWorkflow::class)->balance($fixture['stock']->id, $destination->id)['quantity'];
|
|
$expectedSource = match ($scenario) {
|
|
'same_adjustment' => 800,'stale_adjustment' => 900,default => 400
|
|
};
|
|
if ($scenario === 'opposing_transfers') {
|
|
if (! in_array($statuses, [[201, 201], [201, 422]], true) || $source + $target !== 1000 || $source < 0 || $target < 0) {
|
|
throw new RuntimeException('Transferências opostas inválidas.');
|
|
}
|
|
} elseif ($source !== $expectedSource || $target !== ($scenario === 'same_transfer' ? 600 : 0)) {
|
|
throw new RuntimeException('Saldos inesperados.');
|
|
}
|
|
foreach (StockOperation::query()->where('kind', 'transfer')->get() as $transfer) {
|
|
$moves = StockMovement::query()->where('stock_operation_id', $transfer->id);
|
|
if ($moves->count() !== 2 || (int) $moves->sum('quantity_milli') !== 0) {
|
|
throw new RuntimeException('Transferência incompleta.');
|
|
}
|
|
}
|
|
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'source_milli' => $source, 'destination_milli' => $target];
|
|
}
|
|
$report['passed'] = true;
|
|
} catch (Throwable $error) {
|
|
$report['passed'] = false;
|
|
$report['failure'] = $error->getMessage();
|
|
} finally {
|
|
foreach ($processes as $process) {
|
|
if ($process->isRunning()) {
|
|
$process->stop();
|
|
}
|
|
}
|
|
if (isset($connection) && $connection->transactionLevel() > 0) {
|
|
$connection->rollBack();
|
|
}
|
|
DB::disconnect('receipt_concurrency');
|
|
DB::disconnect('pgsql');
|
|
if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) {
|
|
$admin->exec('DROP DATABASE "'.$database.'"');
|
|
$report['test_database_dropped'] = true;
|
|
}
|
|
$report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore;
|
|
}
|
|
file_put_contents(__DIR__.'/../../docs/stock-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL);
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
|
|
exit(($report['passed'] ?? false) ? 0 : 1);
|