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

153 lines
10 KiB
PHP

<?php
use App\Models\GoodsReceipt;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Services\InventoryCounts;
use App\Services\InventoryLots;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\StockWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
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_adjust_review']);
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release']);
supplyGrant($this->f['otherReceiver'], ['inventory_lot_release']);
$this->actingAs($this->f['receiver']);
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
$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' => [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000'],
['code' => 'B', 'expires_on' => today()->addDays(20)->toDateString(), 'quantity' => '4.000']]], 'prepare'));
$this->actingAs($this->f['receiver']);
app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $draft));
});
function countContext($test): array
{
return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id);
}
function countedPayload($test, string $a = '5.000', string $b = '5.000'): array
{
$lots = countContext($test)['lots'];
return ['kind' => 'count', 'reference' => 'COUNT-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id,
'responsible_id' => $test->f['receiver']->id, 'quantity' => (string) BigDecimal::of($a)->plus($b)->toScale(3), 'reason' => 'Contagem física fictícia repartida por lote.',
'ledger_version' => countContext($test)['physical']['version'], 'lot_version' => app(InventoryCounts::class)->source($test->f['stock']->id, $test->f['warehouse']->id)['hash'],
'lot_counts' => [['lot_id' => $lots[0]['id'], 'quantity' => $a], ['lot_id' => $lots[1]['id'], 'quantity' => $b]]];
}
function prepareCount($test, string $a = '5.000', string $b = '5.000'): StockOperation
{
$test->actingAs($test->f['receiver']);
return app(StockWorkflow::class)->record($test->f['receiver'], countedPayload($test, $a, $b));
}
function applyCount($test, StockOperation $count, ?array $payload = null): StockOperation
{
$test->actingAs($test->f['issuer']);
return app(StockWorkflow::class)->record($test->f['issuer'], $payload ?? ['kind' => 'adjustment', 'reference' => 'ADJ-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(),
'warehouse_id' => $count->warehouse_id, 'stock_item_id' => $count->stock_item_id, 'responsible_id' => $test->f['issuer']->id, 'quantity' => '0.000', 'count_id' => $count->id, 'reason' => 'Conferência independente da diferença contada.']);
}
test('a count alone never changes physical stock and a zero-net correction reconciles lot differences', function () {
$count = prepareCount($this);
expect(StockMovement::query()->count())->toBe(1)->and(countContext($this)['physical']['quantity'])->toBe(10000);
$op = applyCount($this, $count);
$context = countContext($this);
expect(array_column($context['lots'], 'quantity_milli'))->toBe([5000, 5000])->and($context['physical']['quantity'])->toBe(10000)->and($context['reconciled'])->toBeTrue()
->and(StockMovement::query()->where('stock_operation_id', $op->id)->orderBy('id')->pluck('quantity_milli')->all())->toBe([-1000, 1000]);
expect($op->snapshot['lot_adjustment']['increase_milli'])->toBe(1000)->and($op->snapshot['lot_adjustment']['decrease_milli'])->toBe(1000);
});
test('negative correction can reconcile held or expired material without recording consumption', function () {
$this->travel(25)->days();
$count = prepareCount($this, '3.000', '0.000');
$op = applyCount($this, $count);
expect(countContext($this)['physical']['quantity'])->toBe(3000)->and(countContext($this)['usable_milli'])->toBe(0)
->and(StockMovement::query()->where('stock_operation_id', $op->id)->pluck('movement_type')->all())->toBe(['adjustment_out']);
});
test('positive corrections quarantine the whole affected lot and preserve the unaffected release', function () {
foreach (countContext($this)['lots'] as $lot) {
$meta = SupplyRecord::query()->findOrFail($lot['metadata_id']);
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => 0], 'release', $meta));
}
applyCount($this, prepareCount($this, '7.000', '4.000'));
$lots = countContext($this)['lots'];
expect($lots[0]['state'])->toBe('held')->and($lots[1]['state'])->toBe('released')->and(countContext($this)['usable_milli'])->toBe(4000);
$meta = SupplyRecord::query()->findOrFail($lots[0]['metadata_id']);
supplyGrant($this->f['issuer'], ['inventory_lot_release']);
$input = supplyInput(['lot_id' => $lots[0]['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lots[0]['state_version']], 'release', $meta);
expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], $input))->toThrow(ValidationException::class);
$this->actingAs($this->f['otherReceiver']);
app(InventoryLots::class)->save($this->f['otherReceiver'], $input);
expect(countContext($this)['usable_milli'])->toBe(11000);
});
test('found quantities create identity once from the count without fabricating a supplier receipt', function () {
$payload = countedPayload($this, '6.000', '4.000');
$payload['quantity'] = '12.000';
$payload['lot_counts'][] = ['new_key' => (string) Str::uuid(), 'quantity' => '2.000'];
$count = app(StockWorkflow::class)->record($this->f['receiver'], $payload);
$op = applyCount($this, $count);
$found = collect(countContext($this)['lots'])->firstWhere('data.code', null);
expect($found['state'])->toBe('unidentified')->and($found['quantity_milli'])->toBe(2000)->and($found['origin']['count_id'])->toBe($count->id)
->and(GoodsReceipt::query()->count())->toBe(1)->and($op->snapshot['lot_adjustment']['rows'][2]['lot_id'])->toBe($found['id']);
});
test('count self approval and ungranted adjustment are forbidden even for warehouse operators', function () {
$count = prepareCount($this);
$payload = ['kind' => 'adjustment', 'reference' => 'SELF', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $count->warehouse_id,
'stock_item_id' => $count->stock_item_id, 'responsible_id' => $this->f['receiver']->id, 'quantity' => '0.000', 'count_id' => $count->id, 'reason' => 'Tentativa de ajustar a própria contagem.'];
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $payload))->toThrow(HttpException::class);
supplyGrant($this->f['receiver'], ['inventory_adjust_review']);
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $payload))->toThrow(ValidationException::class);
expect(StockMovement::query()->count())->toBe(1);
});
test('quantities, identities and metadata must match the frozen count baseline', function () {
$bad = countedPayload($this);
$bad['lot_counts'][1]['lot_id'] = $bad['lot_counts'][0]['lot_id'];
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $bad))->toThrow(ValidationException::class);
$bad = countedPayload($this);
$bad['quantity'] = '11.000';
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $bad))->toThrow(ValidationException::class);
$count = prepareCount($this);
$this->actingAs($this->f['issuer']);
$meta = SupplyRecord::query()->findOrFail(countContext($this)['lots'][0]['metadata_id']);
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'NEW', 'expires_on' => today()->addDays(11)->toDateString()], 'identify', $meta));
expect(fn () => applyCount($this, $count))->toThrow(ValidationException::class);
});
test('review-only profile can apply the immutable count through HTTP but cannot create a count or issue', function () {
$count = prepareCount($this);
$payload = ['kind' => 'adjustment', 'reference' => 'HTTP-ADJ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $count->warehouse_id,
'stock_item_id' => $count->stock_item_id, 'responsible_id' => $this->f['issuer']->id, 'count_id' => $count->id, 'reason' => 'Conferência independente pelo perfil próprio.'];
$this->actingAs($this->f['issuer'])->postJson('/stock/operacoes', $payload)->assertCreated();
$this->postJson('/stock/operacoes', $payload)->assertOk();
$this->get('/stock/operacoes')->assertOk();
$this->postJson('/stock/operacoes', [...$payload, 'kind' => 'count'])->assertForbidden();
expect(StockMovement::query()->count())->toBe(3);
});
test('an audit failure rolls back both signed movements, found identities and allocations', function () {
$payload = countedPayload($this, '5.000', '4.000');
$payload['quantity'] = '11.000';
$payload['lot_counts'][] = ['new_key' => (string) Str::uuid(), 'quantity' => '2.000'];
$count = app(StockWorkflow::class)->record($this->f['receiver'], $payload);
$before = SupplyRecord::query()->count();
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
expect(fn () => applyCount($this, $count))->toThrow(RuntimeException::class);
expect(StockMovement::query()->count())->toBe(1)->and(SupplyRecord::query()->count())->toBe($before)->and(countContext($this)['reconciled'])->toBeTrue();
});