335 lines
24 KiB
PHP
335 lines
24 KiB
PHP
<?php
|
|
|
|
use App\Models\InventoryLotAllocation;
|
|
use App\Models\StockMovement;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\Warehouse;
|
|
use App\Services\InventoryLots;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\ReplenishmentProjection;
|
|
use App\Services\StockWorkflow;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
require_once __DIR__.'/../Support/SupplyFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->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 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);
|
|
});
|