Files
compras/tests/Feature/WarehouseFulfilmentTest.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

421 lines
32 KiB
PHP

<?php
use App\Models\Department;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\WarehouseRequest;
use App\Models\WarehouseRequestEvent;
use App\Services\InventoryLots;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\StockWorkflow;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use App\Services\WarehouseReservations;
use App\Services\WarehouseWorkdesk;
use Illuminate\Database\QueryException;
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';
require_once __DIR__.'/../Support/SupplyFixture.php';
test('warehouse request export shares filters and preserves facts while protecting spreadsheet cells', function () {
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], [...$this->draftData, 'operation_key' => (string) Str::uuid(), 'reason' => '=SUM(100;200)']);
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
$before = [];
foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'stock_operations', 'stock_movements', 'audit_logs'] as $table) {
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
}
$this->actingAs($this->reviewer);
$url = '/stock/requisicoes.csv?view=reserve&state=partial&search=RA-'.$this->r->id.'&warehouse_id='.$this->f['warehouse']->id.'&page=2';
$csv = $this->get($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->streamedContent();
$rows = array_map(fn ($line) => str_getcsv($line, ';', '"', ''), explode("\n", trim($csv)));
expect($rows)->toHaveCount(4)->and($rows[2])->toHaveCount(21)->and($rows[3][0])->toBe('RA-'.$this->r->id)
->and($rows[3][7])->toBe("'=SUM(100;200)")->and(array_slice($rows[3], 14, 5))->toBe(['4.000', '2.500', '1.500', '2.500', '0.000']);
expect($this->get('/stock/requisicoes.csv?state=draft')->streamedContent())->not->toContain('RA-'.$this->r->id);
foreach ($before as $table => $rows) {
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
}
});
test('warehouse request export includes old pending requests beyond pagination', function () {
foreach (range(1, 16) as $index) {
app(WarehouseRequests::class)->draft($this->f['issuer'], [...$this->draftData, 'operation_key' => (string) Str::uuid(), 'reason' => 'Pendência antiga '.$index]);
}
$csv = $this->get('/stock/requisicoes.csv?state=draft&page=2')->assertOk()->streamedContent();
expect(explode("\n", trim($csv)))->toHaveCount(20);
expect($csv)->toContain('RA-'.$this->r->id, 'Pendência antiga 16');
});
test('warehouse request export enforces roles tenant scope and validates filters', function () {
$this->getJson('/stock/requisicoes.csv?state=invalid')->assertUnprocessable();
$other = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id]);
$this->actingAs($other)->get('/stock/requisicoes.csv')->assertForbidden();
supplyGrant($other, ['warehouse_request_prepare']);
expect($this->actingAs($other)->get('/stock/requisicoes.csv')->assertOk()->streamedContent())->not->toContain('RA-'.$this->r->id);
$foreign = receiptFixture();
supplyGrant($foreign['receiver'], ['warehouse_request_read']);
expect($this->actingAs($foreign['receiver'])->get('/stock/requisicoes.csv')->assertOk()->streamedContent())->not->toContain('RA-'.$this->r->id);
$this->getJson('/stock/requisicoes.csv?warehouse_id='.$this->f['warehouse']->id)->assertUnprocessable();
});
test('warehouse request print reproduces reservations partial deliveries and confirmations without writes', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']], 'reason' => '<script>unsafe()</script>']);
$delivery = WarehouseRequestEvent::query()->where('action', 'deliver')->sole();
$url = '/stock/requisicoes/'.$this->r->id.'/imprimir';
$tables = ['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'stock_operations', 'audit_logs', 'budget_entries'];
$before = [];
foreach ($tables as $table) {
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
}
$this->get($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
->assertSee('Entrega parcial')->assertSee('4,000')->assertSee('1,500')->assertSee('2,500')->assertSee('Sem factura')
->assertSee($this->reviewer->name)->assertSee('Oficina interna')->assertSee('Recepção por confirmar')
->assertSee('/entregas/'.$delivery->id.'/imprimir', false)->assertDontSee('<script>unsafe()', false)->assertSee('&lt;script&gt;', false);
$this->get($url)->assertOk();
foreach ($before as $table => $rows) {
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
}
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
$this->get($url)->assertOk()->assertSee('Recepção confirmada');
});
test('warehouse request print distinguishes draft and cancellation without pretending a stock exit', function () {
$url = '/stock/requisicoes/'.$this->r->id.'/imprimir';
$this->get($url)->assertOk()->assertSee('Rascunho')->assertSee('Sem intervenções registadas')->assertSee('4,000');
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
warehouseCommand($this, $this->reviewer, 'cancel');
$this->get($url)->assertOk()->assertSee('Cancelada')->assertSee('2,500')->assertSee('1,500')->assertSee('0,000');
});
test('warehouse request print respects existing viewer and tenant isolation', function () {
$url = '/stock/requisicoes/'.$this->r->id.'/imprimir';
$this->actingAs($this->reviewer)->get($url)->assertOk();
$other = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id]);
$this->actingAs($other)->get($url)->assertForbidden();
$foreign = receiptFixture();
$this->actingAs($foreign['receiver'])->get($url)->assertNotFound();
$this->actingAs($this->f['issuer'])->get('/stock/requisicoes/999999/imprimir')->assertNotFound();
});
test('warehouse delivery print block isolates a partial delivery and reflects native receipt without mutating business facts', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']], 'reason' => '<script>alert("test")</script>']);
$first = WarehouseRequestEvent::query()->where('action', 'deliver')->sole();
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
$url = '/stock/requisicoes/'.$this->r->id.'/entregas/'.$first->id.'/imprimir';
$tables = ['warehouse_requests', 'warehouse_request_events', 'stock_movements', 'stock_operations', 'warehouse_reservations', 'audit_logs', 'invoices', 'budget_entries'];
$before = [];
foreach ($tables as $table) {
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
}
$this->get($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
->assertSee('RA-'.$this->r->id)->assertSee('Sem factura')->assertSee('1,500')->assertDontSee('2,500')
->assertSee('Oficina interna')->assertSee($this->reviewer->name)->assertSee('Recepção por confirmar')->assertDontSee('<script>alert(', false)
->assertSee('&lt;script&gt;', false);
$this->get($url)->assertOk();
foreach ($before as $table => $rows) {
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
}
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
$this->get($url)->assertOk()->assertSee('Recepção confirmada por')->assertSee($this->f['issuer']->name);
$this->actingAs($this->reviewer)->get($url)->assertOk();
});
test('warehouse delivery print block rejects foreign tenants unrelated events and viewers without native access', function () {
warehouseCommand($this, $this->f['issuer'], 'submit');
$submit = WarehouseRequestEvent::query()->where('action', 'submit')->sole();
$base = '/stock/requisicoes/'.$this->r->id.'/entregas/';
$this->get($base.$submit->id.'/imprimir')->assertNotFound();
warehouseCommand($this, $this->reviewer, 'authorize');
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '4.000']]]);
$delivery = WarehouseRequestEvent::query()->where('action', 'deliver')->sole();
$url = $base.$delivery->id.'/imprimir';
$other = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id]);
$this->actingAs($other)->get($url)->assertForbidden();
$foreign = receiptFixture();
$this->actingAs($foreign['receiver'])->get($url)->assertNotFound();
$this->actingAs($this->f['receiver'])->get('/stock/requisicoes/999999/entregas/'.$delivery->id.'/imprimir')->assertNotFound();
});
beforeEach(function () {
$this->f = receiptFixture('100.000');
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
supplyGrant($this->reviewer, ['warehouse_request_review', 'warehouse_request_read']);
$this->actingAs($this->f['receiver']);
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
$this->actingAs($this->f['issuer']);
$this->draftData = ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'internal', 'equipment_id' => null]]];
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $this->draftData);
});
function warehouseCommand($test, $actor, string $action, array $extra = []): array
{
$test->actingAs($actor);
$input = ['operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'], 'action' => $action, 'reason' => 'Registo efectivo da intervenção de teste.', ...$extra];
$test->postJson('/stock/requisicoes/'.$test->r->id.'/eventos', $input)->assertRedirect();
return $input;
}
test('internal request is the exit document and partial deliveries consume only actual quantities with independent receipt', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
$delivery = warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $delivery)->assertRedirect();
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(8500);
$p = app(WarehouseFulfilment::class)->progress($this->r);
expect($p['state'])->toBe('partial')->and($p['remaining'])->toBe([2500]);
$batch = WarehouseRequestEvent::query()->where('action', 'deliver')->sole();
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $batch->id]);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
$last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->first();
$confirm = warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $confirm)->assertRedirect();
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('closed')
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(6000)
->and(DB::table('invoices')->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0);
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/request')->where('progress.state', 'closed')->where('progress.delivered.0', 4000));
});
test('reservations protect stock from unrelated issues and cancellation releases only the undelivered balance', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000]);
$this->actingAs($this->f['receiver']);
$issue = ['kind' => 'issue', 'reference' => 'UNRELATED', 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['issuer']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '7.000', 'destination' => 'Outra oficina', 'reason' => 'Tentativa de saída que consumiria reserva.'];
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $issue))->toThrow(ValidationException::class);
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([2500]);
$cancel = warehouseCommand($this, $this->reviewer, 'cancel');
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $cancel)->assertRedirect();
$p = app(WarehouseFulfilment::class)->progress($this->r);
expect($p['state'])->toBe('cancelled')->and($p['reserved'])->toBe([0])->and($p['cancelled'])->toBe([2500])->and($p['remaining'])->toBe([0]);
$this->actingAs($this->f['receiver']);
app(StockWorkflow::class)->record($this->f['receiver'], $issue);
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(1500);
});
test('competing integral authorizations do not overbook and partial reservations can be replenished explicitly', function () {
warehouseCommand($this, $this->f['issuer'], 'submit');
warehouseCommand($this, $this->reviewer, 'authorize');
$original = $this->r;
$this->actingAs($this->f['issuer']);
$data = $this->draftData;
$data['operation_key'] = (string) Str::uuid();
$data['lines'][0]['quantity'] = '8.000';
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
$this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('submitted');
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([6000]);
$second = $this->r;
$this->r = $original;
warehouseCommand($this, $this->reviewer, 'cancel');
$this->r = $second;
warehouseCommand($this, $this->reviewer, 'reserve');
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([8000]);
expect(fn () => DB::table('warehouse_reservations')->where('warehouse_request_id', $second->id)->update(['quantity_milli' => 0]))->toThrow(QueryException::class);
});
test('warehouse authorization rejects self decision stale version and unauthorized physical delivery', function () {
supplyGrant($this->f['issuer'], ['warehouse_request_review']);
warehouseCommand($this, $this->f['issuer'], 'submit');
$url = '/stock/requisicoes/'.$this->r->id.'/eventos';
$this->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Tentativa de auto aprovação.'])->assertForbidden();
$this->actingAs($this->f['receiver'])->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'deliver', 'reason' => 'Entrega não autorizada.', 'lines' => [['index' => 0, 'quantity' => '1.000']]])->assertUnprocessable();
$this->actingAs($this->reviewer)->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'authorize', 'reason' => 'Decisão numa página antiga.'])->assertUnprocessable();
warehouseCommand($this, $this->reviewer, 'authorize');
supplyGrant($this->reviewer, ['warehouse', 'warehouse_request_deliver']);
$this->actingAs($this->reviewer)->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Decisor tenta entregar material.', 'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertForbidden();
$this->actingAs($this->f['receiver'])->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega parcial sem autorização.', 'lines' => [['index' => 0, 'quantity' => '1.000']]])->assertUnprocessable();
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
});
test('quality hold can block already reserved material without releasing the commitment or permitting physical delivery', function () {
supplyGrant($this->f['issuer'], ['inventory_lot_prepare']);
supplyGrant($this->f['receiver'], ['inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']);
$this->actingAs($this->f['issuer']);
$setup = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'Q-RES', 'expires_on' => today()->addMonth()->toDateString()]]], 'prepare'));
$this->actingAs($this->f['receiver']);
app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $setup));
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
$metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']);
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', $metadata));
warehouseCommand($this, $this->f['issuer'], 'submit');
warehouseCommand($this, $this->reviewer, 'authorize');
$this->actingAs($this->f['receiver']);
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'hold', $metadata));
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Tentativa de entrega de lote retido.', 'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertUnprocessable();
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000])->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000)
->and(app(WarehouseReservations::class)->free($this->f['stock']->id, $this->f['warehouse']->id))->toBe(-4000);
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('health.needs_check', true)->where('health.delivery_ready', false));
$this->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.needs_check', true)->where('records.data.0.health.delivery_ready', false));
});
test('shortage on a later line rolls back the entire delivery and preserves the authorization', function () {
$other = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'ZERO', 'name' => 'Artigo sem saldo', 'unit' => 'cx']);
$data = $this->draftData;
$data['operation_key'] = (string) Str::uuid();
$data['lines'][] = ['stock_item_id' => $other->id, 'quantity' => '1.000', 'use' => 'internal'];
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
$count = DB::table('stock_movements')->count();
$this->actingAs($this->f['receiver'])->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega de conjunto com saldo insuficiente.',
'lines' => [['index' => 0, 'quantity' => '1.000'], ['index' => 1, 'quantity' => '1.000']]])->assertUnprocessable();
expect(DB::table('stock_movements')->count())->toBe($count)->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('authorized');
});
test('rejection prevents delivery without changing stock', function () {
warehouseCommand($this, $this->f['issuer'], 'submit');
warehouseCommand($this, $this->reviewer, 'reject');
$this->actingAs($this->f['receiver'])->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Tentativa sobre requisição rejeitada.',
'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertUnprocessable();
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
});
test('workdesk state projection matches the authoritative journal through partial deliveries receipts cancellation and rejection', function () {
$check = function (string $state) {
$derived = WarehouseRequest::query()->whereKey($this->r->id)->selectRaw(app(WarehouseWorkdesk::class)->stateExpression().' as derived_state')->firstOrFail()->getAttribute('derived_state');
expect($derived)->toBe($state)->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe($state);
};
$check('draft');
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
$check('submitted');
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
$check('authorized');
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
$check('partial');
$first = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->sole();
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
$check('partial');
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '3.000']]]);
$check('delivered');
$last = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->latest('id')->firstOrFail();
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
$check('closed');
foreach (['reject' => 'rejected', 'cancel' => 'cancelled'] as $action => $state) {
$this->actingAs($this->f['issuer']);
$data = $this->draftData;
$data['operation_key'] = (string) Str::uuid();
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
warehouseCommand($this, $this->f['issuer'], 'submit');
if ($action === 'cancel') {
warehouseCommand($this, $this->reviewer, 'authorize');
}
warehouseCommand($this, $this->reviewer, $action);
$check($state);
}
});
test('workdesk tasks follow existing permissions and segregation without turning consultation into a grant', function () {
warehouseCommand($this, $this->f['issuer'], 'submit');
$before = DB::table('warehouse_request_events')->count();
$this->actingAs($this->reviewer)->get('/stock/requisicoes?view=review')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.actions.canReview', true)->where('states.submitted', 1));
$this->actingAs($this->f['issuer'])->get('/stock/requisicoes?view=review')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
expect(DB::table('warehouse_request_events')->count())->toBe($before);
warehouseCommand($this, $this->reviewer, 'authorize');
supplyGrant($this->reviewer, ['warehouse', 'warehouse_request_deliver']);
$this->actingAs($this->reviewer)->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
$this->actingAs($this->f['receiver'])->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.delivery_ready', true));
$this->actingAs($this->reviewer)->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('canDeliver', false)->has('people.delivery', 1)->where('people.delivery.0.id', $this->f['receiver']->id));
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->has('people.delivery', 0));
});
test('workdesk retains unconfirmed deliveries after balance cancellation and removes the task only after receipt', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
warehouseCommand($this, $this->reviewer, 'cancel');
$this->actingAs($this->f['issuer'])->get('/stock/requisicoes?view=receipt&state=cancelled')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.unconfirmed_deliveries', 1));
$delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->sole();
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
$this->get('/stock/requisicoes?view=receipt')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('cancelled');
});
test('workdesk filtering precedes pagination and finds old requests without adding new movements', function () {
$first = $this->r;
$this->actingAs($this->f['issuer']);
for ($i = 0; $i < 17; $i++) {
$data = $this->draftData;
$data['operation_key'] = (string) Str::uuid();
app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
}
warehouseCommand($this, $this->f['issuer'], 'submit');
$moves = DB::table('stock_movements')->count();
$this->actingAs($this->reviewer)->get('/stock/requisicoes?view=review&state=submitted&search=Oficina')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $first->id));
$this->get('/stock/requisicoes?view=all&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 18)->has('records.data', 3));
$this->get('/stock/requisicoes?search=RA-'.$first->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $first->id));
expect(DB::table('stock_movements')->count())->toBe($moves);
});
test('workdesk counts do not cross company department or warehouse scope', function () {
$foreign = receiptFixture();
supplyGrant($foreign['issuer'], ['warehouse_request_prepare']);
$this->actingAs($foreign['issuer']);
$data = $this->draftData;
$data['operation_key'] = (string) Str::uuid();
$data['warehouse_id'] = $foreign['warehouse']->id;
$data['cost_center_id'] = $foreign['r']->cost_center_id;
$data['lines'][0]['stock_item_id'] = $foreign['stock']->id;
app(WarehouseRequests::class)->draft($foreign['issuer'], $data);
$this->actingAs($this->reviewer)->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('views.0.total', 1)->where('records.data.0.id', $this->r->id));
$this->getJson('/stock/requisicoes?warehouse_id='.$foreign['warehouse']->id)->assertUnprocessable();
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra unidade do ensaio']);
$outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
supplyGrant($outsider, ['warehouse_request_review']);
$this->actingAs($outsider)->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0)->where('views.0.total', 0));
$this->get('/stock/requisicoes/'.$this->r->id)->assertForbidden();
});
test('changed source and excess quantities are refused on the live document', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
$url = '/stock/requisicoes/'.$this->r->id.'/eventos';
$payload = ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega efectivamente apresentada.', 'lines' => [['index' => 0, 'quantity' => '4.001']]];
$this->actingAs($this->f['receiver'])->postJson($url, $payload)->assertUnprocessable();
$this->f['stock']->update(['name' => 'Especificação alterada depois da autorização']);
$payload['lines'][0]['quantity'] = '1.000';
$payload['operation_key'] = (string) Str::uuid();
$this->postJson($url, $payload)->assertUnprocessable();
expect(app(WarehouseFulfilment::class)->progress($this->r)['delivered'])->toBe([0]);
});
test('failure after recording the physical issue rolls back document event and stock together', function () {
warehouseCommand($this, $this->f['issuer'], 'submit');
warehouseCommand($this, $this->reviewer, 'authorize');
$movements = DB::table('stock_movements')->count();
$operations = DB::table('stock_operations')->count();
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andReturnUsing(function ($actor, $action) {
if ($action === 'warehouse_request.deliver') {
throw new RuntimeException('Injected final document audit failure');
}
});
$this->actingAs($this->f['receiver']);
expect(fn () => app(WarehouseFulfilment::class)->command($this->f['receiver'], $this->r->id, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver',
'reason' => 'Entrega integral de teste.', 'lines' => [['index' => 0, 'quantity' => '4.000']]]))->toThrow(RuntimeException::class);
expect(DB::table('stock_movements')->count())->toBe($movements)->and(DB::table('stock_operations')->count())->toBe($operations)
->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('authorized');
});