136 lines
9.0 KiB
PHP
136 lines
9.0 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\StockWorkflow;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
require_once __DIR__.'/../Support/SupplyFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->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);
|
|
});
|