Consolida recepcoes, stock, equipamentos e identidade SGC
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
$database = $argv[1] ?? '';
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
fwrite(STDERR, 'Base de testes inválida.');
|
||||
exit(2);
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(ConsoleKernel::class)->bootstrap();
|
||||
if (! $app->environment('testing')) {
|
||||
throw new LogicException('O worker só pode executar em APP_ENV=testing.');
|
||||
}
|
||||
$config = config('database.connections.pgsql');
|
||||
$config['database'] = $database;
|
||||
$config['url'] = null;
|
||||
config(['database.default' => 'pgsql', 'database.connections.pgsql' => $config]);
|
||||
DB::purge('pgsql');
|
||||
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||
throw new LogicException('A ligação não corresponde à base de testes.');
|
||||
}
|
||||
DB::select('select set_config(?, ?, false)', ['application_name', 'procura_receipt_'.$argv[4]]);
|
||||
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||
auth()->guard()->setUser(User::query()->findOrFail($argv[2]));
|
||||
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
$request = Request::create('/ordens/'.$argv[3].'/recepcoes', 'POST', $payload, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$kernel = $app->make(HttpKernel::class);
|
||||
$response = $kernel->handle($request);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
$kernel->terminate($request, $response);
|
||||
echo json_encode(['status' => $response->getStatusCode(), 'id' => $body['id'] ?? null, 'created' => $body['created'] ?? null, 'error_fields' => array_keys($body['errors'] ?? []), 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\StockMovement;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
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';
|
||||
|
||||
// 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]);
|
||||
$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_quantity', 'same_confirmation', 'complementary_quantities', 'same_delivery_note'] as $scenario) {
|
||||
$fixture = receiptFixture();
|
||||
$first = receiptPayload($fixture, $scenario === 'complementary_quantities' ? '0.400' : '0.600');
|
||||
$second = receiptPayload($fixture, '0.600');
|
||||
if ($scenario === 'same_confirmation') {
|
||||
$second = $first;
|
||||
} elseif ($scenario === 'same_delivery_note') {
|
||||
$second['delivery_note'] = $first['delivery_note'];
|
||||
}
|
||||
$connection = DB::connection();
|
||||
$connection->beginTransaction();
|
||||
// Hold the order so both independent requests are demonstrably waiting for locks before release.
|
||||
$connection->table('purchase_orders')->where('id', $fixture['order']->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__.'/receipt-worker.php', $database, (string) $user->id, (string) $fixture['order']->id, $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_receipt_A','procura_receipt_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('Não foi demonstrada espera simultânea pelos 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);
|
||||
}
|
||||
$statuses = array_column($results, 'status');
|
||||
sort($statuses);
|
||||
$expectedStatuses = $scenario === 'same_confirmation' ? [200, 201] : ($scenario === 'complementary_quantities' ? [201, 201] : [201, 422]);
|
||||
$expectedQuantity = $scenario === 'complementary_quantities' ? 1000 : 600;
|
||||
$expectedCount = $scenario === 'complementary_quantities' ? 2 : 1;
|
||||
$progress = app(ReceiptWorkflow::class)->progress($fixture['order']);
|
||||
if ($statuses !== $expectedStatuses || GoodsReceipt::query()->count() !== $expectedCount || StockMovement::query()->count() !== $expectedCount ||
|
||||
(int) StockMovement::query()->sum('quantity_milli') !== $expectedQuantity || $progress['lines'][0]['received_milli'] !== $expectedQuantity || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||
throw new RuntimeException('Resultado de concorrência inesperado: '.json_encode($results));
|
||||
}
|
||||
if ($scenario === 'complementary_quantities' && $progress['state'] !== 'complete') {
|
||||
throw new RuntimeException('A soma exacta em PostgreSQL não concluiu a recepção.');
|
||||
}
|
||||
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'receipts' => $expectedCount, 'stock_movements' => $expectedCount, 'quantity_milli' => $expectedQuantity, 'order_state' => $progress['state']];
|
||||
}
|
||||
$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/recepcao-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);
|
||||
@@ -0,0 +1,215 @@
|
||||
<?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);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
$database = $argv[1] ?? '';
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
fwrite(STDERR, 'Base de testes inválida.');
|
||||
exit(2);
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(ConsoleKernel::class)->bootstrap();
|
||||
if (! $app->environment('testing')) {
|
||||
throw new LogicException('O worker só pode executar em APP_ENV=testing.');
|
||||
}
|
||||
$config = config('database.connections.pgsql');
|
||||
$config['database'] = $database;
|
||||
$config['url'] = null;
|
||||
config(['database.default' => 'pgsql', 'database.connections.pgsql' => $config]);
|
||||
DB::purge('pgsql');
|
||||
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||
throw new LogicException('A ligação não corresponde à base de testes.');
|
||||
}
|
||||
DB::select('select set_config(?, ?, false)', ['application_name', 'procura_stock_'.$argv[4]]);
|
||||
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||
auth()->guard()->setUser(User::query()->findOrFail($argv[2]));
|
||||
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
$request = Request::create('/stock/operacoes', 'POST', $payload, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$kernel = $app->make(HttpKernel::class);
|
||||
$response = $kernel->handle($request);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
$kernel->terminate($request, $response);
|
||||
echo json_encode(['status' => $response->getStatusCode(), 'id' => $body['id'] ?? null, 'created' => $body['created'] ?? null, 'error_fields' => array_keys($body['errors'] ?? []), 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\TechnicalReview;
|
||||
use App\Models\User;
|
||||
use App\Services\EquipmentWorkflow;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
function equipmentRequestData(array $f, string $type = 'equipment'): array
|
||||
{
|
||||
return ['cost_center_id' => $f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => today()->addWeek()->format('Y-m-d'), 'justification' => 'Equipamento para uma linha de produção de teste.', 'items' => [['description' => 'Máquina de teste', 'category' => 'Produção', 'purchase_type' => $type, 'quantity' => '1', 'unit' => 'cx', 'unit_price' => '1.00', 'tax_rate' => '0', 'production_area' => 'Linha fictícia', 'equipment_id' => $f['asset']->id, 'spare_part_id' => $f['part']->id]]];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$f = &$this->f;
|
||||
$f['admin'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$f['head'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$f['finance'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$f['level'] = CriticalityLevel::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['admin']->id, 'code' => 'CRIT-TEST', 'name' => 'Categoria de teste', 'criteria' => 'Critério fictício definido explicitamente para teste', 'is_critical' => true, 'created_at' => now()]);
|
||||
$f['asset'] = Equipment::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'department_id' => $f['department']->id, 'criticality_level_id' => $f['level']->id, 'code' => 'EQ-TEST', 'description' => 'Compressor de teste', 'area' => 'Linha A', 'status' => 'stopped']);
|
||||
$f['part'] = SparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'stock_item_id' => $f['stock']->id, 'criticality_level_id' => $f['level']->id, 'minimum_milli' => 2000]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'equipment_id' => $f['asset']->id, 'spare_part_id' => $f['part']->id, 'created_at' => now()]);
|
||||
$this->actingAs($f['receiver']);
|
||||
});
|
||||
|
||||
test('criticality requires explicit company criteria and critical flag without defaults', function () {
|
||||
$this->actingAs($this->f['admin']);
|
||||
$this->postJson('/equipamentos/categorias', ['code' => 'NEW', 'name' => 'Nova categoria'])->assertUnprocessable()->assertJsonValidationErrors(['criteria', 'is_critical']);
|
||||
$this->post('/equipamentos/categorias', ['code' => 'NEW', 'name' => 'Nova categoria', 'criteria' => 'Critério decidido pela empresa de teste', 'is_critical' => false])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$level = CriticalityLevel::query()->where('code', 'NEW')->firstOrFail();
|
||||
expect($level->is_critical)->toBeFalse();
|
||||
expect(fn () => $level->update(['criteria' => 'Rewrite']))->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('only administration configures criticality', function ($role) {
|
||||
$this->f['receiver']->forceFill(['purchasing_role' => $role])->save();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->post('/equipamentos/categorias', ['code' => 'X', 'name' => 'X', 'criteria' => 'X', 'is_critical' => true])->assertForbidden();
|
||||
})->with([PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead, PurchasingRole::Requester]);
|
||||
|
||||
test('catalogue requires explicit minimum and supports optimistic audited updates', function () {
|
||||
$payload = ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id];
|
||||
$this->postJson('/equipamentos/pecas', $payload)->assertUnprocessable()->assertJsonValidationErrors('minimum_quantity');
|
||||
$payload['minimum_quantity'] = '0';
|
||||
$this->post('/equipamentos/pecas', $payload)->assertSessionHasNoErrors();
|
||||
expect($this->f['part']->refresh()->minimum_milli)->toBe(0)->and($this->f['part']->version)->toBe(2);
|
||||
$this->postJson('/equipamentos/pecas', $payload)->assertUnprocessable()->assertJsonValidationErrors('version');
|
||||
});
|
||||
|
||||
test('warehouse stock drives minimum alert without duplicate inventory balances', function () {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$this->get('/equipamentos')->assertInertia(fn (Assert $p) => $p->component('equipment/index')->where('parts.0.balance_milli', 1000)->where('parts.0.below_minimum', true));
|
||||
$this->postJson('/equipamentos/associacoes', ['equipment_id' => $this->f['asset']->id, 'spare_part_id' => $this->f['part']->id])->assertUnprocessable();
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000)->and(EquipmentSparePart::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('parts require compatible destination and snapshot equipment in issue and movement', function () {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$p = ['kind' => 'issue', 'reference' => 'SAI-EQ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'destination' => 'Linha A', 'reason' => 'Substituição de rolamento', 'quantity' => '0.500'];
|
||||
$this->postJson('/stock/operacoes', $p)->assertUnprocessable()->assertJsonValidationErrors('equipment_id');
|
||||
$p['equipment_id'] = $this->f['asset']->id;
|
||||
$this->postJson('/stock/operacoes', $p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $p)->assertOk();
|
||||
$op = StockOperation::query()->firstOrFail();
|
||||
expect($op->equipment_id)->toBe($this->f['asset']->id)->and($op->spare_part_id)->toBe($this->f['part']->id)->and($op->snapshot['equipment']['description'])->toBe('Compressor de teste');
|
||||
$this->f['asset']->update(['description' => 'Nome novo']);
|
||||
expect($op->refresh()->snapshot['equipment']['description'])->toBe('Compressor de teste');
|
||||
$movement = StockMovement::query()->where('stock_operation_id', $op->id)->firstOrFail();
|
||||
expect($movement->equipment_id)->toBe($op->equipment_id)->and($movement->snapshot['equipment']['description'])->toBe('Compressor de teste');
|
||||
});
|
||||
|
||||
test('cross company associations and catalogue references are rejected', function ($resource) {
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
if ($resource === 'equipment') {
|
||||
$this->postJson('/equipamentos/activos', ['code' => 'OTHER', 'description' => 'Test', 'area' => 'A', 'status' => 'active', 'department_id' => $other['department']->id, 'criticality_level_id' => $this->f['level']->id])->assertUnprocessable()->assertJsonValidationErrors('department_id');
|
||||
} else {
|
||||
$this->postJson('/equipamentos/pecas', ['stock_item_id' => $other['stock']->id, 'minimum_quantity' => '0', 'criticality_level_id' => $this->f['level']->id])->assertUnprocessable()->assertJsonValidationErrors('stock_item_id');
|
||||
}
|
||||
})->with(['equipment', 'stock']);
|
||||
|
||||
test('unrelated and foreign equipment cannot receive a designated spare part', function () {
|
||||
$asset = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'department_id' => $this->f['department']->id, 'criticality_level_id' => $this->f['level']->id, 'code' => 'UNLINKED', 'description' => 'Other', 'area' => 'A', 'status' => 'active']);
|
||||
expect(fn () => app(EquipmentWorkflow::class)->linkedPart($this->f['part']->id, $asset->id))->toThrow(ValidationException::class);
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['receiver']);
|
||||
$this->get('/equipamentos')->assertInertia(fn (Assert $p) => $p->has('equipment', 0)->has('parts', 0)->has('history.data', 0));
|
||||
$this->postJson('/equipamentos/associacoes', ['equipment_id' => $this->f['asset']->id, 'spare_part_id' => $this->f['part']->id])->assertUnprocessable()->assertJsonValidationErrors(['equipment_id', 'spare_part_id']);
|
||||
});
|
||||
|
||||
test('equipment requisition demands technical validation and adds finance for every value', function () {
|
||||
$data = equipmentRequestData($this->f);
|
||||
$this->post('/requisicoes', $data)->assertSessionHasNoErrors();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical');
|
||||
$basis = app(EquipmentWorkflow::class)->basis($r);
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Especificações adequadas à linha de teste'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->f['receiver'])->post('/requisicoes/'.$r->id.'/submeter')->assertSessionHasNoErrors();
|
||||
expect($r->refresh()->status)->toBe('pending')->and($r->steps()->pluck('role')->all())->toBe(['department_head', 'finance']);
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
expect($r->refresh()->status)->toBe('pending');
|
||||
$this->actingAs($this->f['finance'])->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/requisicoes/'.$r->id.'/adjudicacao', ['quotation_id' => $this->f['order']->purchase_award_id, 'justification' => 'Justificação de teste', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Linha A'])->assertUnprocessable()->assertJsonValidationErrors('purchase_type');
|
||||
});
|
||||
|
||||
test('technical decision cannot be made by requester admin or warehouse', function ($role) {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$actor = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => $role]);
|
||||
if ($role === PurchasingRole::DepartmentHead) {
|
||||
$r->update(['requester_id' => $actor->id]);
|
||||
}
|
||||
$this->actingAs($actor)->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
||||
})->with([PurchasingRole::DepartmentHead, PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Finance]);
|
||||
|
||||
test('technical decision is immutable and stale review cannot approve edited request', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$basis = app(EquipmentWorkflow::class)->basis($r);
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Validação do teste'])->assertSessionHasNoErrors();
|
||||
$review = TechnicalReview::query()->firstOrFail();
|
||||
expect(fn () => $review->delete())->toThrow(LogicException::class);
|
||||
$data = equipmentRequestData($this->f);
|
||||
$data['justification'] = 'Mudança da finalidade técnica do equipamento';
|
||||
$this->actingAs($this->f['receiver'])->put('/requisicoes/'.$r->id, $data)->assertSessionHasNoErrors();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical');
|
||||
$this->actingAs($this->f['head'])->postJson('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Teste'])->assertUnprocessable()->assertJsonValidationErrors('basis_hash');
|
||||
expect(TechnicalReview::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('rejected technical review does not permit submission', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'rejected', 'reason' => 'Não cumpre a especificação técnica'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical');
|
||||
});
|
||||
|
||||
test('urgent critical spare parts require downtime without granting quotation exceptions', function () {
|
||||
$data = equipmentRequestData($this->f, 'spare_part');
|
||||
$data['priority'] = 'urgent';
|
||||
$this->postJson('/requisicoes', $data)->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
$data['items'][0]['downtime_started_at'] = now()->subHour()->format('Y-m-d\TH:i');
|
||||
$this->post('/requisicoes', $data)->assertSessionHasNoErrors();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
expect($r->items()->firstOrFail()->equipment_id)->toBe($this->f['asset']->id)->and($r->items()->firstOrFail()->downtime_started_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('spare receipts preserve authorised item and equipment traceability', function () {
|
||||
$f = receiptFixture('1.000', 'spare_part');
|
||||
$this->actingAs($f['receiver']);
|
||||
// Attach already authorised links only inside the isolated fixture.
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'code' => 'PART', 'name' => 'Test', 'criteria' => 'Fixture', 'is_critical' => false, 'created_at' => now()]);
|
||||
$asset = Equipment::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'department_id' => $f['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ', 'description' => 'Test', 'area' => 'A', 'status' => 'active']);
|
||||
$part = SparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'stock_item_id' => $f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'equipment_id' => $asset->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
||||
DB::table('purchase_order_items')->where('id', $f['line']->id)->update(['equipment_id' => $asset->id, 'spare_part_id' => $part->id]);
|
||||
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f, '1.000'))->assertCreated();
|
||||
$movement = StockMovement::query()->firstOrFail();
|
||||
expect($movement->equipment_id)->toBe($asset->id)->and($movement->spare_part_id)->toBe($part->id);
|
||||
});
|
||||
|
||||
test('public equipment page has fictional props only', function () {
|
||||
$this->get('/consulta/equipamentos')->assertInertia(fn (Assert $p) => $p->component('preview/index')->where('initialScreen', 'equipment')->where('auth.user', null)->missing('equipment')->missing('parts'));
|
||||
});
|
||||
|
||||
test('technical reviewer of another department and foreign company cannot decide', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra área']);
|
||||
$this->f['head']->forceFill(['department_id' => $dept->id])->save();
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['receiver']);
|
||||
$this->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertNotFound();
|
||||
});
|
||||
|
||||
test('technical audit failure rolls back decision', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit failed'));
|
||||
$this->actingAs($this->f['head']);
|
||||
$this->withoutExceptionHandling();
|
||||
try {
|
||||
$this->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste']);
|
||||
$this->fail('Expected exception');
|
||||
} catch (RuntimeException $e) {
|
||||
expect($e->getMessage())->toBe('Audit failed');
|
||||
}
|
||||
expect(TechnicalReview::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('foreign classifications cannot be used in equipment or part configuration', function () {
|
||||
$other = receiptFixture();
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $other['tenant']->id, 'created_by' => $other['receiver']->id, 'code' => 'FOREIGN', 'name' => 'Outra', 'criteria' => 'Fixture', 'is_critical' => false, 'created_at' => now()]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->postJson('/equipamentos/activos', ['code' => 'FOREIGN', 'description' => 'Máquina', 'area' => 'Linha', 'status' => 'active', 'department_id' => $this->f['department']->id, 'criticality_level_id' => $level->id])->assertUnprocessable()->assertJsonValidationErrors('criticality_level_id');
|
||||
$this->postJson('/equipamentos/pecas', ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_quantity' => '1'])->assertUnprocessable()->assertJsonValidationErrors('criticality_level_id');
|
||||
});
|
||||
|
||||
test('classification and minimum edits do not erase earlier movement snapshots', function () {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$p = ['kind' => 'issue', 'reference' => 'SNAP', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'destination' => 'Produção', 'reason' => 'Reposição', 'quantity' => '0.001', 'equipment_id' => $this->f['asset']->id];
|
||||
$this->postJson('/stock/operacoes', $p)->assertCreated();
|
||||
$this->post('/equipamentos/pecas', ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id, 'minimum_quantity' => '5.000'])->assertSessionHasNoErrors();
|
||||
expect(StockOperation::query()->firstOrFail()->snapshot['spare_part']['minimum_milli'])->toBe(2000);
|
||||
});
|
||||
|
||||
test('finance can consult but cannot change equipment or parts', function () {
|
||||
$this->actingAs($this->f['finance'])->get('/equipamentos')->assertOk();
|
||||
$this->post('/equipamentos/pecas', ['stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id, 'minimum_quantity' => '1'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('new catalogue forms accept empty optional identity and version fields', function () {
|
||||
$this->post('/equipamentos/activos', ['id' => '', 'version' => '', 'stock_item_id' => '', 'serial_number' => '', 'code' => 'NEW-FORM', 'description' => 'Equipamento novo', 'area' => 'Linha B', 'status' => 'active', 'department_id' => $this->f['department']->id, 'criticality_level_id' => $this->f['level']->id])->assertSessionHasNoErrors();
|
||||
expect(Equipment::query()->where('code', 'NEW-FORM')->count())->toBe(1);
|
||||
});
|
||||
@@ -3,5 +3,5 @@
|
||||
test('returns a successful response', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertRedirect('/consulta');
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('home is a distinct public entry point without business data', function () {
|
||||
$this->get('/')->assertOk()->assertInertia(fn (Assert $page) => $page->component('home')->where('auth.user', null)->missing('orders')->missing('receipts')->missing('balances'));
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('each demo area has a dedicated address without exposing operational data', function (string $path, string $screen) {
|
||||
$this->get($path)->assertOk()->assertInertia(fn (Assert $page) => $page->component('preview/index')->where('initialScreen', $screen)->where('auth.user', null)->missing('orders')->missing('receipts')->missing('movements')->missing('balances'));
|
||||
})->with([['/consulta', 'overview'], ['/consulta/requisicoes', 'requests'], ['/consulta/fornecedores', 'suppliers'], ['/consulta/cotacoes', 'quotes'], ['/consulta/ordens', 'orders'], ['/consulta/recepcoes', 'receipts'], ['/consulta/stock', 'stock'], ['/consulta/conflitos', 'conflicts'], ['/consulta/aparencia', 'appearance']]);
|
||||
|
||||
test('home and public navigation preserve authenticated accounts without exposing identity', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get('/')->assertInertia(fn (Assert $page) => $page->where('auth.user', null));
|
||||
$this->get('/consulta/recepcoes')->assertInertia(fn (Assert $page) => $page->where('auth.user', null));
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('public receiving and stock demos provide no mutation endpoints', function () {
|
||||
$this->post('/consulta/recepcoes', [])->assertStatus(405);
|
||||
$this->post('/consulta/stock', [])->assertStatus(405);
|
||||
$this->get('/consulta/desconhecido')->assertNotFound();
|
||||
$this->get('/recepcoes')->assertRedirect('/login');
|
||||
$this->get('/stock')->assertRedirect('/login');
|
||||
});
|
||||
@@ -3,17 +3,25 @@
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function awardPrices(object $test, int $firstPrice, int $taxRate = 0): void
|
||||
@@ -373,3 +381,24 @@ test('delivery dates that expire before emission are not silently replaced', fun
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('delivery_date');
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('spare purchase flows through award order receipt and issue with equipment trace', function () {
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->admin->id, 'code' => 'PART-TEST', 'name' => 'Normal de teste', 'criteria' => 'Critério explícito da fixture', 'is_critical' => false, 'created_at' => now()]);
|
||||
$asset = Equipment::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'department_id' => $this->department->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-FLOW', 'description' => 'Máquina da fixture', 'area' => 'Produção', 'status' => 'active']);
|
||||
$stock = StockItem::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'code' => 'PART-FLOW', 'name' => 'Peça da fixture', 'unit' => 'un']);
|
||||
$part = SparePart::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'stock_item_id' => $stock->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 500]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'equipment_id' => $asset->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
||||
$this->item->update(['purchase_type' => 'spare_part', 'spare_part_id' => $part->id, 'equipment_id' => $asset->id]);
|
||||
$award = selectPurchaseAward($this);
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
expect($order->items()->firstOrFail()->spare_part_id)->toBe($part->id)->and($order->snapshot['equipment_context'][0]['equipment']['description'])->toBe('Máquina da fixture');
|
||||
$asset->update(['description' => 'Nome actualizado']);
|
||||
expect($order->refresh()->snapshot['equipment_context'][0]['equipment']['description'])->toBe('Máquina da fixture');
|
||||
$receiver = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
$this->actingAs($receiver);
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'FLOW', 'name' => 'Armazém de teste']);
|
||||
$receipt = ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-FLOW', 'received_on' => today()->format('Y-m-d'), 'warehouse_id' => $warehouse->id, 'items' => [['purchase_order_item_id' => $order->items()->firstOrFail()->id, 'received_quantity' => '1.000', 'accepted_quantity' => '1.000', 'quality_status' => 'compliant', 'stock_item_id' => $stock->id]]];
|
||||
$this->postJson('/ordens/'.$order->id.'/recepcoes', $receipt)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', ['kind' => 'issue', 'reference' => 'SAI-FLOW', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $warehouse->id, 'stock_item_id' => $stock->id, 'responsible_id' => $receiver->id, 'destination' => 'Produção', 'reason' => 'Montagem no equipamento', 'quantity' => '0.500', 'equipment_id' => $asset->id])->assertCreated();
|
||||
expect((int) StockMovement::query()->where('stock_item_id', $stock->id)->sum('quantity_milli'))->toBe(500)->and(StockMovement::query()->where('equipment_id', $asset->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\GoodsReceiptItem;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->url = '/ordens/'.$this->f['order']->id.'/recepcoes';
|
||||
$this->payload = receiptPayload($this->f);
|
||||
});
|
||||
|
||||
test('partial then total receipts preserve pending quantities and credit only accepted stock', function () {
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.400'))->assertCreated()->assertJsonPath('status', 'partial');
|
||||
$first = GoodsReceipt::query()->firstOrFail();
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.600'))->assertCreated()->assertJsonPath('status', 'complete');
|
||||
$progress = app(ReceiptWorkflow::class)->progress($this->f['order']);
|
||||
expect($progress['state'])->toBe('complete')->and($progress['lines'][0]['delivery_pending_milli'])->toBe(0)->and($progress['lines'][0]['accepted_milli'])->toBe(1000);
|
||||
expect(StockMovement::query()->count())->toBe(2)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000);
|
||||
expect($first->refresh()->status)->toBe('partial');
|
||||
expect(AuditLog::query()->where('action', 'receipt.confirmed')->count())->toBe(2);
|
||||
$this->get('/stock')->assertInertia(fn (Assert $page) => $page->component('stock/index')->where('balances.0.quantity_milli', 1000)->has('movements.data', 2));
|
||||
});
|
||||
|
||||
test('overreceipt is rejected against cumulative delivered quantities', function () {
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.700'))->assertCreated();
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.400'))->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
expect(GoodsReceipt::query()->count())->toBe(1)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(700);
|
||||
});
|
||||
|
||||
test('idempotent retries preserve one receipt and movement but changed payload cannot reuse the key', function () {
|
||||
$first = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$this->postJson($this->url, $this->payload)->assertOk()->assertJsonPath('id', $first)->assertJsonPath('created', false);
|
||||
$changed = $this->payload;
|
||||
$changed['items'][0]['received_quantity'] = '0.500';
|
||||
$changed['items'][0]['accepted_quantity'] = '0.500';
|
||||
$this->postJson($this->url, $changed)->assertUnprocessable()->assertJsonValidationErrors('idempotency_key');
|
||||
expect(GoodsReceipt::query()->count())->toBe(1)->and(StockMovement::query()->count())->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'receipt.confirmed')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('another token cannot duplicate the same normalized delivery note', function () {
|
||||
$this->payload['delivery_note'] = 'gr teste 001';
|
||||
$this->postJson($this->url, $this->payload)->assertCreated();
|
||||
$this->payload['idempotency_key'] = (string) Str::uuid();
|
||||
$this->payload['delivery_note'] = ' GR TESTE 001 ';
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('delivery_note');
|
||||
expect(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('reservations and rejections record divergences without crediting rejected goods or authorizing replacements', function () {
|
||||
$body = receiptPayload($this->f, '0.600');
|
||||
$body['items'][0] = [...$body['items'][0], 'accepted_quantity' => '0.400', 'quality_status' => 'reservations', 'notes' => 'Duas décimas danificadas.'];
|
||||
$this->postJson($this->url, $body)->assertCreated();
|
||||
$rejected = receiptPayload($this->f, '0.400');
|
||||
$rejected['warehouse_id'] = null;
|
||||
$rejected['items'][0] = [...$rejected['items'][0], 'accepted_quantity' => '0', 'quality_status' => 'nonconforming', 'stock_item_id' => null, 'notes' => 'Material não conforme.'];
|
||||
$this->postJson($this->url, $rejected)->assertCreated()->assertJsonPath('status', 'rejected');
|
||||
expect(StockMovement::query()->count())->toBe(1)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(400);
|
||||
$progress = app(ReceiptWorkflow::class)->progress($this->f['order']);
|
||||
expect($progress['state'])->toBe('quality_pending')->and($progress['has_divergences'])->toBeTrue();
|
||||
expect($progress['lines'][0]['delivery_pending_milli'])->toBe(0)->and($progress['lines'][0]['acceptance_pending_milli'])->toBe(600);
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.100'))->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('quality and quantity inconsistencies cannot confirm stock', function (string $received, string $accepted, string $quality, ?string $notes) {
|
||||
$this->payload['items'][0] = [...$this->payload['items'][0], 'received_quantity' => $received, 'accepted_quantity' => $accepted, 'quality_status' => $quality, 'notes' => $notes];
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable();
|
||||
expect(GoodsReceipt::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(0);
|
||||
})->with([['0.500', '0.600', 'reservations', 'Excesso inválido'], ['0.500', '0.400', 'compliant', null], ['0.500', '0.100', 'nonconforming', 'Não conforme'], ['0.500', '0.400', 'reservations', null], ['-1', '0', 'nonconforming', 'Negativo'], ['0.0001', '0.0001', 'compliant', null], ['0', '0', 'compliant', null]]);
|
||||
|
||||
test('stock items require the correct warehouse and unit', function (string $missing) {
|
||||
if ($missing === 'warehouse') {
|
||||
$this->payload['warehouse_id'] = null;
|
||||
} elseif ($missing === 'item') {
|
||||
$this->payload['items'][0]['stock_item_id'] = null;
|
||||
} else {
|
||||
$this->f['stock']->update(['unit' => 'kg']);
|
||||
}
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
expect(StockMovement::query()->count())->toBe(0);
|
||||
})->with(['warehouse', 'item', 'unit']);
|
||||
|
||||
test('services can be accepted without creating physical stock', function () {
|
||||
$f = receiptFixture('1.000', 'service');
|
||||
$this->actingAs($f['receiver']);
|
||||
$body = receiptPayload($f, '1.000');
|
||||
$body['warehouse_id'] = null;
|
||||
$body['items'][0]['stock_item_id'] = null;
|
||||
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', $body)->assertCreated()->assertJsonPath('status', 'complete');
|
||||
expect(StockMovement::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('an issuer cannot receive their order even after becoming warehouse staff', function () {
|
||||
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
|
||||
$this->actingAs($this->f['issuer'])->postJson($this->url, $this->payload)->assertForbidden();
|
||||
});
|
||||
|
||||
test('confirmation is exclusive to warehouse staff', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->postJson($this->url, $this->payload)->assertForbidden();
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, PurchasingRole::Requester, PurchasingRole::DepartmentHead]);
|
||||
|
||||
test('warehouse order view exposes receiving quantities without the financial snapshot', function () {
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->component('receipts/order')->where('canConfirm', true)->where('progress.lines.0.ordered_milli', 1000)->missing('order.snapshot')->missing('order.total_minor')->missing('order.currency'));
|
||||
$this->get('/recepcoes')->assertInertia(fn (Assert $page) => $page->has('orders.data', 1));
|
||||
});
|
||||
|
||||
test('companies cannot consult or confirm another company receipt or stock', function () {
|
||||
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['receiver'])->get($this->url)->assertNotFound();
|
||||
$this->postJson($this->url, $this->payload)->assertNotFound();
|
||||
$this->get('/recepcoes/'.$id)->assertNotFound();
|
||||
$this->get('/stock')->assertInertia(fn (Assert $page) => $page->has('balances', 0)->has('movements.data', 0)->has('items', 1)->where('items.0.id', $foreign['stock']->id));
|
||||
$body = receiptPayload($foreign);
|
||||
$body['warehouse_id'] = $this->f['warehouse']->id;
|
||||
$body['items'][0]['stock_item_id'] = $this->f['stock']->id;
|
||||
$this->postJson('/ordens/'.$foreign['order']->id.'/recepcoes', $body)->assertUnprocessable()->assertJsonValidationErrors(['warehouse_id', 'items.0.stock_item_id']);
|
||||
});
|
||||
|
||||
test('department heads see only their department receipts and cannot inspect stock', function () {
|
||||
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get('/recepcoes')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('receipts.data', 0));
|
||||
$this->get('/recepcoes/'.$id)->assertForbidden();
|
||||
$this->get('/stock')->assertForbidden();
|
||||
$head->forceFill(['department_id' => $this->f['department']->id])->save();
|
||||
$this->actingAs($head)->get('/recepcoes/'.$id)->assertOk();
|
||||
});
|
||||
|
||||
test('a line from another order and duplicate lines are rejected', function () {
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->payload['items'][0]['purchase_order_item_id'] = $foreign['line']->id;
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
$body = receiptPayload($this->f);
|
||||
$body['items'][] = $body['items'][0];
|
||||
$this->postJson($this->url, $body)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('receipts items and movements are append only and unsupported HTTP edits are absent', function () {
|
||||
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$receipt = GoodsReceipt::query()->findOrFail($id);
|
||||
$item = GoodsReceiptItem::query()->firstOrFail();
|
||||
$movement = StockMovement::query()->firstOrFail();
|
||||
foreach ([$receipt, $item, $movement] as $record) {
|
||||
expect(fn () => $record->delete())->toThrow(LogicException::class);
|
||||
}
|
||||
expect(fn () => $receipt->update(['delivery_note' => 'Outra']))->toThrow(LogicException::class);
|
||||
expect(fn () => $item->update(['accepted_milli' => 1000]))->toThrow(LogicException::class);
|
||||
expect(fn () => $movement->update(['quantity_milli' => 1000]))->toThrow(LogicException::class);
|
||||
$this->put('/recepcoes/'.$id, [])->assertStatus(405);
|
||||
$this->delete('/recepcoes/'.$id)->assertStatus(405);
|
||||
});
|
||||
|
||||
test('stock is derived from receipts not initial balances supplied by browser', function () {
|
||||
$this->post('/stock/artigos', ['code' => ' novo-art ', 'name' => 'Novo artigo', 'unit' => ' CX ', 'quantity_milli' => 999999, 'tenant_id' => 999])->assertSessionHasNoErrors();
|
||||
$item = StockItem::query()->where('code', 'NOVO-ART')->firstOrFail();
|
||||
expect($item->tenant_id)->toBe($this->f['tenant']->id)->and($item->unit)->toBe('cx');
|
||||
expect(StockMovement::query()->count())->toBe(0);
|
||||
$this->post('/stock/artigos', ['code' => 'novo-art', 'name' => 'Duplicado', 'unit' => 'cx'])->assertSessionHasErrors('code');
|
||||
$this->post('/stock/movimentos', [])->assertNotFound();
|
||||
});
|
||||
|
||||
test('audit failure rolls back receipt lines and all stock entries', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha simulada de auditoria da recepção.'));
|
||||
});
|
||||
$this->postJson($this->url, $this->payload)->assertStatus(500);
|
||||
expect(GoodsReceipt::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(0)->and(GoodsReceiptItem::query()->count())->toBe(0);
|
||||
expect(app(ReceiptWorkflow::class)->progress($this->f['order'])['lines'][0]['delivery_pending_milli'])->toBe(1000);
|
||||
});
|
||||
|
||||
test('receipt dates and server controlled fields are enforced', function () {
|
||||
$this->postJson($this->url, [...$this->payload, 'received_on' => now()->subDay()->format('Y-m-d')])->assertUnprocessable()->assertJsonValidationErrors('received_on');
|
||||
$this->postJson($this->url, [...$this->payload, 'received_on' => now()->addDay()->format('Y-m-d')])->assertUnprocessable();
|
||||
$this->postJson($this->url, [...$this->payload, 'received_by' => $this->f['issuer']->id, 'status' => 'complete'])->assertUnprocessable()->assertJsonValidationErrors(['received_by', 'status']);
|
||||
});
|
||||
|
||||
test('guests cannot access receiving and stock operations', function () {
|
||||
auth()->logout();
|
||||
$this->get('/recepcoes')->assertRedirect('/login');
|
||||
$this->get('/stock')->assertRedirect('/login');
|
||||
$this->post($this->url, $this->payload)->assertRedirect('/login');
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$this->dest = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']);
|
||||
$this->p = ['kind' => 'issue', 'reference' => 'SAI-001', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['issuer']->id, 'destination' => 'Produção / REQ-001', 'reason' => 'Consumo de produção', 'quantity' => '0.600'];
|
||||
});
|
||||
|
||||
test('issues are exact, idempotent and cannot make stock negative', function () {
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertOk()->assertJsonPath('created', false);
|
||||
$this->p['quantity'] = '0.500';
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable();
|
||||
$this->p['reference'] = 'SAI-002';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors('quantity');
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(400)->and(StockOperation::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('transfer creates exactly two opposite ledger entries and rejects repeated references', function () {
|
||||
$this->p['kind'] = 'transfer';
|
||||
$this->p['destination_warehouse_id'] = $this->dest->id;
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$op = StockOperation::query()->firstOrFail();
|
||||
$moves = StockMovement::query()->where('stock_operation_id', $op->id);
|
||||
expect($moves->count())->toBe(2)->and((int) $moves->sum('quantity_milli'))->toBe(0);
|
||||
expect((int) StockMovement::query()->where('warehouse_id', $this->dest->id)->sum('quantity_milli'))->toBe(600);
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors('reference');
|
||||
$this->p['reference'] = 'OTHER';
|
||||
$this->p['destination_warehouse_id'] = $this->f['warehouse']->id;
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('counts preserve theoretical quantities and adjustments apply a justified difference once', function () {
|
||||
$this->p['kind'] = 'count';
|
||||
$this->p['quantity'] = '0';
|
||||
$this->p['ledger_version'] = StockMovement::query()->max('id');
|
||||
$id = $this->postJson('/stock/operacoes', $this->p)->assertCreated()->json('id');
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000);
|
||||
$this->p['kind'] = 'adjustment';
|
||||
$this->p['count_id'] = $id;
|
||||
$this->p['reference'] = 'AJ-001';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertOk();
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(0);
|
||||
$this->p['reference'] = 'AJ-002';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable();
|
||||
expect(StockOperation::query()->findOrFail($id)->theoretical_milli)->toBe(1000);
|
||||
});
|
||||
|
||||
test('intervening receipts and issues invalidate counts and stale count forms', function () {
|
||||
$count = [...$this->p, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => StockMovement::query()->max('id')];
|
||||
$id = $this->postJson('/stock/operacoes', $count)->assertCreated()->json('id');
|
||||
$this->p['reference'] = 'SAI-NEW';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', [...$count, 'reference' => 'COUNT-NEW', 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('ledger_version');
|
||||
$this->postJson('/stock/operacoes', [...$this->p, 'kind' => 'adjustment', 'reference' => 'AJ-NEW', 'count_id' => $id, 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('count_id');
|
||||
});
|
||||
|
||||
test('operational role cannot be replaced by administration or other profiles', function ($role) {
|
||||
$this->f['receiver']->forceFill(['purchasing_role' => $role])->save();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertForbidden();
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, PurchasingRole::DepartmentHead, PurchasingRole::Requester]);
|
||||
|
||||
test('foreign company resources cannot be used', function ($field) {
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$values = ['warehouse_id' => $other['warehouse']->id, 'destination_warehouse_id' => $other['warehouse']->id, 'stock_item_id' => $other['stock']->id, 'responsible_id' => $other['receiver']->id];
|
||||
if ($field === 'destination_warehouse_id') {
|
||||
$this->p['kind'] = 'transfer';
|
||||
}
|
||||
$this->p[$field] = $values[$field];
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors($field);
|
||||
})->with(['warehouse_id', 'destination_warehouse_id', 'stock_item_id', 'responsible_id']);
|
||||
|
||||
test('audit failure rolls back both halves of transfer and operation', function () {
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
|
||||
$this->p['kind'] = 'transfer';
|
||||
$this->p['destination_warehouse_id'] = $this->dest->id;
|
||||
$this->withoutExceptionHandling();
|
||||
try {
|
||||
$this->postJson('/stock/operacoes', $this->p);
|
||||
$this->fail('Expected exception');
|
||||
} catch (RuntimeException $e) {
|
||||
expect($e->getMessage())->toBe('Audit unavailable');
|
||||
}
|
||||
expect(StockOperation::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('operations are immutable and new public screen contains only demonstration props', function () {
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$op = StockOperation::query()->firstOrFail();
|
||||
expect(fn () => $op->update(['reason' => 'rewrite']))->toThrow(LogicException::class);
|
||||
expect(fn () => $op->delete())->toThrow(LogicException::class);
|
||||
$this->get('/stock/operacoes')->assertInertia(fn (Assert $p) => $p->component('stock/operations')->has('operations.data', 1)->where('canOperate', true));
|
||||
$this->get('/consulta/operacoes-stock')->assertInertia(fn (Assert $p) => $p->component('preview/index')->where('initialScreen', 'operations')->missing('operations')->where('auth.user', null));
|
||||
});
|
||||
|
||||
test('invalid precision and missing justification or destination do not write', function ($field, $value) {
|
||||
$this->p[$field] = $value;
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors($field);
|
||||
expect(StockOperation::query()->count())->toBe(0);
|
||||
})->with([['quantity', '-1'], ['quantity', '0'], ['quantity', '0.0001'], ['reason', ' '], ['destination', ''], ['responsible_id', null]]);
|
||||
|
||||
test('positive adjustments credit only the difference and matching counts create no movements', function () {
|
||||
$count = [...$this->p, 'kind' => 'count', 'quantity' => '1.250', 'ledger_version' => StockMovement::query()->max('id')];
|
||||
$id = $this->postJson('/stock/operacoes', $count)->assertCreated()->json('id');
|
||||
$adjustment = [...$this->p, 'kind' => 'adjustment', 'count_id' => $id, 'reference' => 'AJ-POS', 'idempotency_key' => (string) Str::uuid()];
|
||||
$this->postJson('/stock/operacoes', $adjustment)->assertCreated();
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1250);
|
||||
$id = $this->postJson('/stock/operacoes', [...$count, 'reference' => 'MATCH', 'idempotency_key' => (string) Str::uuid(), 'ledger_version' => StockMovement::query()->max('id')])->assertCreated()->json('id');
|
||||
$this->postJson('/stock/operacoes', [...$adjustment, 'reference' => 'NOOP', 'count_id' => $id, 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable();
|
||||
expect(StockMovement::query()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('failure creating transfer credit rolls back its debit', function () {
|
||||
StockMovement::creating(function (StockMovement $movement) {
|
||||
if ($movement->movement_type === 'transfer_in') {
|
||||
throw new RuntimeException('Credit failed');
|
||||
}
|
||||
});
|
||||
$this->withoutExceptionHandling();
|
||||
try {
|
||||
$this->postJson('/stock/operacoes', [...$this->p, 'kind' => 'transfer', 'destination_warehouse_id' => $this->dest->id]);
|
||||
$this->fail('Expected failure');
|
||||
} catch (RuntimeException $error) {
|
||||
expect($error->getMessage())->toBe('Credit failed');
|
||||
}
|
||||
expect(StockOperation::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('stock views isolate operations and counts from other companies', function () {
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['receiver']);
|
||||
$this->get('/stock/operacoes')->assertInertia(fn (Assert $p) => $p->has('operations.data', 0)->has('counts', 0));
|
||||
});
|
||||
|
||||
test('form encoded retry identifiers and equivalent quantities retain idempotency', function () {
|
||||
$id = $this->postJson('/stock/operacoes', $this->p)->assertCreated()->json('id');
|
||||
foreach (['warehouse_id', 'stock_item_id', 'responsible_id'] as $key) {
|
||||
$this->p[$key] = (string) $this->p[$key];
|
||||
}
|
||||
$this->p['quantity'] = '0.6';
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertOk()->assertJsonPath('id', $id);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Synthetic fixture: callable only on in-memory SQLite or the dedicated PostgreSQL test database. */
|
||||
function receiptFixture(string $quantity = '1.000', string $purchaseType = 'consumable'): array
|
||||
{
|
||||
$connection = DB::connection();
|
||||
$allowed = ($connection->getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:') ||
|
||||
($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $connection->getDatabaseName()));
|
||||
if (! app()->environment('testing') || ! $allowed) {
|
||||
throw new LogicException('Fixtures de recepção proibidas fora de uma base de testes isolada.');
|
||||
}
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa fictícia de recepção']);
|
||||
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => 'Operações']);
|
||||
$center = CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'OPS', 'name' => 'Operações']);
|
||||
$issuer = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$receiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
$otherReceiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
auth()->guard()->setUser($receiver);
|
||||
$r = Requisition::query()->create(['tenant_id' => $tenant->id, 'requester_id' => $issuer->id, 'department_id' => $department->id, 'cost_center_id' => $center->id, 'number' => 'REQ-TEST-'.Str::random(10), 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Fixture de ordem já emitida para testes de recepção.', 'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$requested = $r->items()->create(['tenant_id' => $tenant->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'quantity' => $quantity, 'unit' => 'cx', 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$supplier = Supplier::query()->create(['tenant_id' => $tenant->id, 'created_by' => $issuer->id, 'name' => 'Fornecedor fictício', 'nuit' => '123456789', 'contact' => 'Contacto de teste', 'email' => 'fixture@example.invalid', 'phone' => '000000000', 'address' => 'Morada fictícia', 'category' => 'Material', 'beneficiaries' => 'Beneficiário fictício', 'document_references' => 'Documento fictício', 'diligence_notes' => 'Somente para a base de testes.', 'status' => 'active', 'next_review' => now()->addYear()]);
|
||||
$quote = Quotation::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'supplier_id' => $supplier->id, 'created_by' => $issuer->id, 'reference' => 'PROP-TEST', 'delivery_days' => 5, 'valid_until' => now()->addWeek(), 'payment_terms' => '30 dias', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000, 'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10]]);
|
||||
$award = PurchaseAward::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'quotation_id' => $quote->id, 'created_by' => $issuer->id, 'justification' => 'Adjudicação de fixture, não repetir o fluxo anterior.', 'delivery_date' => now()->addWeek(), 'delivery_location' => 'Armazém fictício', 'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000, 'approval_snapshot' => [], 'decision_matrix' => [], 'comparison_hash' => str_repeat('0', 64)]);
|
||||
$order = PurchaseOrder::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'purchase_award_id' => $award->id, 'supplier_id' => $supplier->id, 'issued_by' => $issuer->id, 'number' => 'OC-TEST-'.Str::random(10), 'status' => 'issued', 'currency' => 'MZN', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000, 'issued_at' => now(), 'snapshot' => ['supplier' => $supplier->only(['name', 'nuit']), 'company' => ['name' => $tenant->name], 'development_only' => true, 'budget_checked' => false]]);
|
||||
$line = $order->items()->create(['tenant_id' => $tenant->id, 'requisition_item_id' => $requested->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'unit' => 'cx', 'quantity' => $quantity, 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ARM-TEST', 'name' => 'Armazém fictício']);
|
||||
$stock = StockItem::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ART-TEST', 'name' => 'Artigo de stock fictício', 'unit' => 'cx']);
|
||||
|
||||
return compact('tenant', 'department', 'issuer', 'receiver', 'otherReceiver', 'r', 'order', 'line', 'warehouse', 'stock');
|
||||
}
|
||||
|
||||
function receiptPayload(array $fixture, string $quantity = '0.600'): array
|
||||
{
|
||||
return ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-'.Str::random(10), 'received_on' => now()->format('Y-m-d'), 'warehouse_id' => $fixture['warehouse']->id, 'items' => [['purchase_order_item_id' => $fixture['line']->id, 'received_quantity' => $quantity, 'accepted_quantity' => $quantity, 'quality_status' => 'compliant', 'stock_item_id' => $fixture['stock']->id, 'notes' => null]]];
|
||||
}
|
||||
Reference in New Issue
Block a user