137 lines
10 KiB
PHP
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);
|
|
});
|