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

132 lines
10 KiB
PHP

<?php
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Services\ReplenishmentProjection;
use App\Services\StockWorkflow;
use App\Services\SupplyCoordination;
use App\Services\SupplyPlanning;
use Illuminate\Support\Str;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
beforeEach(function () {
$this->f = receiptFixture('100.000');
supplyGrant($this->f['issuer'], ['procurement', 'supply_delivery', 'supply_technical_prepare']);
supplyGrant($this->f['receiver'], ['warehouse', 'supply_reserve', 'supply_technical_review', 'supply_read']);
$this->actingAs($this->f['receiver']);
});
function reserveData(array $f, string $reference = 'NEC-1', int $quantity = 30000): array
{
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'quantity_milli' => $quantity, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => $reference];
}
function deliveryData(array $f): array
{
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purchase_order_item_id' => $f['line']->id, 'quantity_milli' => 100000,
'expected_on' => today()->addWeek()->toDateString(), 'confirmed_on' => today()->toDateString(), 'evidence' => 'Confirmação fictícia do fornecedor'];
}
function coordinationIssue($test, string $quantity): object
{
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'SAIDA-'.Str::random(8), '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' => 'Satisfação da necessidade no ensaio.']);
}
test('reservations enter calendar once and their allocations reuse existing issues without changing stock', function () {
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
$input = supplyInput(reserveData($this->f));
$this->postJson('/compras/fornecimento/reservation', $input)->assertRedirect();
$this->postJson('/compras/fornecimento/reservation', $input)->assertRedirect();
$reserve = SupplyRecord::query()->sole();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['reservations'])->toHaveCount(1)->and($events['reservations'][0]['quantity'])->toBe(30000);
$issue = coordinationIssue($this, '20.000');
$apply = supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 20000], 'fulfil', $reserve);
$this->postJson('/compras/fornecimento/reservation', $apply)->assertRedirect();
$this->postJson('/compras/fornecimento/reservation', $apply)->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['reservations'][0]['quantity'])->toBe(10000)->and($events['consumed_today'])->toBe(0)->and($events['fulfilled_needs'][0]['quantity'])->toBe(20000)
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(80000);
$second = supplyInput(reserveData($this->f, 'NEC-2', 10000));
$this->postJson('/compras/fornecimento/reservation', $second)->assertRedirect();
$other = app(SupplyPlanning::class)->latest('reservation')->latest('id')->first();
$this->postJson('/compras/fornecimento/reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 1], 'fulfil', $other))->assertUnprocessable();
});
test('future confirmations subtract actual received quantity and cancellation never erases stock', function () {
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/delivery', supplyInput(deliveryData($this->f)))->assertRedirect();
$confirmation = SupplyRecord::query()->sole();
$this->postJson('/compras/fornecimento/delivery', supplyInput(deliveryData($this->f)))->assertUnprocessable();
$receipt = receiptPayload($this->f, '20.000');
unset($receipt['items'][0]['stock_item_id']);
$this->actingAs($this->f['receiver'])->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', $receipt)->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['receipts'])->toHaveCount(1)->and($events['receipts'][0]['quantity'])->toBe(80000);
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/delivery', supplyInput([], 'cancel', $confirmation))->assertRedirect();
$this->actingAs($this->f['receiver']);
expect(app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString())['receipts'])->toBe([])
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(20000);
});
test('late confirmations produce warnings instead of masking earlier shortages', function () {
$data = [...deliveryData($this->f), 'expected_on' => today()->subDay()->toDateString()];
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/delivery', supplyInput($data))->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['receipts'])->toBe([])->and($events['warnings'])->toHaveCount(1);
});
test('technical equivalence is directional independently reviewed and invalidated by source change', function () {
$substitute = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'SUB-1', 'name' => 'Substituto do ensaio', 'unit' => 'cx']);
$data = ['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'substitute_id' => $substitute->id, 'application' => 'Aplicação do ensaio', 'compatibility' => 'equivalent',
'evidence' => 'Especificações e resultados do ensaio técnico.', 'factor_numerator' => 1, 'factor_denominator' => 1, 'valid_until' => today()->addYear()->toDateString()];
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/alternative', supplyInput($data))->assertRedirect();
$draft = SupplyRecord::query()->sole();
$this->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertForbidden();
supplyGrant($this->f['issuer'], ['supply_technical_review']);
$this->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertUnprocessable();
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertRedirect();
$approved = app(SupplyPlanning::class)->latest('alternative')->sole();
expect(app(SupplyCoordination::class)->eligibleAlternative($approved))->toBeTrue()
->and(app(SupplyPlanning::class)->latest('alternative')->where('stock_item_id', $substitute->id)->exists())->toBeFalse();
$substitute->update(['name' => 'Nova especificação de cadastro']);
expect(app(SupplyCoordination::class)->eligibleAlternative($approved))->toBeFalse()->and($approved->snapshot['substitute']['name'])->toBe('Substituto do ensaio');
});
test('known reservations survive unknown forecast and same day actual consumption is not deducted twice', function () {
$p = [...supplyPolicy($this->f), 'daily_milli' => null, 'horizon_days' => 3];
$result = app(ReplenishmentProjection::class)->calculate($p, 100000, today()->toDateString(), [['date' => today()->toDateString(), 'quantity' => 30000, 'additional' => false]]);
expect($result['available'])->toBeTrue()->and($result['demand_complete'])->toBeFalse()->and($result['series'][0]['balance_milli'])->toBe(70000);
$result = app(ReplenishmentProjection::class)->calculate([...$p, 'daily_milli' => 10000], 70000, today()->toDateString(), [], [], 30000);
expect($result['series'][0]['balance_milli'])->toBe(70000)->and($result['series'][1]['balance_milli'])->toBe(60000);
});
test('an early issue satisfies its future reservation without consuming its forecast a second time', function () {
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
$data = [...reserveData($this->f), 'needed_on' => today()->addDay()->toDateString()];
$this->postJson('/compras/fornecimento/reservation', supplyInput($data))->assertRedirect();
$reservation = SupplyRecord::query()->sole();
$issue = coordinationIssue($this, '30.000');
$this->postJson('/compras/fornecimento/reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 30000], 'fulfil', $reservation))->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['reservations'])->toBe([])->and($events['fulfilled_needs'][0]['date'])->toBe(today()->addDay()->toDateString());
$projection = app(ReplenishmentProjection::class)->calculate([...supplyPolicy($this->f), 'horizon_days' => 3], 70000, today()->toDateString(), $events['reservations'], $events['receipts'], $events['consumed_today'], $events['fulfilled_needs']);
expect($projection['series'][0]['balance_milli'])->toBe(60000)->and($projection['series'][1]['balance_milli'])->toBe(60000)->and($projection['series'][2]['balance_milli'])->toBe(50000);
});
test('future combined deliveries expose excess even when the proposed first receipt respects the maximum', function () {
$p = [...supplyPolicy($this->f), 'daily_milli' => 0, 'minimum_milli' => 0, 'pack_milli' => 1000, 'horizon_days' => 3];
$start = today()->toDateString();
$projection = app(ReplenishmentProjection::class)->calculate($p, 100000, $start, [['date' => $start, 'quantity' => 80000]], [['date' => today()->addDay()->toDateString(), 'quantity' => 100000, 'confirmed' => true]]);
expect($projection['excess_milli'])->toBe(0)->and($projection['future_excess_milli'])->toBe(80000)->and($projection['requires_exception'])->toBeTrue();
});
test('coordination permission never grants unrelated workflow commands or crosses companies', function () {
$this->postJson('/compras/fornecimento/delivery', supplyInput(deliveryData($this->f)))->assertForbidden();
$foreign = receiptFixture();
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/reservation', supplyInput(reserveData($foreign)))->assertNotFound();
$this->get('/compras/fornecimento?view=reservations')->assertOk();
});