351 lines
28 KiB
PHP
351 lines
28 KiB
PHP
<?php
|
|
|
|
use App\Models\CriticalityLevel;
|
|
use App\Models\Equipment;
|
|
use App\Models\EquipmentSparePart;
|
|
use App\Models\Requisition;
|
|
use App\Models\SparePart;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\User;
|
|
use App\Services\AutomaticPurchases;
|
|
use App\Services\InventoryLots;
|
|
use App\Services\ReplenishmentQueue;
|
|
use App\Services\ReplenishmentRun;
|
|
use App\Services\RequisitionCatalogue;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\StockWorkflow;
|
|
use App\Services\SupplyPlanning;
|
|
use App\Services\WarehouseFulfilment;
|
|
use App\Services\WarehouseRequests;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
|
|
|
function pilotWork($test): int
|
|
{
|
|
activateAutomaticRule($test);
|
|
$queue = Mockery::mock(ReplenishmentQueue::class)->makePartial();
|
|
$queue->shouldReceive('interactive')->andReturn(false);
|
|
app()->instance(ReplenishmentQueue::class, $queue);
|
|
automaticIssue($test, '120.000');
|
|
|
|
return DB::table('replenishment_work')->where('tenant_id', $test->f['tenant']->id)->where('stock_item_id', $test->f['stock']->id)->value('id');
|
|
}
|
|
|
|
beforeEach(function () {
|
|
$this->f = suggestionFixture();
|
|
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
|
$this->actingAs($this->f['issuer']);
|
|
$policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
|
|
$this->actingAs($this->f['receiver']);
|
|
$this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
|
$this->actingAs($this->f['issuer']);
|
|
$this->input = ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização autorizada para o ensaio isolado.',
|
|
'data' => ['policy_key' => $this->f['policy']->record_key, 'requester_id' => $this->f['issuer']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]];
|
|
});
|
|
|
|
function activateAutomaticRule($test)
|
|
{
|
|
$test->actingAs($test->f['issuer']);
|
|
$rule = app(AutomaticPurchases::class)->save($test->f['issuer'], $test->input);
|
|
$test->actingAs($test->f['receiver']);
|
|
|
|
return app(AutomaticPurchases::class)->save($test->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'reason' => 'Activação independente da parametrização verificada.', 'data' => []]);
|
|
}
|
|
|
|
function automaticIssue($test, string $quantity, array $extra = [])
|
|
{
|
|
$test->actingAs($test->f['receiver']);
|
|
|
|
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'TEST-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['issuer']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'destination' => 'Manutenção interna', 'reason' => 'Consumo físico confirmado no ensaio isolado.', ...$extra]);
|
|
}
|
|
|
|
test('automatic purchase reuses approved policy catalogue agreement and tax while preserving human submission and approvals', function () {
|
|
$rule = activateAutomaticRule($this);
|
|
expect($rule->data['unit_price'])->toBe('10.00');
|
|
$count = Requisition::query()->count();
|
|
automaticIssue($this, '100.000');
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
|
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
|
expect(Requisition::query()->count())->toBe($count + 1)->and($r->status)->toBe('draft')->and($r->requester_id)->toBe($this->f['issuer']->id)->and($r->cost_center_id)->toBe($this->f['r']->cost_center_id)
|
|
->and($r->items()->sole()->quantity)->toBe('84.000')->and($r->steps()->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0)
|
|
->and(DB::table('audit_logs')->where('action', 'requisition.automatically_created')->value('user_id'))->toBe($this->f['receiver']->id);
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
expect(Requisition::query()->count())->toBe($count + 1)->and(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('covered');
|
|
$this->actingAs($this->f['issuer']);
|
|
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $r);
|
|
expect($r->fresh()->status)->toBe('pending')->and($r->steps()->first()->role)->toBe('department_head');
|
|
});
|
|
|
|
test('missing configuration records a pending need and never invents a requester or budget', function () {
|
|
$before = Requisition::query()->count();
|
|
automaticIssue($this, '120.000');
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
|
expect($need->state)->toBe('pending')->and($need->data['purchases'])->toBe([])->and($need->data['message'])->toContain('Parametrize')->and(Requisition::query()->count())->toBe($before);
|
|
});
|
|
|
|
test('supply task auto rule approval connects the reviewer to one requester draft while preserving human approvals', function () {
|
|
$planning = app(SupplyPlanning::class);
|
|
$this->actingAs($this->f['receiver']);
|
|
$planning->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
|
$this->actingAs($this->f['issuer']);
|
|
$draft = $planning->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key,
|
|
'safety_milli' => 120000, 'minimum_stock_milli' => 120000, 'target_milli' => 140000, 'maximum_milli' => 160000, 'daily_milli' => 0, 'minimum_milli' => 0, 'pack_milli' => 1000]));
|
|
$this->actingAs($this->f['receiver']);
|
|
$policy = $planning->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft));
|
|
$this->actingAs($this->f['issuer']);
|
|
$input = $this->input;
|
|
$input['data']['policy_key'] = $policy->record_key;
|
|
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $input);
|
|
supplyGrant($this->f['issuer'], ['supply_review']);
|
|
$this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('rules.data.0.canApprove', false));
|
|
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
|
->where('tasks.data.0.href', '/stock/reposicao?rule_id='.$rule->id.'#supply-rule-'.$rule->id));
|
|
$this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('rules.total', 1)->where('rules.data.0.canApprove', true));
|
|
$count = Requisition::query()->count();
|
|
$approval = ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'data' => [], 'reason' => 'Conferência independente apenas no ensaio descartável.'];
|
|
$this->post('/stock/reposicao', $approval)->assertSessionHasNoErrors();
|
|
$this->post('/stock/reposicao', $approval)->assertSessionHasNoErrors();
|
|
$requisition = Requisition::query()->where('status', 'draft')->sole();
|
|
expect(Requisition::query()->count())->toBe($count + 1)->and($requisition->requester_id)->toBe($this->f['issuer']->id)
|
|
->and($requisition->items()->sole()->quantity)->toBe('20.000')->and($requisition->steps()->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0);
|
|
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
$this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('rules.data.0.state', 'approved')->where('rules.data.0.canApprove', false));
|
|
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'prepare')->where('tasks.data.0.href', '/requisicoes/'.$requisition->id));
|
|
expect($requisition->fresh()->status)->toBe('draft');
|
|
});
|
|
|
|
test('supply task auto rule preserves the existing ability of an authorised preparer to suspend an active rule', function () {
|
|
$approved = activateAutomaticRule($this);
|
|
supplyGrant($this->f['issuer'], ['supply_review']);
|
|
$this->actingAs($this->f['issuer'])->get('/stock/reposicao?rule_id='.$approved->id)->assertInertia(fn (Assert $page) => $page->where('rules.data.0.canApprove', false)->where('rules.data.0.canSuspend', true));
|
|
expect(app(AutomaticPurchases::class)->canReview($this->f['issuer'], $approved, 'suspend'))->toBeTrue();
|
|
$this->post('/stock/reposicao', ['operation_key' => (string) Str::uuid(), 'record_key' => $approved->record_key, 'version' => $approved->version, 'action' => 'suspend', 'data' => [], 'reason' => 'Suspender a regra pelo percurso já existente.'])->assertSessionHasNoErrors();
|
|
expect(app(SupplyPlanning::class)->latest('auto_rule')->sole()->state)->toBe('suspended');
|
|
});
|
|
|
|
test('durable signals survive the missing immediate callback and scheduled recovery never impersonates or duplicates drafts', function () {
|
|
$id = pilotWork($this);
|
|
$before = Requisition::query()->count();
|
|
$row = DB::table('replenishment_work')->find($id);
|
|
expect($row->generation)->toBeGreaterThan($row->processed_generation);
|
|
auth()->guard()->logout();
|
|
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue()->and(auth()->check())->toBeFalse();
|
|
$r = Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->latest('id')->firstOrFail();
|
|
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1)->and($r->status)->toBe('draft');
|
|
$audit = DB::table('audit_logs')->where('requisition_id', $r->id)->where('action', 'requisition.automatically_created')->sole();
|
|
expect(json_decode($audit->after, true)['execution']['mode'])->toBe('scheduled_replenishment')->and($audit->ip_address)->toBeNull();
|
|
DB::table('replenishment_work')->where('id', $id)->update(['due_at' => now()->subMinute()]);
|
|
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue();
|
|
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1)->and(app(ReplenishmentRun::class)->tenantId())->toBeNull();
|
|
});
|
|
|
|
test('failure after draft creation rolls back the draft and checkpoint before a successful retry', function () {
|
|
$id = pilotWork($this);
|
|
$before = Requisition::query()->count();
|
|
$checkpoint = DB::table('replenishment_work')->where('id', $id)->value('processed_generation');
|
|
auth()->guard()->logout();
|
|
$injected = false;
|
|
DB::listen(function ($query) use (&$injected) {
|
|
if (! $injected && str_contains($query->sql, 'replenishment_work') && str_contains($query->sql, 'processed_generation') && str_starts_with(strtolower($query->sql), 'update')) {
|
|
$injected = true;
|
|
throw new RuntimeException('Injected checkpoint interruption');
|
|
}
|
|
});
|
|
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeFalse();
|
|
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before);
|
|
$row = DB::table('replenishment_work')->find($id);
|
|
expect($row->processed_generation)->toBe($checkpoint)->and($row->failures)->toBe(1);
|
|
DB::table('replenishment_work')->where('id', $id)->update(['due_at' => now()->subMinute()]);
|
|
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue();
|
|
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1);
|
|
});
|
|
|
|
test('periodic discovery rechecks configured minimum and expired rules without stock movements', function () {
|
|
activateAutomaticRule($this);
|
|
automaticIssue($this, '100.000');
|
|
auth()->guard()->logout();
|
|
$queue = app(ReplenishmentQueue::class);
|
|
$queue->discover();
|
|
expect($queue->drain(100)['failed'])->toBe(0);
|
|
$need = SupplyRecord::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->where('kind', 'auto_need')->latest('id')->firstOrFail();
|
|
expect($need->data['purchases'])->toHaveCount(1);
|
|
$count = DB::table('stock_movements')->where('tenant_id', $this->f['tenant']->id)->count();
|
|
$this->travel(32)->days();
|
|
expect($queue->drain(100)['failed'])->toBe(0);
|
|
$latest = SupplyRecord::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->where('kind', 'auto_need')->latest('id')->firstOrFail();
|
|
expect($latest->state)->toBe('pending')->and($latest->data['message'])->toContain('expirou')->and(DB::table('stock_movements')->where('tenant_id', $this->f['tenant']->id)->count())->toBe($count);
|
|
});
|
|
|
|
test('pending replenishment pagination retains old needs and never exposes another tenant', function () {
|
|
$tenant = $this->f['tenant']->id;
|
|
for ($i = 0; $i < 105; $i++) {
|
|
SupplyRecord::query()->create(['tenant_id' => $tenant, 'kind' => 'auto_need', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'pending', 'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'created_by' => $this->f['issuer']->id, 'data' => ['message' => 'Necessidade pendente de teste.'], 'snapshot' => ['item' => ['id' => $this->f['stock']->id], 'warehouse' => ['id' => $this->f['warehouse']->id], 'origins' => []], 'reason' => 'Registo isolado para conferir paginação.', 'operation_key' => (string) Str::uuid(), 'input_hash' => hash('sha256', (string) $i), 'created_at' => now()->subDays(106 - $i)]);
|
|
}
|
|
$first = SupplyRecord::query()->where('kind', 'auto_need')->oldest('id')->value('id');
|
|
$foreign = receiptFixture();
|
|
SupplyRecord::query()->create(['tenant_id' => $foreign['tenant']->id, 'kind' => 'auto_need', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'pending', 'stock_item_id' => $foreign['stock']->id, 'warehouse_id' => $foreign['warehouse']->id, 'created_by' => $foreign['issuer']->id, 'data' => ['message' => 'Outra empresa'], 'snapshot' => [], 'reason' => 'Origem de outra empresa no ensaio isolado.', 'operation_key' => (string) Str::uuid(), 'input_hash' => str_repeat('a', 64), 'created_at' => now()->subYear()]);
|
|
$this->actingAs($this->f['issuer']);
|
|
$this->get('/stock/reposicao?need_state=pending')->assertOk()->assertInertia(fn (Assert $p) => $p->where('needs.total', 105)->has('needs.data', 20)->where('needs.data.0.id', $first)->where('needs.current_page', 1));
|
|
$this->get('/stock/reposicao?need_state=pending&needs_page=6')->assertOk()->assertInertia(fn (Assert $p) => $p->where('needs.total', 105)->has('needs.data', 5)->where('needs.current_page', 6));
|
|
});
|
|
|
|
test('a rolled back physical command also rolls back its durable replenishment signal', function () {
|
|
$this->actingAs($this->f['receiver']);
|
|
$before = DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->value('generation');
|
|
expect(fn () => DB::transaction(function () {
|
|
automaticIssue($this, '1.000');
|
|
throw new RuntimeException('Interrupted physical transaction');
|
|
}))->toThrow(RuntimeException::class);
|
|
expect(DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->value('generation'))->toBe($before)
|
|
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(120000);
|
|
});
|
|
|
|
test('background execution cannot be borrowed by an authenticated session', function () {
|
|
$id = pilotWork($this);
|
|
expect(fn () => app(ReplenishmentQueue::class)->scheduled($id))->toThrow(HttpException::class);
|
|
expect(app(ReplenishmentRun::class)->tenantId())->toBeNull();
|
|
});
|
|
|
|
test('the pending work list exposes interrupted articles but recovery still requires an explicit operational task', function () {
|
|
$id = pilotWork($this);
|
|
DB::table('replenishment_work')->where('id', $id)->update(['failures' => 1, 'last_error' => 'Recuperação automática pendente.']);
|
|
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
|
supplyGrant($admin, ['admin']);
|
|
$this->actingAs($admin)->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', false)->where('workItems.total', 1)->where('workItems.data.0.id', $id));
|
|
$this->post('/stock/reposicao/trabalhos/'.$id.'/avaliar')->assertForbidden();
|
|
$this->actingAs($this->f['issuer'])->post('/stock/reposicao/trabalhos/'.$id.'/avaliar')->assertRedirect();
|
|
});
|
|
|
|
test('replenishment workers do not process the SGF composition', function () {
|
|
$id = pilotWork($this);
|
|
auth()->guard()->logout();
|
|
config(['work_context.composition' => 'sgf']);
|
|
expect(app(ReplenishmentQueue::class)->ready())->toBeFalse();
|
|
expect(fn () => app(ReplenishmentQueue::class)->scheduled($id))->toThrow(HttpException::class);
|
|
expect(app(ReplenishmentRun::class)->tenantId())->toBeNull();
|
|
});
|
|
|
|
test('periodic evaluation detects lot expiry without physical movements and uses the existing draft workflow', function () {
|
|
supplyGrant($this->f['issuer'], ['inventory_lot_prepare']);
|
|
supplyGrant($this->f['receiver'], ['inventory_lot_review', 'inventory_lot_release']);
|
|
$this->actingAs($this->f['issuer']);
|
|
$setup = 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' => 'EXP-PILOT', 'expires_on' => today()->addDays(2)->toDateString()]]], 'prepare'));
|
|
$this->actingAs($this->f['receiver']);
|
|
app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $setup));
|
|
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
|
|
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
|
activateAutomaticRule($this);
|
|
$before = Requisition::query()->count();
|
|
$moves = DB::table('stock_movements')->count();
|
|
auth()->guard()->logout();
|
|
$this->travel(3)->days();
|
|
$queue = app(ReplenishmentQueue::class);
|
|
$queue->discover();
|
|
expect($queue->drain(100)['failed'])->toBe(0)->and(DB::table('stock_movements')->count())->toBe($moves)
|
|
->and(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1);
|
|
});
|
|
|
|
test('a rejected automatic purchase does not reopen itself or repeat the rejected purchase', function () {
|
|
activateAutomaticRule($this);
|
|
automaticIssue($this, '120.000');
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
|
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
|
$this->actingAs($this->f['issuer']);
|
|
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $r);
|
|
$reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
|
supplyGrant($reviewer, ['department_head']);
|
|
$this->actingAs($reviewer);
|
|
app(RequisitionWorkflow::class)->decide($reviewer, $r->fresh(), 'rejected', 'Compra não autorizada para esta necessidade.');
|
|
app(AutomaticPurchases::class)->evaluate($reviewer, $this->f['stock']->id, $this->f['warehouse']->id);
|
|
$current = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
|
expect($r->fresh()->status)->toBe('rejected')->and($current->state)->toBe('pending')->and($current->data['message'])->toContain('rejeitada')->and($current->data['purchases'])->toHaveCount(1);
|
|
});
|
|
|
|
test('a preparer cannot activate their own rule and stale or changed commands do not create duplicate rules', function () {
|
|
supplyGrant($this->f['issuer'], ['supply_review']);
|
|
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input);
|
|
expect(app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input)->id)->toBe($rule->id);
|
|
$decision = ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Tentativa de activar a própria regra.', 'data' => []];
|
|
expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $decision))->toThrow(ValidationException::class);
|
|
$this->input['data']['needed_days'] = 9;
|
|
expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input))->toThrow(ValidationException::class);
|
|
$this->actingAs($this->f['receiver']);
|
|
$decision['version'] = 0;
|
|
expect(fn () => app(AutomaticPurchases::class)->save($this->f['receiver'], $decision))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('expired or changed sources keep the need pending without preventing the physical operation', function () {
|
|
activateAutomaticRule($this);
|
|
$before = Requisition::query()->count();
|
|
$this->travel(32)->days();
|
|
automaticIssue($this, '120.000');
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
expect(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('pending')->and(Requisition::query()->count())->toBe($before)
|
|
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(0);
|
|
});
|
|
|
|
test('configuration UI separates preparation review and read and does not generate purchases on GET', function () {
|
|
$before = Requisition::query()->count();
|
|
$this->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/replenishment')->where('canPrepare', true)->where('canReview', false)->has('requesters', 1));
|
|
$this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', true)->has('requesters', 0));
|
|
expect(Requisition::query()->count())->toBe($before);
|
|
});
|
|
|
|
test('warehouse shortage carries the original destination and equipment and is not counted twice after reservation', function () {
|
|
activateAutomaticRule($this);
|
|
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
|
supplyGrant($this->f['receiver'], ['warehouse_request_review']);
|
|
$this->actingAs($this->f['issuer']);
|
|
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério do ensaio.', 'is_critical' => false, 'created_at' => now()]);
|
|
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id, 'code' => 'EQ-OF', 'description' => 'Equipamento da oficina central', 'area' => 'Oficina', 'status' => 'active']);
|
|
$request = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'destination' => 'Oficina central, bancada de montagem', 'reason' => 'Componentes para reposição e manutenção interna.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '125.000', 'use' => 'equipment', 'equipment_id' => $equipment->id]]]);
|
|
app(WarehouseFulfilment::class)->command($this->f['issuer'], $request->id, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'submit', 'reason' => 'Submissão do material para utilização interna.', 'partial_allowed' => true]);
|
|
$this->actingAs($this->f['receiver']);
|
|
app(WarehouseFulfilment::class)->command($this->f['receiver'], $request->id, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Autorização parcial com saldo em falta.', 'partial_allowed' => true]);
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
|
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
|
expect($r->items()->sole()->quantity)->toBe('108.000')->and($need->snapshot['origins'][0]['destination'])->toBe('Oficina central, bancada de montagem')
|
|
->and($need->snapshot['origins'][0]['reserved_milli'])->toBe(120000)->and($need->snapshot['unreserved_milli'])->toBe(5000);
|
|
$origin = app(AutomaticPurchases::class)->origin($r);
|
|
expect($origin['origins'][0]['warehouse_request_id'])->toBe($request->id);
|
|
expect($origin['origins'][0]['equipment']['code'])->toBe('EQ-OF');
|
|
$this->actingAs($this->f['issuer'])->get('/requisicoes/'.$r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('automaticSource.origins.0.destination', 'Oficina central, bancada de montagem'));
|
|
});
|
|
|
|
test('revoked requester authority or expired work context cannot be borrowed for automatic purchases', function () {
|
|
activateAutomaticRule($this);
|
|
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
|
$before = Requisition::query()->count();
|
|
automaticIssue($this, '120.000');
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
expect(app(SupplyPlanning::class)->latest('auto_need')->sole()->data['message'])->toContain('competência')->and(Requisition::query()->count())->toBe($before);
|
|
});
|
|
|
|
test('automatic spare part purchases reuse the registered compatible equipment and reject a missing technical destination', function () {
|
|
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'PART', 'name' => 'Normal', 'criteria' => 'Critério do ensaio.', 'is_critical' => false, 'created_at' => now()]);
|
|
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id, 'code' => 'EQ-P', 'description' => 'Máquina de teste', 'area' => 'Oficina', 'status' => 'active']);
|
|
$part = SparePart::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 20000]);
|
|
EquipmentSparePart::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
|
$this->actingAs($this->f['receiver']);
|
|
app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 1, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']), 'category' => 'Peças', 'purchase_type' => 'spare_part', 'published' => true, 'reason' => 'Peça publicada para aquisição com destino técnico.']);
|
|
$this->actingAs($this->f['issuer']);
|
|
expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input))->toThrow(ValidationException::class);
|
|
$this->input['data']['equipment_id'] = $equipment->id;
|
|
activateAutomaticRule($this);
|
|
automaticIssue($this, '120.000', ['equipment_id' => $equipment->id]);
|
|
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
|
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
|
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
|
expect($r->items()->sole()->equipment_id)->toBe($equipment->id)->and($r->items()->sole()->spare_part_id)->toBe($part->id)->and($r->status)->toBe('draft');
|
|
});
|