f = receiptFixture('100.000'); supplyGrant($this->f['issuer'], ['inventory_lot_prepare', 'inventory_lot_read']); supplyGrant($this->f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']); $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000')); }); function lotPolicyContext($test): array { return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id); } function initialLotPolicy($test, 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, 'quarantine_on_receipt' => true, 'lots' => [['code' => 'BASE', 'expires_on' => today()->addDays(3)->toDateString()]], ...$extra], 'prepare')); $test->actingAs($test->f['receiver']); return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft)); } function proposeLotPolicy($test, SupplyRecord $old, array $extra): SupplyRecord { $test->actingAs($test->f['issuer']); return app(InventoryLots::class)->save($test->f['issuer'], supplyInput([...$old->data, ...$extra, 'lots' => []], 'prepare', $old)); } function activateLotPolicy($test, SupplyRecord $draft): SupplyRecord { $test->actingAs($test->f['receiver']); return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft)); } function releasePolicyBaseline($test): void { $test->actingAs($test->f['receiver']); $lot = lotPolicyContext($test)['lots'][0]; app(InventoryLots::class)->save($test->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $test->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id']))); } function receivePolicyLot($test, array $metadata, array $lineExtra = []): object { $test->actingAs($test->f['receiver']); $payload = receiptPayload($test->f, '2.000'); $payload['items'][0] = [...$payload['items'][0], 'lots' => [$metadata], ...$lineExtra]; return app(ReceiptWorkflow::class)->confirm($test->f['receiver'], $test->f['order'], $payload); } test('a proposed policy keeps the approved scope available and shows impact without recreating stock', function () { $active = initialLotPolicy($this); releasePolicyBaseline($this); $before = InventoryLotAllocation::query()->count(); $draft = proposeLotPolicy($this, $active, ['minimum_remaining_days' => 5]); expect($draft->snapshot['impact'])->toBe(['before_milli' => 10000, 'after_milli' => 0]) ->and(app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->id)->toBe($active->id) ->and(lotPolicyContext($this)['usable_milli'])->toBe(10000); $this->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->component('stock/lots')->has('records', 2)->has('contexts.'.$active->record_key)); $new = activateLotPolicy($this, $draft); expect(lotPolicyContext($this)['usable_milli'])->toBe(0)->and(InventoryLotAllocation::query()->count())->toBe($before) ->and(StockMovement::query()->count())->toBe(1)->and($active->fresh()->data['minimum_remaining_days'])->toBe(0) ->and(app(InventoryLots::class)->activeSetups()->count())->toBe(1); $again = activateLotPolicy($this, proposeLotPolicy($this, $new, ['minimum_remaining_days' => 0])); expect($again->snapshot['impact']['after_milli'])->toBe(10000)->and(lotPolicyContext($this)['usable_milli'])->toBe(10000); }); test('quality or quantity changes after preparing a revision invalidate the frozen impact', function () { $active = initialLotPolicy($this); releasePolicyBaseline($this); $draft = proposeLotPolicy($this, $active, ['minimum_remaining_days' => 1]); $this->actingAs($this->f['receiver']); $lot = lotPolicyContext($this)['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', SupplyRecord::query()->findOrFail($lot['metadata_id']))); expect(fn () => activateLotPolicy($this, $draft))->toThrow(ValidationException::class); expect(app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->id)->toBe($active->id); }); test('preapproved exemption applies only to future compliant identified and sufficiently valid receipts', function () { $active = initialLotPolicy($this, ['quarantine_on_receipt' => false]); expect(lotPolicyContext($this)['usable_milli'])->toBe(0); receivePolicyLot($this, ['code' => 'COMPLETE', 'expires_on' => today()->addDays(10)->toDateString()]); $lot = collect(lotPolicyContext($this)['lots'])->firstWhere('data.code', 'COMPLETE'); expect($lot['usable_milli'])->toBe(2000)->and($lot['release_mode'])->toBe('receipt_policy')->and($lot['release_setup_id'])->toBe($active->id) ->and($lot['origin']['receipt_setup_id'])->toBe($active->id); receivePolicyLot($this, ['code' => 'UNKNOWN']); receivePolicyLot($this, ['code' => 'RESERVED', 'expires_on' => today()->addDays(10)->toDateString()], ['accepted_quantity' => '1.000', 'quality_status' => 'reservations', 'notes' => 'Reserva de qualidade documentada.']); expect(lotPolicyContext($this)['usable_milli'])->toBe(2000)->and(lotPolicyContext($this)['reconciled'])->toBeTrue(); }); test('switching receipt policy affects future arrivals while preserving earlier decisions and their rule version', function () { $active = initialLotPolicy($this, ['quarantine_on_receipt' => false]); receivePolicyLot($this, ['code' => 'EARLIER', 'expires_on' => today()->addDays(10)->toDateString()]); $new = activateLotPolicy($this, proposeLotPolicy($this, $active, ['quarantine_on_receipt' => true])); receivePolicyLot($this, ['code' => 'LATER', 'expires_on' => today()->addDays(10)->toDateString()]); $lots = collect(lotPolicyContext($this)['lots'])->keyBy('data.code'); expect($lots['EARLIER']['release_setup_id'])->toBe($active->id)->and($lots['EARLIER']['usable_milli'])->toBe(2000) ->and($lots['LATER']['usable_milli'])->toBe(0)->and($lots['LATER']['origin']['receipt_setup_id'])->toBe($new->id); }); test('expiry exemptions are explicit and transfers still require destination quarantine under an exempt receipt policy', function () { initialLotPolicy($this, ['quarantine_on_receipt' => false, 'requires_expiry' => false]); receivePolicyLot($this, ['code' => 'UNLIMITED', 'no_expiry' => true]); $destination = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']); $this->actingAs($this->f['issuer']); $draft = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $destination->id, 'requires_expiry' => false, 'minimum_remaining_days' => 0, 'quarantine_on_receipt' => false, 'lots' => []], 'prepare')); activateLotPolicy($this, $draft); app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'transfer', 'reference' => 'TRANSFER', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'destination_warehouse_id' => $destination->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '1.000', 'responsible_id' => $this->f['receiver']->id, 'reason' => 'TransferĂȘncia sujeita a nova qualidade no destino.']); expect(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(0)->and(lotPolicyContext($this)['usable_milli'])->toBe(1000); }); test('a policy author cannot approve the exemption and a revision cannot repartition the baseline again', function () { $active = initialLotPolicy($this); $draft = proposeLotPolicy($this, $active, ['quarantine_on_receipt' => false]); supplyGrant($this->f['issuer'], ['inventory_lot_review']); expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class); expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], supplyInput([...$draft->data, 'lots' => [['code' => 'FAKE', 'quantity' => '10.000']]], 'prepare', $draft)))->toThrow(ValidationException::class); expect(InventoryLotAllocation::query()->count())->toBe(1); });