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(); });