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