Files
compras/tests/Feature/InventoryLotPolicyTest.php
T
2026-11-02 08:08:05 +02:00

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