Files
compras/tests/Feature/SupplySuggestionsTest.php
T
2026-11-03 11:51:44 +02:00

236 lines
20 KiB
PHP

<?php
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Services\RequisitionWorkflow;
use App\Services\SupplyPlanning;
use App\Services\SupplySuggestions;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/SuggestionFixture.php';
beforeEach(function () {
$this->f = suggestionFixture();
$this->actingAs($this->f['issuer']);
});
function saveSuggestion($test, array $extra = []): SupplyRecord
{
$test->actingAs($test->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $test->f['policy']->record_key, ...$extra]))->assertRedirect();
return app(SupplyPlanning::class)->latest('suggestion')->sole();
}
function approveSuggestion($test, SupplyRecord $draft, array $extra = []): SupplyRecord
{
$test->actingAs($test->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput($extra, 'approve', $draft))->assertRedirect();
return app(SupplyPlanning::class)->latest('suggestion')->sole();
}
test('policy source search finds an older approved policy and keeps only eligible selections', function () {
for ($i = 0; $i < 100; $i++) {
$item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
'code' => 'LOOKUP-'.$i, 'name' => 'Outro artigo de teste '.$i, 'unit' => 'cx']);
$this->actingAs($this->f['issuer']);
$draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'stock_item_id' => $item->id]));
$this->actingAs($this->f['receiver']);
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft));
}
$before = [$this->f['policy']->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()];
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $p) => $p->has('policies', 100)
->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->isEmpty()));
$this->get('/compras/fornecimento?view=suggestions&policy_search='.urlencode(mb_strtolower($this->f['stock']->code)))->assertInertia(fn (Assert $p) => $p->has('policies', 1)->where('policies.0.key', $this->f['policy']->record_key));
$this->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $p) => $p->has('policies', 0));
$this->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->has('policies', 1));
expect([$this->f['policy']->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($before);
$this->actingAs($this->f['receiver']);
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->has('policies', 0));
expect($this->f['policy']->fresh()->getAttributes())->toBe($before[0]);
});
test('policy source search validates inputs company scope and preparation rights', function () {
$this->getJson('/compras/fornecimento?view=suggestions&policy_search='.str_repeat('A', 101))->assertUnprocessable();
$this->get('/compras/fornecimento?view=suggestions&policy_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('policies', 0));
$this->actingAs($this->f['receiver'])->get('/compras/fornecimento?view=suggestions&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('policies', 0));
$foreign = suggestionFixture();
$this->actingAs($foreign['issuer'])->getJson('/compras/fornecimento?view=suggestions&selected_policy_key='.$this->f['policy']->record_key)->assertUnprocessable();
});
test('suggestion freezes the forecast catalogue agreement and company defaults without repeating descriptors', function () {
$input = supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => null, 'name' => 'FORGED', 'snapshot' => ['balance' => 999]]);
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
$saved = app(SupplyPlanning::class)->latest('suggestion')->sole();
expect($saved->data['quantity_milli'])->toBe(84000)->and($saved->data['unit_price'])->toBe('10.00')->and($saved->data['price_source'])->toBe('agreement')
->and($saved->snapshot['item']['name'])->toBe($this->f['stock']->name)->and($saved->snapshot['balance']['quantity'])->toBe(120000)
->and($saved->snapshot['projection']['risk_date'])->toBe(today()->addDays(9)->toDateString());
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key]))->assertUnprocessable();
$this->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $page) => $page->component('supply/suggestions')->has('records', 1)->where('canPrepare', true)->where('canConvert', true));
});
test('independent review and a single conversion create an ordinary draft with server owned catalogue facts', function () {
$draft = saveSuggestion($this);
supplyGrant($this->f['issuer'], ['supply_suggest_review']);
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
$approved = approveSuggestion($this, $draft);
supplyGrant($this->f['receiver'], ['supply_suggest_convert', 'requester']);
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
$input = supplyInput(['cost_center_id' => $this->f['r']->cost_center_id, 'quantity_milli' => 999, 'unit_price' => '0.01', 'description' => 'FORGED'], 'convert', $approved);
$before = Requisition::query()->count();
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
$converted = app(SupplyPlanning::class)->latest('suggestion')->sole();
$request = Requisition::query()->whereKey($converted->data['requisition_id'])->firstOrFail();
$line = $request->items()->sole();
expect(Requisition::query()->count())->toBe($before + 1)->and($request->status)->toBe('draft')->and($request->requester_id)->toBe($this->f['issuer']->id)
->and($line->quantity)->toBe('84.000')->and($line->unit_price_minor)->toBe(1000)->and($line->description)->toBe($this->f['stock']->name)
->and($line->catalogue_snapshot['stock_item_id'])->toBe($this->f['stock']->id)->and($converted->snapshot['conversion']['requisition_created']['number'])->toBe($request->number);
$this->postJson('/compras/fornecimento/suggestion', supplyInput($input['data'], 'convert', $converted))->assertUnprocessable();
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key]))->assertUnprocessable();
$this->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $page) => $page->where('requestLinks.'.$request->id.'.id', $request->id));
$this->get('/requisicoes/'.$request->id)->assertInertia(fn (Assert $page) => $page->where('replenishmentSource.approval_version', $approved->version)->where('replenishmentSource.quantity_milli', 84000)->missing('replenishmentSource.supplier'));
});
test('changed source data blocks approval and conversion and refresh preserves the previous version', function () {
$draft = saveSuggestion($this);
$oldHash = $draft->snapshot['hash'];
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/reservation', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id,
'quantity_milli' => 1000, 'needed_on' => today()->addDay()->toDateString(), 'additional' => true, 'reference' => 'Nova necessidade conhecida']))->assertRedirect();
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key], 'save', $draft))->assertRedirect();
$new = app(SupplyPlanning::class)->latest('suggestion')->sole();
expect($new->snapshot['hash'])->not->toBe($oldHash)->and($draft->fresh()->snapshot['hash'])->toBe($oldHash);
$approved = approveSuggestion($this, $new);
$this->f['stock']->update(['name' => 'Cadastro alterado depois da aprovação']);
$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()->state)->toBe('approved');
});
test('manual plans require acknowledgement and packaging and maximum constraints are enforced', function () {
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => 85000]))->assertUnprocessable();
$draft = saveSuggestion($this, ['quantity_milli' => 96000]);
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
$approved = approveSuggestion($this, $draft, ['confirm_exception' => true]);
expect($approved->data['exception_confirmed'])->toBeTrue();
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'return', $approved))->assertRedirect();
$returned = app(SupplyPlanning::class)->latest('suggestion')->sole();
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => 120000], 'save', $returned))->assertRedirect();
$excess = app(SupplyPlanning::class)->latest('suggestion')->sole();
expect(app(SupplySuggestions::class)->hasExcess($excess))->toBeTrue();
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput(['confirm_exception' => true], 'approve', $excess))->assertUnprocessable();
});
test('foreign agreement currency is never silently converted into a local requisition estimate', function () {
$f = suggestionFixture('USD');
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $f['policy']->record_key]))->assertUnprocessable();
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $f['policy']->record_key, 'unit_price' => '650.00']))->assertRedirect();
$record = app(SupplyPlanning::class)->latest('suggestion')->sole();
expect($record->data['price_source'])->toBe('manual_estimate')->and($record->data['unit_price'])->toBe('650.00')->and($record->snapshot['agreement']['data']['currency'])->toBe('USD');
});
test('conversion is atomic with its journal even when final audit fails', function () {
$approved = approveSuggestion($this, saveSuggestion($this));
$this->actingAs($this->f['issuer']);
$before = Requisition::query()->count();
$this->partialMock(RequisitionWorkflow::class, function ($mock) {
$mock->shouldReceive('audit')->andReturnUsing(function ($actor, $action) {
if ($action === 'supply.suggestion.convert') {
throw new RuntimeException('Injected final audit failure.');
}
});
});
$this->withoutExceptionHandling();
expect(fn () => $this->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved)))->toThrow(RuntimeException::class);
expect(Requisition::query()->count())->toBe($before)->and(app(SupplyPlanning::class)->latest('suggestion')->sole()->state)->toBe('approved');
});
test('tracking cannot be closed while the created request remains open and role revocation is effective', function () {
$approved = approveSuggestion($this, saveSuggestion($this));
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertRedirect();
$converted = app(SupplyPlanning::class)->latest('suggestion')->sole();
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertUnprocessable();
Requisition::query()->whereKey($converted->data['requisition_id'])->firstOrFail()->update(['status' => 'rejected']);
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertRedirect();
expect(app(SupplyPlanning::class)->latest('suggestion')->sole()->state)->toBe('closed');
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key]))->assertForbidden();
});
test('another company cannot resolve a suggestion or convert with another department cost center', function () {
$draft = saveSuggestion($this);
$other = suggestionFixture();
$this->actingAs($other['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertNotFound();
$approved = approveSuggestion($this, $draft);
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $other['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
});
test('suggestion task connects independent review conversion and completed tracking without duplicate drafts', function () {
$draft = saveSuggestion($this);
$path = '/compras/fornecimento?view=suggestions&record_id='.$draft->id;
supplyGrant($this->f['issuer'], ['supply_suggest_review']);
$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.task', 'suggestion_approve')->where('tasks.data.0.href', $path.'#supply-suggestion-'.$draft->id)->missing('tasks.data.0.snapshot'));
$before = [SupplyRecord::query()->count(), Requisition::query()->count(), DB::table('audit_logs')->count()];
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true));
expect([SupplyRecord::query()->count(), Requisition::query()->count(), DB::table('audit_logs')->count()])->toBe($before);
$approved = approveSuggestion($this, $draft);
supplyGrant($this->f['receiver'], ['supply_suggest_convert', 'requester']);
expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $approved, 'convert'))->toBeFalse();
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'suggestion_convert'));
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.canConvert', true));
$command = supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved);
$this->postJson('/compras/fornecimento/suggestion', $command)->assertRedirect();
$this->postJson('/compras/fornecimento/suggestion', $command)->assertRedirect();
expect(Requisition::query()->count())->toBe($before[1] + 1);
$converted = app(SupplyPlanning::class)->latest('suggestion')->sole();
$purchase = Requisition::query()->findOrFail($converted->data['requisition_id']);
expect($purchase->status)->toBe('draft')->and($purchase->steps()->count())->toBe(0);
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$purchase->update(['status' => 'rejected']);
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'suggestion_close'));
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertRedirect();
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
});
test('suggestion task preserves source revalidation specific grants and focus isolation', function () {
$draft = saveSuggestion($this);
$this->f['stock']->update(['name' => 'Origem alterada depois da preparação']);
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'return', $draft))->assertRedirect();
$returned = app(SupplyPlanning::class)->latest('suggestion')->sole();
expect($returned->snapshot['item'])->toBe($draft->snapshot['item'])->and(app(SupplySuggestions::class)->canAct($this->f['receiver'], $draft, 'approve'))->toBeFalse();
$this->getJson('/compras/fornecimento?view=calendar&record_id='.$returned->id)->assertUnprocessable();
$foreign = suggestionFixture();
$this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento?view=suggestions&record_id='.$returned->id)->assertUnprocessable();
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$this->actingAs($this->f['receiver']);
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $returned, 'approve'))->toBeFalse();
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
$this->get('/requisicoes/tarefas?module=supply')->assertForbidden();
});
test('suggestion task excludes all preparers and conversion without requester authority', function () {
$draft = saveSuggestion($this);
supplyGrant($this->f['receiver'], ['supply_suggest_prepare']);
$this->actingAs($this->f['receiver']);
$updated = app(SupplySuggestions::class)->save($this->f['receiver'], supplyInput(['policy_key' => $this->f['policy']->record_key], 'save', $draft));
supplyGrant($this->f['issuer'], ['supply_suggest_review']);
foreach ([$this->f['issuer'], $this->f['receiver']] as $user) {
expect(app(SupplySuggestions::class)->canAct($user, $updated, 'approve'))->toBeFalse();
}
supplyGrant($this->f['otherReceiver'], ['supply_suggest_review']);
$this->actingAs($this->f['otherReceiver']);
$approved = app(SupplySuggestions::class)->save($this->f['otherReceiver'], supplyInput([], 'approve', $updated));
DB::table('organization_profiles')->whereIn('id', DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->pluck('profile_id'))->update(['permissions' => json_encode(['supply_suggest_convert'])]);
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $approved, 'convert'))->toBeFalse();
});