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'); });