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