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

137 lines
10 KiB
PHP

<?php
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Services\ReplenishmentProjection;
use App\Services\StockWorkflow;
use App\Services\SupplyPlanning;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
test('calendar keeps stock risk but withdraws deadlines when the reused agreement expires or supplier changes', function () {
$f = receiptFixture();
supplyGrant($f['issuer'], ['supply_prepare']);
supplyGrant($f['receiver'], ['supply_review']);
$planning = app(SupplyPlanning::class);
$this->actingAs($f['issuer']);
$draft = $planning->save($f['issuer'], 'agreement', supplyInput([
'stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'supplier_id' => $f['order']->supplier_id,
'habitual' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addDay()->toDateString(),
'origin' => 'local', 'currency' => 'MZN', 'price_minor' => null, 'lead_days' => 4, 'reference' => 'Prazo documentado de ensaio',
]));
$this->actingAs($f['receiver']);
$agreement = $planning->save($f['receiver'], 'agreement', supplyInput([], 'approve', $draft));
$this->actingAs($f['issuer']);
$draft = $planning->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'agreement_key' => $agreement->record_key]));
$this->actingAs($f['receiver']);
$policy = $planning->save($f['receiver'], 'policy', supplyInput([], 'approve', $draft));
$current = $planning->calendar($policy, today()->toDateString());
expect($current['available'])->toBeTrue()->and($current['request_date'])->not->toBeNull();
$expired = $planning->calendar($policy, today()->addDays(2)->toDateString());
expect($expired['available'])->toBeTrue()->and($expired['lead_time_available'])->toBeFalse()
->and($expired['request_date'])->toBeNull()->and($expired['order_date'])->toBeNull()->and($expired['reorder_point_milli'])->toBeNull();
$supplier = Supplier::query()->findOrFail($f['order']->supplier_id);
$supplier->update(['version' => $supplier->version + 1]);
expect($planning->calendar($policy, today()->toDateString())['lead_time_available'])->toBeFalse();
$f['stock']->update(['unit' => 'UNIDADE ALTERADA']);
expect($planning->calendar($policy, today()->toDateString())['available'])->toBeFalse();
});
test('replenishment reproduces examples A B and C without counting reservations or late arrivals twice', function () {
$p = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
$engine = app(ReplenishmentProjection::class);
$a = $engine->calculate($p, 120000, '2026-11-01');
expect($a['risk_date'])->toBe('2026-11-10')->and($a['order_date'])->toBe('2026-11-06')->and($a['request_date'])->toBe('2026-11-05')->and($a['quantity_milli'])->toBe(84000)->and($a['excess_milli'])->toBe(0);
$b = $engine->calculate([...$p, 'lead_days' => 2, 'minimum_milli' => 0, 'pack_milli' => 1000], 100000, '2026-11-01', [['date' => '2026-11-03', 'quantity' => 30000]], [['date' => '2026-11-10', 'quantity' => 40000, 'confirmed' => true]]);
expect($b['risk_date'])->toBe('2026-11-06')->and($b['shortage_date'])->toBe('2026-11-09')->and($b['quantity_milli'])->toBe(80000)->and($b['series'][2]['balance_milli'])->toBe(50000);
$c = $engine->calculate([...$p, 'safety_milli' => 35000, 'target_milli' => 50000, 'maximum_milli' => 65000, 'minimum_milli' => 40000, 'pack_milli' => 24000], 35000, '2026-11-01');
expect($c['base_milli'])->toBe(15000)->and($c['quantity_milli'])->toBe(48000)->and($c['excess_milli'])->toBe(18000)->and($c['requires_exception'])->toBeTrue();
expect($engine->calculate([...$p, 'daily_milli' => null], 120000, '2026-11-01')['available'])->toBeFalse();
});
test('supply approval is independent idempotent versioned and isolated by company', function () {
$f = receiptFixture();
supplyGrant($f['issuer'], ['supply_prepare', 'supply_review']);
supplyGrant($f['receiver'], ['supply_read', 'supply_review']);
$input = supplyInput(supplyPolicy($f));
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', $input)->assertRedirect();
$this->postJson('/compras/fornecimento/policy', $input)->assertRedirect();
expect(SupplyRecord::query()->count())->toBe(1);
$draft = SupplyRecord::query()->sole();
$approve = supplyInput([], 'approve', $draft);
$this->postJson('/compras/fornecimento/policy', $approve)->assertUnprocessable();
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/policy', $approve)->assertRedirect();
$this->postJson('/compras/fornecimento/policy', $approve)->assertRedirect();
expect(SupplyRecord::query()->count())->toBe(2)->and($draft->fresh()->state)->toBe('draft');
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput(supplyPolicy($f), 'save', $draft))->assertUnprocessable();
$this->get('/compras/fornecimento?view=calendar')->assertOk();
$foreign = receiptFixture();
supplyGrant($foreign['issuer'], ['supply_prepare']);
$this->actingAs($foreign['issuer'])->postJson('/compras/fornecimento/policy', supplyInput(supplyPolicy($f)))->assertNotFound();
});
test('supply source data comes from the catalogue and cannot be forged or overwritten after approval', function () {
$f = receiptFixture();
supplyGrant($f['issuer'], ['supply_prepare']);
supplyGrant($f['receiver'], ['supply_review']);
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput([...supplyPolicy($f), 'name' => 'FORGED']))->assertRedirect();
$draft = SupplyRecord::query()->sole();
expect($draft->snapshot['item']['name'])->toBe($f['stock']->name)->and($draft->data)->not->toHaveKey('name');
$f['stock']->update(['name' => 'Nome alterado posteriormente']);
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $draft))->assertUnprocessable();
expect(fn () => DB::table('supply_records')->where('id', $draft->id)->delete())->toThrow(QueryException::class);
});
test('import tracking cannot bypass permissions create duplicate orders or make arrived goods usable', function () {
$f = receiptFixture();
supplyGrant($f['issuer'], ['import_prepare', 'procurement']);
supplyGrant($f['receiver'], ['warehouse', 'import_customs', 'import_read']);
$input = supplyInput(['purchase_order_id' => $f['order']->id, 'purpose' => 'internal', 'jurisdiction' => 'Jurisdição de ensaio', 'origin_country' => 'País A', 'dispatch_country' => 'País B', 'regime' => 'Regime documentado']);
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/import', $input)->assertRedirect();
$draft = SupplyRecord::query()->sole();
$this->postJson('/compras/fornecimento/import', supplyInput($input['data']))->assertUnprocessable();
$event = ['evidence' => 'Documento de ensaio', 'event_date' => today()->toDateString()];
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'release', $draft))->assertForbidden();
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'transit', $draft))->assertRedirect();
$transit = SupplyRecord::query()->latest('id')->first();
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'arrive', $transit))->assertRedirect();
$arrived = SupplyRecord::query()->latest('id')->first();
$this->actingAs($f['receiver'])->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f))->assertUnprocessable();
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'release', $arrived))->assertRedirect();
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f))->assertRedirect();
expect(app(StockWorkflow::class)->balance($f['stock']->id, $f['warehouse']->id)['quantity'])->toBe(600);
});
test('only one habitual agreement overlaps an item and warehouse and stale supplier approval is refused', function () {
$f = receiptFixture();
supplyGrant($f['issuer'], ['supply_prepare']);
supplyGrant($f['receiver'], ['supply_review']);
$data = ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'supplier_id' => $f['order']->supplier_id,
'habitual' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addMonth()->toDateString(), 'origin' => 'local', 'currency' => 'MZN', 'price_minor' => null, 'lead_days' => null, 'reference' => 'Contrato fictício'];
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/agreement', supplyInput($data))->assertRedirect();
$draft = SupplyRecord::query()->sole();
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/agreement', supplyInput([], 'approve', $draft))->assertRedirect();
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/agreement', supplyInput($data))->assertRedirect();
$duplicate = SupplyRecord::query()->latest('id')->first();
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/agreement', supplyInput([], 'approve', $duplicate))->assertUnprocessable();
expect($draft->data['price_minor'])->toBeNull()->and($draft->data['lead_days'])->toBeNull();
$supplier = Supplier::query()->findOrFail($f['order']->supplier_id);
$supplier->update(['version' => $supplier->version + 1]);
$this->postJson('/compras/fornecimento/agreement', supplyInput([], 'approve', $duplicate))->assertUnprocessable();
});
test('revoked access and forged operation retries do not alter existing policies', function () {
$f = receiptFixture();
supplyGrant($f['issuer'], ['supply_prepare']);
$input = supplyInput(supplyPolicy($f));
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', $input)->assertRedirect();
$this->postJson('/compras/fornecimento/policy', [...$input, 'reason' => 'Outro fundamento com a mesma chave.'])->assertUnprocessable();
DB::table('organization_memberships')->where('user_id', $f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
$this->postJson('/compras/fornecimento/policy', supplyInput(supplyPolicy($f)))->assertForbidden();
expect(SupplyRecord::query()->count())->toBe(1);
});