f = receiptFixture('100.000'); supplyGrant($this->f['issuer'], ['inventory_lot_read', 'inventory_lot_prepare']); supplyGrant($this->f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']); supplyGrant($this->f['otherReceiver'], ['warehouse', 'inventory_lot_prepare', 'inventory_lot_release']); $this->actingAs($this->f['receiver']); $this->receiptData = receiptPayload($this->f, '10.000'); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptData); }); function lotSetup($test, array $lots = [], array $extra = []): SupplyRecord { $test->actingAs($test->f['issuer']); $draft = app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => $lots, ...$extra], 'prepare')); $test->actingAs($test->f['receiver']); return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft)); } function lotContext($test): array { return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id); } function lotQualityTasks(object $test, User $actor): array { $test->actingAs($actor); return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'lot-quality:'))); } function lotDecision($test, array $lot, string $action = 'release', $actor = null, ?int $warehouse = null): SupplyRecord { $actor ??= $test->f['receiver']; $test->actingAs($actor); $metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']); return app(InventoryLots::class)->save($actor, supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $warehouse ?? $test->f['warehouse']->id, 'state_version' => $lot['state_version']], $action, $metadata)); } function lotDestination($test, array $extra = []): Warehouse { $warehouse = Warehouse::query()->create(['tenant_id' => $test->f['tenant']->id, 'created_by' => $test->f['receiver']->id, 'code' => 'D-'.Str::random(8), 'name' => 'Destino de conferência']); lotSetup($test, [], ['warehouse_id' => $warehouse->id, ...$extra]); return $warehouse; } function lotIssue($test, string $quantity, array $extra = []): object { $test->actingAs($test->f['receiver']); return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'LOT-'.Str::random(10), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['receiver']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'reason' => 'Saída fictícia para conferir conservação de lotes.', ...$extra]); } test('activation partitions only remaining stock and release never creates stock', function () { lotIssue($this, '2.000'); $before = StockMovement::query()->count(); lotSetup($this, [['code' => 'LOTE-A', 'expires_on' => today()->addDays(30)->toDateString()]]); expect(lotContext($this))->toMatchArray(['reconciled' => true, 'usable_milli' => 0, 'blocked_milli' => 8000]); $lot = lotContext($this)['lots'][0]; lotDecision($this, $lot); expect(lotContext($this)['usable_milli'])->toBe(8000)->and(StockMovement::query()->count())->toBe($before); $this->actingAs($this->f['receiver']); expect(app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptData)->wasRecentlyCreated)->toBeFalse(); }); test('unknown identity stays quarantined and quality decisions require independent metadata authors', function () { lotSetup($this); $lot = lotContext($this)['lots'][0]; expect($lot['state'])->toBe('unidentified'); expect(fn () => lotDecision($this, $lot))->toThrow(ValidationException::class); expect(fn () => lotIssue($this, '0.001'))->toThrow(ValidationException::class); $this->actingAs($this->f['issuer']); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id']))); $lot = lotContext($this)['lots'][0]; supplyGrant($this->f['issuer'], ['inventory_lot_release']); expect(fn () => lotDecision($this, $lot, 'release', $this->f['issuer']))->toThrow(ValidationException::class); lotDecision($this, $lot); expect(lotContext($this)['usable_milli'])->toBe(10000); }); test('FEFO consumes earliest expiry and preserves the single physical ledger with exact partitions', function () { lotSetup($this, [['code' => 'LATER', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000'], ['code' => 'FIRST', 'expires_on' => today()->addDays(1)->toDateString(), 'quantity' => '4.000']]); foreach (lotContext($this)['lots'] as $lot) { lotDecision($this, $lot); } $op = lotIssue($this, '5.000'); $lots = collect(lotContext($this)['lots'])->keyBy('data.code'); expect($lots['FIRST']['quantity_milli'])->toBe(0)->and($lots['LATER']['quantity_milli'])->toBe(5000) ->and(lotContext($this)['reconciled'])->toBeTrue()->and(StockMovement::query()->where('stock_operation_id', $op->id)->count())->toBe(1) ->and(InventoryLotAllocation::query()->whereNotNull('stock_movement_id')->count())->toBe(2); $this->travel(2)->days(); expect(lotContext($this)['usable_milli'])->toBe(5000); }); test('tracked receipt is quarantined, reusable retry is exact and expired receipts roll back atomically', function () { lotSetup($this); $data = receiptPayload($this->f, '3.000'); $data['items'][0]['lots'] = [['code' => 'NEW', 'expires_on' => today()->addDays(10)->toDateString()]]; $this->actingAs($this->f['receiver']); $receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); expect(lotContext($this)['physical']['quantity'])->toBe(13000)->and(lotContext($this)['usable_milli'])->toBe(0)->and(lotContext($this)['reconciled'])->toBeTrue(); expect(app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data)->id)->toBe($receipt->id); $lot = collect(lotContext($this)['lots'])->firstWhere('data.code', 'NEW'); expect(fn () => lotDecision($this, $lot))->toThrow(ValidationException::class); lotDecision($this, $lot, 'release', $this->f['otherReceiver']); expect(lotContext($this)['usable_milli'])->toBe(3000); $bad = receiptPayload($this->f, '2.000'); $bad['items'][0]['lots'] = [['code' => 'EXPIRED', 'expires_on' => today()->subDay()->toDateString()]]; $this->actingAs($this->f['receiver']); $before = StockMovement::query()->count(); expect(fn () => app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $bad))->toThrow(ValidationException::class); expect(StockMovement::query()->count())->toBe($before)->and(lotContext($this)['physical']['quantity'])->toBe(13000); }); test('metadata correction invalidates release and cannot rewrite historical quality decisions', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]); $lot = lotContext($this)['lots'][0]; $release = lotDecision($this, $lot); $this->actingAs($this->f['issuer']); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id']))); expect(lotContext($this)['usable_milli'])->toBe(0)->and($release->fresh()->state)->toBe('released')->and($release->data['metadata_id'])->toBe($lot['metadata_id']); expect(fn () => lotDecision($this, $lot))->toThrow(ValidationException::class); lotDecision($this, lotContext($this)['lots'][0]); expect(lotContext($this)['usable_milli'])->toBe(10000); }); test('stale baseline and self approval cannot activate while new operations remain unaltered', function () { $this->actingAs($this->f['issuer']); $draft = 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' => []], 'prepare')); supplyGrant($this->f['issuer'], ['inventory_lot_review']); expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class); lotIssue($this, '1.000'); expect(fn () => app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class); expect(InventoryLotAllocation::query()->count())->toBe(0); }); test('expiry and hold remove usable quantity without changing physical stock', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString()]], ['minimum_remaining_days' => 1]); lotDecision($this, lotContext($this)['lots'][0]); lotDecision($this, lotContext($this)['lots'][0], 'hold'); expect(lotContext($this)['usable_milli'])->toBe(0); lotDecision($this, lotContext($this)['lots'][0]); $this->travel(2)->days(); expect(lotContext($this)['lots'][0]['state'])->toBe('expired')->and(lotContext($this)['physical']['quantity'])->toBe(10000); expect(fn () => lotIssue($this, '0.001'))->toThrow(ValidationException::class); }); test('projection expires only unconsumed lot remainder and preserves legacy output without lots', function () { $policy = supplyPolicy($this->f); $policy['daily_milli'] = 2000; $policy['horizon_days'] = 3; $today = today()->toDateString(); $projection = app(ReplenishmentProjection::class)->calculate($policy, 10000, $today, [], [], 0, [], [['quantity' => 10000, 'last_usable_on' => $today]]); expect($projection['series'][0]['balance_milli'])->toBe(8000)->and($projection['series'][1]['expired_milli'])->toBe(8000) ->and($projection['series'][1]['balance_milli'])->toBe(-2000)->and($projection['series'][2]['expired_milli'])->toBe(0); $legacy = app(ReplenishmentProjection::class)->calculate($policy, 10000, $today); expect($legacy['series'][0])->not->toHaveKey('expired_milli'); }); test('lot menu shares permissions with server, does not grant warehouse operations, and rejects foreign tenant identifiers', function () { $this->actingAs($this->f['issuer'])->get('/stock/lotes')->assertOk(); $this->actingAs($this->f['issuer'])->postJson('/stock/operacoes', [])->assertForbidden(); $foreign = receiptFixture()['warehouse']; $this->actingAs($this->f['issuer'])->postJson('/stock/lotes', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $foreign->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'))->assertNotFound(); }); test('tracked stock cannot escape controls through an unpartitioned transfer or count adjustment', function () { lotSetup($this); $destination = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']); expect(fn () => lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]))->toThrow(ValidationException::class); expect(fn () => lotIssue($this, '9.000', ['kind' => 'count', 'ledger_version' => app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['version']]))->toThrow(ValidationException::class); expect(lotContext($this)['physical']['quantity'])->toBe(10000); }); test('forecast uses usable lots, ignores unqualified arrivals and expires only remaining usable quantities', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->toDateString()]]); lotDecision($this, lotContext($this)['lots'][0]); $policy = new SupplyRecord(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id]); $data = supplyPolicy($this->f); $data['daily_milli'] = 2000; $data['horizon_days'] = 3; $events = ['reservations' => [], 'receipts' => [['date' => today()->toDateString(), 'quantity' => 500000, 'confirmed' => true]], 'consumed_today' => 0, 'fulfilled_needs' => []]; $projection = app(InventoryLots::class)->project($policy, $data, lotContext($this)['physical'], $events, today()->toDateString()); expect($projection['physical_milli'])->toBe(10000)->and($projection['usable_milli'])->toBe(10000) ->and($projection['series'][0]['incoming_milli'])->toBe(0)->and($projection['series'][1]['expired_milli'])->toBe(8000) ->and($projection['lot_warning'])->not->toBeEmpty(); lotDecision($this, lotContext($this)['lots'][0], 'hold'); $new = app(InventoryLots::class)->project($policy, $data, lotContext($this)['physical'], $events, today()->toDateString()); expect($new['usable_milli'])->toBe(0)->and($new['series'][0]['balance_milli'])->toBe(-2000)->and($new['lot_context'])->not->toBe($projection['lot_context']); }); test('lot allocation database rejects source over-allocation and preserves immutable rows', function () { $active = lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString(), 'quantity' => '6.000'], ['code' => 'B', 'expires_on' => today()->addDays(3)->toDateString(), 'quantity' => '4.000']]); $row = InventoryLotAllocation::query()->firstOrFail(); expect(fn () => $row->update(['quantity_milli' => 1]))->toThrow(LogicException::class); expect(fn () => DB::transaction(fn () => DB::table('inventory_lot_allocations')->where('id', $row->id)->update(['quantity_milli' => 1])))->toThrow(QueryException::class); // A third real identity cannot acquire stock beyond the reconciled baseline. $lot = SupplyRecord::query()->create(['tenant_id' => $this->f['tenant']->id, 'kind' => 'lot', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'registered', 'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'data' => [], 'snapshot' => [], 'reason' => 'Identidade fictícia para testar guarda de origem.', 'operation_key' => (string) Str::uuid(), 'input_hash' => str_repeat('0', 64), 'created_by' => $this->f['receiver']->id, 'created_at' => now()]); expect(fn () => DB::transaction(fn () => app(InventoryLots::class)->allocate($lot->id, $this->f['warehouse']->id, 1, null, $active)))->toThrow(QueryException::class); }); test('lot action retries reuse their results and activation cannot be repeated with a new operation', function () { $this->actingAs($this->f['issuer']); $input = supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => false, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'NO-EXPIRY', 'no_expiry' => true]]], 'prepare'); $draft = app(InventoryLots::class)->save($this->f['issuer'], $input); expect(app(InventoryLots::class)->save($this->f['issuer'], $input)->id)->toBe($draft->id); $input['reason'] = 'Outra evidência com a mesma chave.'; expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], $input))->toThrow(ValidationException::class); $this->actingAs($this->f['receiver']); $activate = supplyInput([], 'activate', $draft); $active = app(InventoryLots::class)->save($this->f['receiver'], $activate); expect(app(InventoryLots::class)->save($this->f['receiver'], $activate)->id)->toBe($active->id); expect(fn () => app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class); lotDecision($this, lotContext($this)['lots'][0]); expect(lotContext($this)['usable_milli'])->toBe(10000)->and(InventoryLotAllocation::query()->count())->toBe(1); }); test('tracked transfer conserves both ledgers and reuses identities and metadata through FEFO', function () { lotSetup($this, [['code' => 'FIRST', 'expires_on' => today()->addDays(2)->toDateString(), 'quantity' => '4.000'], ['code' => 'LATER', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000']]); foreach (lotContext($this)['lots'] as $lot) { lotDecision($this, $lot); } $destination = lotDestination($this); $identities = SupplyRecord::query()->where('kind', 'lot')->count(); $operation = lotIssue($this, '5.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]); $from = collect(lotContext($this)['lots'])->keyBy('data.code'); $to = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id); $received = collect($to['lots'])->keyBy('data.code'); expect($from['FIRST']['quantity_milli'])->toBe(0)->and($from['LATER']['quantity_milli'])->toBe(5000) ->and($received['FIRST']['quantity_milli'])->toBe(4000)->and($received['LATER']['quantity_milli'])->toBe(1000) ->and($received['FIRST']['id'])->toBe($from['FIRST']['id'])->and($received['FIRST']['metadata_id'])->toBe($from['FIRST']['metadata_id']) ->and($to['usable_milli'])->toBe(0)->and($to['reconciled'])->toBeTrue()->and(lotContext($this)['reconciled'])->toBeTrue() ->and(SupplyRecord::query()->where('kind', 'lot')->count())->toBe($identities) ->and(StockMovement::query()->where('stock_operation_id', $operation->id)->count())->toBe(2) ->and((int) StockMovement::query()->where('stock_operation_id', $operation->id)->sum('quantity_milli'))->toBe(0) ->and($operation->snapshot['lot_partition']['plan'])->toHaveCount(2); }); test('transfer custody cannot self release and a repeated arrival holds an already released destination lot', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]); lotDecision($this, lotContext($this)['lots'][0]); $destination = lotDestination($this); lotIssue($this, '2.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]); $context = fn () => app(InventoryLots::class)->context($this->f['stock']->id, $destination->id); $lot = $context()['lots'][0]; expect(fn () => lotDecision($this, $lot, 'release', $this->f['receiver'], $destination->id))->toThrow(ValidationException::class); lotDecision($this, $lot, 'release', $this->f['otherReceiver'], $destination->id); expect($context()['usable_milli'])->toBe(2000); $stale = $context()['lots'][0]; lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]); expect($context()['usable_milli'])->toBe(0)->and($context()['physical']['quantity'])->toBe(3000); expect(fn () => lotDecision($this, $stale, 'release', $this->f['otherReceiver'], $destination->id))->toThrow(ValidationException::class); // Holding again must not erase the pending custody authors. lotDecision($this, $context()['lots'][0], 'hold', $this->f['receiver'], $destination->id); expect(fn () => lotDecision($this, $context()['lots'][0], 'release', $this->f['receiver'], $destination->id))->toThrow(ValidationException::class); lotDecision($this, $context()['lots'][0], 'release', $this->f['otherReceiver'], $destination->id); expect($context()['usable_milli'])->toBe(3000)->and(lotContext($this)['usable_milli'])->toBe(7000); }); test('transfer retry does not repeat either movement or destination quarantine decision', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]); lotDecision($this, lotContext($this)['lots'][0]); $destination = lotDestination($this); $data = ['kind' => 'transfer', 'reference' => 'TR-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['receiver']->id, 'warehouse_id' => $this->f['warehouse']->id, 'destination_warehouse_id' => $destination->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '3.000', 'reason' => 'Transferência com repetição controlada.']; $first = app(StockWorkflow::class)->record($this->f['receiver'], $data); $lot = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['lots'][0]; lotDecision($this, $lot, 'release', $this->f['otherReceiver'], $destination->id); $this->actingAs($this->f['receiver']); expect(app(StockWorkflow::class)->record($this->f['receiver'], $data)->id)->toBe($first->id) ->and(StockMovement::query()->where('stock_operation_id', $first->id)->count())->toBe(2) ->and(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(3000); }); test('destination requirements apply independently and transfer requires released source stock', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString()]]); $destination = lotDestination($this, ['minimum_remaining_days' => 5]); expect(fn () => lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]))->toThrow(ValidationException::class); lotDecision($this, lotContext($this)['lots'][0]); lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]); $context = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id); expect($context['lots'][0]['state'])->toBe('expired')->and($context['usable_milli'])->toBe(0)->and($context['reconciled'])->toBeTrue(); expect(fn () => lotDecision($this, $context['lots'][0], 'release', $this->f['otherReceiver'], $destination->id))->toThrow(ValidationException::class); }); test('transfer responsible person must also be independent from destination quality release', function () { lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]); lotDecision($this, lotContext($this)['lots'][0]); $destination = lotDestination($this); lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id, 'responsible_id' => $this->f['otherReceiver']->id]); $lot = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['lots'][0]; expect(fn () => lotDecision($this, $lot, 'release', $this->f['otherReceiver'], $destination->id))->toThrow(ValidationException::class); expect(fn () => lotDecision($this, $lot, 'release', $this->f['receiver'], $destination->id))->toThrow(ValidationException::class); }); test('an untracked origin cannot enter a tracked destination without reconciled identities', function () { $destination = lotDestination($this); $before = StockMovement::query()->count(); expect(fn () => lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]))->toThrow(ValidationException::class); expect(StockMovement::query()->count())->toBe($before)->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $destination->id)['quantity'])->toBe(0); }); test('later identification corrections preserve the metadata actually used in the transfer document', function () { lotSetup($this, [['code' => 'ORIGINAL', 'expires_on' => today()->addDays(10)->toDateString()]]); lotDecision($this, lotContext($this)['lots'][0]); $destination = lotDestination($this); $operation = lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]); $lot = lotContext($this)['lots'][0]; $this->actingAs($this->f['issuer']); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id']))); expect($operation->fresh()->snapshot['lot_partition']['plan'][0]['code'])->toBe('ORIGINAL') ->and($operation->fresh()->snapshot['lot_partition']['plan'][0]['metadata_id'])->toBe($lot['metadata_id']) ->and(lotContext($this)['usable_milli'])->toBe(0) ->and(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(0); }); function taskLotDraft($test, array $extra = [], ?SupplyRecord $previous = null): SupplyRecord { $test->actingAs($test->f['issuer']); return app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [], ...$extra], 'prepare', $previous)); } test('lot setup task connects independent activation without changing physical stock or releasing quality', function () { $draft = taskLotDraft($this, ['lots' => [['code' => 'TASK-LOT', 'expires_on' => today()->addDays(20)->toDateString()]]]); supplyGrant($this->f['issuer'], ['inventory_lot_review']); $path = '/stock/lotes?record_id='.$draft->id; $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canActivate', false)); $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) ->where('tasks.data.0.task', 'lot_setup_activate')->where('tasks.data.0.href', $path.'#lot-setup-'.$draft->id)->missing('tasks.data.0.snapshot')); $before = [SupplyRecord::query()->count(), StockMovement::query()->count(), InventoryLotAllocation::query()->count(), DB::table('audit_logs')->count()]; $this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canActivate', true)); expect([SupplyRecord::query()->count(), StockMovement::query()->count(), InventoryLotAllocation::query()->count(), DB::table('audit_logs')->count()])->toBe($before); $command = supplyInput([], 'activate', $draft); $this->postJson('/stock/lotes', $command)->assertRedirect(); $this->postJson('/stock/lotes', $command)->assertRedirect(); $context = lotContext($this); expect($context['physical']['quantity'])->toBe(10000)->and($context['usable_milli'])->toBe(0)->and($context['lots'][0]['state'])->toBe('held') ->and(StockMovement::query()->count())->toBe($before[1])->and(InventoryLotAllocation::query()->count())->toBe(1); $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'lot_release')); $this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.state', 'active')->where('records.0.canActivate', false)); }); test('lot setup task rejects a stale balance and reuses the revised preparation at the old link', function () { $draft = taskLotDraft($this); lotIssue($this, '1.000'); $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); $this->postJson('/stock/lotes', supplyInput([], 'activate', $draft))->assertUnprocessable(); expect(InventoryLotAllocation::query()->count())->toBe(0); $updated = taskLotDraft($this, [], $draft); $this->actingAs($this->f['receiver'])->get('/stock/lotes?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $updated->id)); $this->postJson('/stock/lotes', supplyInput([], 'activate', $updated))->assertRedirect(); expect(lotContext($this)['physical']['quantity'])->toBe(9000); }); test('lot setup task preserves active configuration while an independently prepared revision awaits review', function () { $active = lotSetup($this, [['code' => 'BASE-TASK', 'expires_on' => today()->addDays(20)->toDateString()]]); $counts = [StockMovement::query()->count(), InventoryLotAllocation::query()->count()]; $this->actingAs($this->f['otherReceiver']); $draft = app(InventoryLots::class)->save($this->f['otherReceiver'], supplyInput([...$active->data, 'minimum_remaining_days' => 5, 'lots' => []], 'prepare', $active)); supplyGrant($this->f['issuer'], ['inventory_lot_review']); supplyGrant($this->f['otherReceiver'], ['inventory_lot_review']); expect(app(InventoryLots::class)->canActivate($this->f['issuer'], $draft))->toBeFalse() ->and(app(InventoryLots::class)->canActivate($this->f['otherReceiver'], $draft))->toBeFalse(); $this->actingAs($this->f['receiver'])->get('/stock/lotes?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('records', 2) ->where('records.0.id', $draft->id)->where('records.1.id', $active->id)->where('contexts.'.$active->record_key.'.setup_id', $active->id)); $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2) ->where('tasks.data', fn ($rows) => collect($rows)->pluck('task')->sort()->values()->all() === ['lot_release', 'lot_setup_activate'])); $this->postJson('/stock/lotes', supplyInput([], 'activate', $draft))->assertRedirect(); expect([StockMovement::query()->count(), InventoryLotAllocation::query()->count()])->toBe($counts) ->and($active->fresh()->data['minimum_remaining_days'])->toBe(0)->and(app(InventoryLots::class)->setup($active->stock_item_id, $active->warehouse_id)->data['minimum_remaining_days'])->toBe(5); }); test('lot setup task retains older pending setups past the overview limit', function () { $first = taskLotDraft($this); for ($index = 1; $index < 105; $index++) { taskLotDraft($this); } $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25) ->where('tasks.data.0.id', 'lot-setup:'.$first->id.':activate')); $this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); $this->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('controlPagination.total', 105)); $this->get('/stock/lotes?controls_page=5')->assertInertia(fn (Assert $page) => $page->has('records', 5)->where('records.4.id', $first->id)); $this->get('/stock/lotes?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)); }); test('lot setup task preserves company maintenance and expiry boundaries', function () { $draft = taskLotDraft($this); $path = '/stock/lotes?record_id='.$draft->id; $foreign = receiptFixture(); supplyGrant($foreign['receiver'], ['inventory_lot_review']); $this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable(); $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($path)->assertForbidden(); expect(app(InventoryLots::class)->canActivate($this->f['receiver'], $draft))->toBeFalse(); DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); $this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden(); }); test('lot setup task does not keep an obsolete competing preparation actionable after another control is activated', function () { $older = taskLotDraft($this); $winner = taskLotDraft($this); $this->actingAs($this->f['receiver'])->postJson('/stock/lotes', supplyInput([], 'activate', $winner))->assertRedirect(); $this->postJson('/stock/lotes', supplyInput([], 'activate', $older))->assertUnprocessable(); $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get('/stock/lotes?record_id='.$older->id)->assertInertia(fn (Assert $page) => $page->where('records.0.canActivate', false)); expect($older->fresh()->state)->toBe('draft')->and(app(InventoryLots::class)->setup($older->stock_item_id, $older->warehouse_id)->record_key)->toBe($winner->record_key); }); test('lot quality task connects identification and independent release without changing physical stock', function () { lotSetup($this); $lot = lotContext($this)['lots'][0]; $tasks = lotQualityTasks($this, $this->f['issuer']); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_identify')->and($tasks[0]['due_on'])->toBeNull(); $this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('focusLotId', $lot['id'])->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.canIdentify', true)); $metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']); $before = $metadata->getAttributes(); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'IDENTIFIED-QA', 'expires_on' => today()->addDays(10)->toDateString()], 'identify', $metadata)); expect(lotQualityTasks($this, $this->f['issuer']))->toBe([]); $tasks = lotQualityTasks($this, $this->f['receiver']); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release'); lotDecision($this, lotContext($this)['lots'][0]); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['usable_milli'])->toBe(10000) ->and(lotContext($this)['physical']['quantity'])->toBe(10000)->and($metadata->fresh()->getAttributes())->toBe($before); }); test('lot quality task excludes receipt contributors even when release permission is accumulated', function () { lotSetup($this, [['code' => 'BASE', 'expires_on' => today()->addDays(10)->toDateString()]]); lotDecision($this, lotContext($this)['lots'][0]); $data = receiptPayload($this->f, '2.000'); $data['items'][0]['lots'] = [['code' => 'RECEIVED-QA', 'expires_on' => today()->addDays(10)->toDateString()]]; app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]); $tasks = lotQualityTasks($this, $this->f['otherReceiver']); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release'); $lot = collect(lotContext($this)['lots'])->firstWhere('data.code', 'RECEIVED-QA'); lotDecision($this, $lot, 'release', $this->f['otherReceiver']); expect(lotQualityTasks($this, $this->f['otherReceiver']))->toBe([]); }); test('lot quality task detects expiry without movement and requests explicit retention only for prior release', function () { lotSetup($this, [['code' => 'EXPIRY-QA', 'expires_on' => today()->addDay()->toDateString()]]); $release = lotDecision($this, lotContext($this)['lots'][0]); $before = SupplyRecord::query()->count(); $this->travel(2)->days(); $tasks = lotQualityTasks($this, $this->f['receiver']); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_hold') ->and(SupplyRecord::query()->count())->toBe($before)->and(lotContext($this)['usable_milli'])->toBe(0) ->and(lotContext($this)['physical']['quantity'])->toBe(10000)->and($release->fresh()->state)->toBe('released'); $context = lotContext($this); $this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.canHold', true)); lotDecision($this, $context['lots'][0], 'hold'); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and($release->fresh()->state)->toBe('released'); }); test('lot quality task reappears after metadata correction and stale metadata cannot decide', function () { lotSetup($this, [['code' => 'BEFORE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]); $entry = lotContext($this)['lots'][0]; $release = lotDecision($this, $entry); $metadata = SupplyRecord::query()->findOrFail($entry['metadata_id']); $this->actingAs($this->f['issuer']); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'AFTER-QA', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', $metadata)); $tasks = lotQualityTasks($this, $this->f['receiver']); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release') ->and(app(InventoryLots::class)->canIdentify($this->f['issuer'], $metadata))->toBeFalse() ->and(app(InventoryLots::class)->canDecideLot($this->f['receiver'], $metadata, lotContext($this), $entry, 'release'))->toBeFalse() ->and($release->fresh()->data['metadata_id'])->toBe($metadata->id); $this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.data.code', 'AFTER-QA')); }); test('lot quality task enforces company warehouse maintenance and membership expiry', function () { $setup = lotSetup($this, [['code' => 'SCOPE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]); $entry = lotContext($this)['lots'][0]; $path = '/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id; $foreign = receiptFixture(); supplyGrant($foreign['receiver'], ['inventory_lot_release']); expect(lotQualityTasks($this, $foreign['receiver']))->toBe([]); $this->getJson($path)->assertUnprocessable(); $this->actingAs($this->f['receiver'])->getJson('/stock/lotes?lot_id='.$entry['id'])->assertUnprocessable(); $this->getJson($path.'&record_id='.$setup->id)->assertUnprocessable(); DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]); $this->postJson('/stock/lotes', supplyInput(['lot_id' => $entry['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $entry['state_version']], 'release', SupplyRecord::query()->findOrFail($entry['metadata_id'])))->assertForbidden(); DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]); expect(lotContext($this)['usable_milli'])->toBe(0); }); test('lot quality task does not offer release against an unreconciled partition', function () { $setup = lotSetup($this, [['code' => 'BALANCE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]); $context = lotContext($this); $entry = $context['lots'][0]; $context['reconciled'] = false; $this->partialMock(InventoryLots::class)->shouldReceive('context')->andReturn($context); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['reconciled'])->toBeFalse(); $this->get('/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id)->assertInertia(fn (Assert $page) => $page->where('contexts.'.$setup->record_key.'.lots.0.canRelease', false)); }); test('lot quality task retains over one hundred lots with paginated tasks and exact links', function () { $entries = array_map(fn ($i) => ['code' => 'VOLUME-'.$i, 'quantity' => '0.100', 'expires_on' => today()->addDays(10)->toDateString()], range(1, 100)); lotSetup($this, $entries); $data = receiptPayload($this->f, '5.000'); $data['items'][0]['lots'] = array_map(fn ($i) => ['code' => 'NEW-'.$i, 'quantity' => '1.000', 'expires_on' => today()->addDays(10)->toDateString()], range(1, 5)); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); $tasks = lotQualityTasks($this, $this->f['otherReceiver']); expect($tasks)->toHaveCount(105); $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)); $this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->has('tasks.data', 5)); $before = SupplyRecord::query()->count(); $this->get($tasks[104]['href'])->assertOk()->assertInertia(fn (Assert $page) => $page->where('focusLotId', lotContext($this)['lots'][104]['id'])); expect(SupplyRecord::query()->count())->toBe($before)->and(lotContext($this)['physical']['quantity'])->toBe(15000); }); test('lot quality task preserves the approved receipt quarantine policy and omits empty lots', function () { lotSetup($this, [['code' => 'POLICY-BASE', 'expires_on' => today()->addDays(10)->toDateString()]], ['quarantine_on_receipt' => false]); lotDecision($this, lotContext($this)['lots'][0]); $data = receiptPayload($this->f, '2.000'); $data['items'][0]['lots'] = [['code' => 'POLICY-NEW', 'expires_on' => today()->addDays(10)->toDateString()]]; app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); $entry = collect(lotContext($this)['lots'])->firstWhere('data.code', 'POLICY-NEW'); expect($entry['quality_released'])->toBeTrue()->and($entry['release_mode'])->toBe('receipt_policy') ->and(lotQualityTasks($this, $this->f['otherReceiver']))->toBe([]); lotIssue($this, '12.000'); $this->actingAs($this->f['issuer']); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'EMPTY-CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($entry['metadata_id']))); expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['physical']['quantity'])->toBe(0); }); test('lot pagination reaches old controls and history beyond the former fixed limits without writes', function () { $first = taskLotDraft($this); for ($i = 0; $i < 209; $i++) { $copy = $first->replicate(); $copy->record_key = (string) Str::uuid(); $copy->operation_key = (string) Str::uuid(); $copy->created_at = $first->created_at; $copy->save(); } $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), StockMovement::query()->count()]; $this->actingAs($this->f['receiver'])->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 210) ->where('historyPagination.total', 210)->has('records', 25)->has('history', 50)); $this->get('/stock/lotes?controls_page=9&history_page=5')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page ->where('controlPagination.current_page', 9)->where('historyPagination.current_page', 5)->has('records', 10)->has('history', 10) ->where('records.9.id', $first->id)->where('history.9.id', $first->id) ->where('controlPagination.prev_page_url', fn ($url) => str_contains($url, 'history_page=5')) ->where('historyPagination.prev_page_url', fn ($url) => str_contains($url, 'controls_page=9'))); $this->get('/stock/lotes?record_id='.$first->id.'&history_page=5')->assertInertia(fn (Assert $page) => $page->where('controlPagination', null) ->has('records', 1)->where('records.0.id', $first->id)->where('historyPagination.total', 210)); expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), StockMovement::query()->count()])->toBe($before); }); test('lot pagination keeps active control and pending revision together at the page boundary and exact lot focus', function () { $active = lotSetup($this, [['code' => 'PAGE-BASE', 'expires_on' => today()->addDays(20)->toDateString()]]); $entry = lotContext($this)['lots'][0]; $before = $active->fresh()->getAttributes(); $this->actingAs($this->f['otherReceiver']); $draft = app(InventoryLots::class)->save($this->f['otherReceiver'], supplyInput([...$active->data, 'minimum_remaining_days' => 5, 'lots' => []], 'prepare', $active)); $this->actingAs($this->f['issuer']); for ($i = 0; $i < 25; $i++) { $item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'PAGE-'.$i, 'name' => 'Artigo sintético sem saldo '.$i, 'unit' => 'cx']); app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $item->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare')); } $this->actingAs($this->f['receiver'])->get('/stock/lotes?controls_page=2')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 26) ->has('records', 2)->where('records.0.id', $draft->id)->where('records.1.id', $active->id) ->where('contexts.'.$active->record_key.'.setup_id', $active->id)); $this->get('/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id)->assertInertia(fn (Assert $page) => $page->where('controlPagination', null) ->has('records', 2)->where('focusLotId', $entry['id'])->where('contexts.'.$active->record_key.'.setup_id', $active->id)); expect($active->fresh()->getAttributes())->toBe($before); }); test('lot pagination rejects invalid pages and preserves tenant history boundaries', function () { taskLotDraft($this); $foreign = receiptFixture(); supplyGrant($foreign['receiver'], ['inventory_lot_read']); $this->actingAs($foreign['receiver'])->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 0)->where('historyPagination.total', 0)); $this->actingAs($this->f['receiver']); foreach (['controls_page=0', 'history_page=-1', 'controls_page=abc', 'history_page=1000001'] as $query) { $this->getJson('/stock/lotes?'.$query)->assertUnprocessable(); } });