Consolida recepcoes, stock, equipamentos e identidade SGC

This commit is contained in:
2026-10-28 13:54:53 +02:00
parent eb1c8b8074
commit 75ffdb395c
76 changed files with 6933 additions and 152 deletions
+38
View File
@@ -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);
+154
View File
@@ -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);
+215
View File
@@ -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);
+38
View File
@@ -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);
+227
View File
@@ -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);
});
+1 -1
View File
@@ -3,5 +3,5 @@
test('returns a successful response', function () {
$response = $this->get(route('home'));
$response->assertRedirect('/consulta');
$response->assertOk();
});
+28
View File
@@ -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');
});
+29
View File
@@ -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);
});
+199
View File
@@ -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');
});
+164
View File
@@ -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);
});
+50
View File
@@ -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]]];
}