188 lines
15 KiB
PHP
188 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Models\StockMovement;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\Warehouse;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\ReplenishmentProjection;
|
|
use App\Services\RequisitionCatalogue;
|
|
use App\Services\StockWorkflow;
|
|
use App\Services\SupplyHistory;
|
|
use App\Services\SupplyPlanning;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
require_once __DIR__.'/../Support/SupplyFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->travelTo(CarbonImmutable::parse('2026-09-30 12:00:00'));
|
|
$this->f = receiptFixture('1500.000');
|
|
supplyGrant($this->f['issuer'], ['procurement', 'supply_prepare', 'supply_history_prepare']);
|
|
supplyGrant($this->f['receiver'], ['warehouse', 'supply_review', 'supply_history_review', 'supply_reserve']);
|
|
$this->actingAs($this->f['receiver']);
|
|
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1200.000'));
|
|
historyIssue($this, '2026-10-10', '100.000');
|
|
historyIssue($this, '2026-10-20', '200.000');
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
|
$this->actingAs($this->f['issuer']);
|
|
});
|
|
|
|
function historyIssue($test, string $date, string $quantity): object
|
|
{
|
|
$test->travelTo(CarbonImmutable::parse($date.' 12:00:00'));
|
|
$test->actingAs($test->f['receiver']);
|
|
|
|
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'HIST-'.Str::random(10), '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' => 'Consumo fictício para testar o período histórico.']);
|
|
}
|
|
|
|
function historyData(array $f): array
|
|
{
|
|
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purpose' => 'internal', 'period_from' => '2026-10-01', 'period_until' => '2026-10-30', 'quality' => 'complete', 'pattern' => 'regular'];
|
|
}
|
|
|
|
function approvedHistory($test, array $extra = []): SupplyRecord
|
|
{
|
|
$test->actingAs($test->f['issuer'])->postJson('/compras/fornecimento/history', supplyInput([...historyData($test->f), ...$extra]))->assertRedirect();
|
|
$draft = app(SupplyPlanning::class)->latest('history')->sole();
|
|
$test->actingAs($test->f['receiver'])->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertRedirect();
|
|
|
|
return app(SupplyPlanning::class)->latest('history')->sole();
|
|
}
|
|
|
|
function historyPolicy($test, SupplyRecord $history, array $extra = []): SupplyRecord
|
|
{
|
|
$data = [...supplyPolicy($test->f), 'purpose' => 'internal', 'safety_milli' => 600000, 'minimum_stock_milli' => 600000, 'target_milli' => 900000, 'maximum_milli' => 1200000,
|
|
'forecast_method' => 'historical', 'history_key' => $history->record_key, 'history_min_days' => 30, 'history_max_age_days' => 90, 'daily_milli' => 999999, ...$extra];
|
|
$test->actingAs($test->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput($data))->assertRedirect();
|
|
$draft = app(SupplyPlanning::class)->latest('policy')->sole();
|
|
$test->actingAs($test->f['receiver'])->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $draft))->assertRedirect();
|
|
|
|
return app(SupplyPlanning::class)->latest('policy')->sole();
|
|
}
|
|
|
|
test('historical mean includes all calendar days and policy reuses the approved period without accepting forged totals', function () {
|
|
$history = approvedHistory($this, ['total_milli' => 999999, 'daily_mean_milli' => '999']);
|
|
expect($history->snapshot['analysis']['total_milli'])->toBe(300000)->and($history->snapshot['analysis']['days'])->toBe(30)
|
|
->and($history->snapshot['analysis']['daily_mean_milli'])->toBe('10000.000000')->and($history->snapshot['analysis']['active_days'])->toBe(2);
|
|
$policy = historyPolicy($this, $history);
|
|
expect($policy->data['daily_milli'])->toBeNull()->and($policy->data['history_version'])->toBe($history->version);
|
|
$effective = app(SupplyHistory::class)->resolve($policy);
|
|
$projection = app(ReplenishmentProjection::class)->calculate($effective['data'], 900000, today()->toDateString());
|
|
expect($projection['method'])->toBe('historical_mean_calendar_days')->and($projection['daily_milli'])->toBe('10000.000000')->and($projection['series'][0]['demand_milli'])->toBe(10000);
|
|
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $page) => $page->where('projections.'.$policy->record_key.'.forecast_history.available', true));
|
|
});
|
|
|
|
test('transfers adjustments counts and receptions affect stock but never inflate consumption', function () {
|
|
$this->travelTo(CarbonImmutable::parse('2026-10-22 12:00:00'));
|
|
$this->actingAs($this->f['receiver']);
|
|
$other = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'HIST-2', 'name' => 'Outro local do ensaio']);
|
|
$base = ['responsible_id' => $this->f['receiver']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'reason' => 'Operação não representa consumo na análise.'];
|
|
app(StockWorkflow::class)->record($this->f['receiver'], [...$base, 'kind' => 'transfer', 'reference' => 'HIST-TRANS', 'idempotency_key' => (string) Str::uuid(), 'destination_warehouse_id' => $other->id, 'quantity' => '10.000']);
|
|
$version = app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['version'];
|
|
$count = app(StockWorkflow::class)->record($this->f['receiver'], [...$base, 'kind' => 'count', 'reference' => 'HIST-COUNT', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '905.000', 'ledger_version' => $version]);
|
|
app(StockWorkflow::class)->record($this->f['receiver'], [...$base, 'kind' => 'adjustment', 'reference' => 'HIST-ADJUST', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '905.000', 'count_id' => $count->id]);
|
|
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '50.000'));
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
|
$analysis = app(SupplyHistory::class)->analyse($this->f['stock']->id, $this->f['warehouse']->id, '2026-10-01', '2026-10-30');
|
|
expect($analysis['total_milli'])->toBe(300000)->and($analysis['movement_types']['issue'])->toBe(2)->and($analysis['movement_types']['transfer_out'])->toBe(1)
|
|
->and($analysis['movement_types']['adjustment'])->toBe(1)->and($analysis['movement_types']['receipt_in'])->toBe(1);
|
|
});
|
|
|
|
test('unknown history remains unavailable whereas reviewed zero consumption is a genuine zero and known needs remain', function () {
|
|
$history = approvedHistory($this, ['period_from' => '2026-10-21']);
|
|
$policy = historyPolicy($this, $history, ['history_min_days' => 10]);
|
|
$effective = app(SupplyHistory::class)->resolve($policy);
|
|
$zero = app(ReplenishmentProjection::class)->calculate($effective['data'], 900000, today()->toDateString());
|
|
$unknown = app(ReplenishmentProjection::class)->calculate([...supplyPolicy($this->f), 'daily_milli' => null], 900000, today()->toDateString());
|
|
expect($zero['available'])->toBeTrue()->and($zero['daily_milli'])->toBe('0.000000')->and($zero['quantity_milli'])->toBe(0)->and($unknown['available'])->toBeFalse();
|
|
$known = app(ReplenishmentProjection::class)->calculate($effective['data'], 900000, today()->toDateString(), [['date' => today()->addDay()->toDateString(), 'quantity' => 350000, 'additional' => true]]);
|
|
expect($known['risk_date'])->toBe(today()->addDay()->toDateString())->and($known['quantity_milli'])->toBeGreaterThan(0);
|
|
});
|
|
|
|
test('fractional milliunit average is projected cumulatively instead of becoming false zero', function () {
|
|
historyIssue($this, '2026-10-28', '0.001');
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
|
$history = approvedHistory($this, ['period_from' => '2026-10-28']);
|
|
$policy = historyPolicy($this, $history, ['history_min_days' => 3]);
|
|
$data = app(SupplyHistory::class)->resolve($policy)['data'];
|
|
$projection = app(ReplenishmentProjection::class)->calculate([...$data, 'horizon_days' => 3], 900000, today()->toDateString());
|
|
expect($projection['daily_milli'])->toBe('0.333333')->and(array_sum(array_column($projection['series'], 'demand_milli')))->toBe(1);
|
|
});
|
|
|
|
test('independent confirmation is mandatory and late historical entries invalidate rather than overwrite an approved analysis', function () {
|
|
$input = supplyInput(historyData($this->f));
|
|
$this->postJson('/compras/fornecimento/history', $input)->assertRedirect();
|
|
$this->postJson('/compras/fornecimento/history', $input)->assertRedirect();
|
|
$draft = app(SupplyPlanning::class)->latest('history')->sole();
|
|
supplyGrant($this->f['issuer'], ['supply_history_review']);
|
|
$this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertUnprocessable();
|
|
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/history', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
|
$this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertRedirect();
|
|
$approved = app(SupplyPlanning::class)->latest('history')->sole();
|
|
$policy = historyPolicy($this, $approved);
|
|
historyIssue($this, '2026-10-25', '1.000');
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
|
$result = app(SupplyHistory::class)->resolve($policy);
|
|
expect($result['history']['available'])->toBeFalse()->and($result['data']['daily_milli'])->toBeNull()->and($approved->fresh()->snapshot['analysis']['total_milli'])->toBe(300000);
|
|
});
|
|
|
|
test('insufficient coverage and nonregular patterns cannot silently feed a simple average', function (string $quality, string $pattern) {
|
|
$history = approvedHistory($this, ['quality' => $quality, 'pattern' => $pattern]);
|
|
$data = [...supplyPolicy($this->f), 'purpose' => 'internal', 'forecast_method' => 'historical', 'history_key' => $history->record_key, 'history_min_days' => 30, 'history_max_age_days' => 90];
|
|
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput($data))->assertUnprocessable();
|
|
})->with([['unknown', 'regular'], ['incomplete', 'regular'], ['stockout', 'regular'], ['complete', 'seasonal'], ['complete', 'intermittent']]);
|
|
|
|
test('minimum coverage purpose age and suspension remain explicit policy conditions', function () {
|
|
$history = approvedHistory($this);
|
|
$data = [...supplyPolicy($this->f), 'forecast_method' => 'historical', 'history_key' => $history->record_key, 'history_min_days' => 31, 'history_max_age_days' => 90];
|
|
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput([...$data, 'purpose' => 'internal']))->assertUnprocessable();
|
|
$this->postJson('/compras/fornecimento/policy', supplyInput([...$data, 'history_min_days' => 30]))->assertUnprocessable();
|
|
$policy = historyPolicy($this, $history);
|
|
$this->travelTo(CarbonImmutable::parse('2027-02-01 12:00:00'));
|
|
expect(app(SupplyHistory::class)->resolve($policy)['history']['available'])->toBeFalse();
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
|
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/history', supplyInput([], 'suspend', $history))->assertRedirect();
|
|
expect(app(SupplyHistory::class)->resolve($policy)['history']['available'])->toBeFalse();
|
|
});
|
|
|
|
test('historical dates must be closed and the source ledger never crosses companies or changes through analysis', function () {
|
|
$before = StockMovement::query()->count();
|
|
$this->postJson('/compras/fornecimento/history', supplyInput([...historyData($this->f), 'period_until' => today()->toDateString()]))->assertUnprocessable();
|
|
$other = receiptFixture();
|
|
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/history', supplyInput([...historyData($this->f), 'stock_item_id' => $other['stock']->id]))->assertNotFound();
|
|
$this->get('/compras/fornecimento?view=history')->assertInertia(fn (Assert $page) => $page->component('supply/history')->where('canPrepare', true));
|
|
expect(StockMovement::query()->count())->toBe($before);
|
|
});
|
|
|
|
test('suggestions freeze the historical period and a suspended source cannot be used to convert an older approval', function () {
|
|
$history = approvedHistory($this);
|
|
$policy = historyPolicy($this, $history);
|
|
app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']),
|
|
'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Reutilização do catálogo no percurso histórico.']);
|
|
supplyGrant($this->f['issuer'], ['supply_suggest_prepare', 'supply_suggest_convert', 'requester']);
|
|
supplyGrant($this->f['receiver'], ['supply_suggest_review']);
|
|
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $policy->record_key, 'unit_price' => '10.00']))->assertRedirect();
|
|
$suggestion = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
|
expect($suggestion->snapshot['forecast_history']['version'])->toBe($history->version)->and($suggestion->snapshot['forecast_history']['analysis']['total_milli'])->toBe(300000);
|
|
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $suggestion))->assertRedirect();
|
|
$approved = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
|
$this->postJson('/compras/fornecimento/history', supplyInput([], 'suspend', $history))->assertRedirect();
|
|
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
|
|
expect($approved->fresh()->snapshot['forecast_history']['version'])->toBe($history->version)->and($approved->fresh()->state)->toBe('approved');
|
|
});
|
|
|
|
test('replacement policy can reuse all approved parameters by reference while activation remains independent and unique', function () {
|
|
$history = approvedHistory($this);
|
|
$source = historyPolicy($this, $history);
|
|
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput(['predecessor_key' => $source->record_key, 'predecessor_version' => $source->version]))->assertRedirect();
|
|
$copy = app(SupplyPlanning::class)->latest('policy')->where('record_key', '!=', $source->record_key)->sole();
|
|
expect($copy->data['target_milli'])->toBe($source->data['target_milli'])->and($copy->data['history_key'])->toBe($history->record_key)->and($copy->data['predecessor_version'])->toBe($source->version);
|
|
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $copy))->assertUnprocessable();
|
|
$this->postJson('/compras/fornecimento/policy', supplyInput([], 'suspend', $source))->assertRedirect();
|
|
$this->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $copy))->assertRedirect();
|
|
expect(app(SupplyPlanning::class)->latest('policy')->where('state', 'approved')->count())->toBe(1)->and($source->fresh()->data['target_milli'])->toBe(900000);
|
|
});
|