Files
InvoiceShelf/tests/Feature/Ai/Tools/RankTopItemsToolTest.php
T
Darko Gjorgjijoski 5ef7804e60 refactor: adopt modular domain architecture (#747)
* refactor: stabilize model identities for domain migration

* refactor: extract module platform context

* refactor: assign models to domain contexts

* refactor: extract ai platform context

* refactor: extract storage platform context

* refactor: extract mail platform context

* refactor: extract pdf platform context

* refactor: extract operations platform context

* refactor: move installation into operations platform

* refactor: extract money domain context

* refactor: extract taxation domain context

* refactor: extract catalog domain context

* refactor: extract metadata domain context

* refactor: extract reporting domain context

* refactor: extract purchases domain context

* refactor: extract receivables domain context

* refactor: extract accounts domain context

* refactor: complete reporting statement boundary

* refactor: extract contacts domain context

* refactor: extract sales domain context

* refactor: remove legacy application layers

* fix: migrate legacy bouncer role identities
2026-08-05 17:40:03 +02:00

194 lines
6.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Domains\Accounts\Models\Company;
use App\Domains\Catalog\Models\Item;
use App\Domains\Catalog\Models\Unit;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Sales\Models\Invoice;
use App\Domains\Sales\Models\InvoiceItem;
use App\Platform\Ai\Application\Tools\RankTopItemsTool;
use Illuminate\Support\Facades\Artisan;
beforeEach(function () {
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
});
/**
* Helper: create an Item with a known name/price for this company. The Item
* factory has known bugs (cascades RecurringInvoice, hardcoded creator_id),
* so we use direct ::create() for determinism.
*/
function makeItem(int $companyId, string $name, int $price): Item
{
$unit = Unit::where('company_id', $companyId)->firstOrFail();
return Item::create([
'name' => $name,
'description' => $name.' description',
'price' => $price,
'company_id' => $companyId,
'unit_id' => $unit->id,
'currency_id' => 1,
]);
}
test('rank_top_items ranks by revenue correctly', function () {
$company = Company::first();
$customer = Customer::factory()->create(['company_id' => $company->id]);
$premium = makeItem($company->id, 'Premium Widget', 10000);
$standard = makeItem($company->id, 'Standard Widget', 5000);
$budget = makeItem($company->id, 'Budget Widget', 1000);
$invoice = Invoice::factory()->create([
'company_id' => $company->id,
'customer_id' => $customer->id,
]);
// Premium: 2 × 10000 = 20000
// Standard: 3 × 5000 = 15000
// Budget: 5 × 1000 = 5000
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id, 'company_id' => $company->id,
'item_id' => $premium->id, 'quantity' => 2, 'price' => 10000, 'total' => 20000,
]);
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id, 'company_id' => $company->id,
'item_id' => $standard->id, 'quantity' => 3, 'price' => 5000, 'total' => 15000,
]);
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id, 'company_id' => $company->id,
'item_id' => $budget->id, 'quantity' => 5, 'price' => 1000, 'total' => 5000,
]);
$result = (new RankTopItemsTool)->execute(
['metric' => 'revenue'],
$company->id,
1,
);
expect($result['metric'])->toBe('revenue');
expect($result['items'][0]['name'])->toBe('Premium Widget');
expect($result['items'][0]['revenue'])->toBe(20000.0);
expect($result['items'][1]['name'])->toBe('Standard Widget');
expect($result['items'][2]['name'])->toBe('Budget Widget');
});
test('rank_top_items ranks by quantity_sold correctly', function () {
$company = Company::first();
$customer = Customer::factory()->create(['company_id' => $company->id]);
$bulky = makeItem($company->id, 'Bulk Good', 100);
$pricey = makeItem($company->id, 'Pricey Good', 50000);
$invoice = Invoice::factory()->create([
'company_id' => $company->id,
'customer_id' => $customer->id,
]);
// Bulky: sold 50 units at 100 each = 5000 revenue
// Pricey: sold 1 unit at 50000 = 50000 revenue
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id, 'company_id' => $company->id,
'item_id' => $bulky->id, 'quantity' => 50, 'price' => 100, 'total' => 5000,
]);
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id, 'company_id' => $company->id,
'item_id' => $pricey->id, 'quantity' => 1, 'price' => 50000, 'total' => 50000,
]);
// By quantity: Bulky wins (50 > 1)
$result = (new RankTopItemsTool)->execute(
['metric' => 'quantity_sold'],
$company->id,
1,
);
expect($result['items'][0]['name'])->toBe('Bulk Good');
expect($result['items'][0]['quantity_sold'])->toBe(50.0);
// By revenue: Pricey wins (50000 > 5000)
$result = (new RankTopItemsTool)->execute(
['metric' => 'revenue'],
$company->id,
1,
);
expect($result['items'][0]['name'])->toBe('Pricey Good');
});
test('rank_top_items excludes ad-hoc line items with null item_id', function () {
$company = Company::first();
$customer = Customer::factory()->create(['company_id' => $company->id]);
$cataloged = makeItem($company->id, 'Real Item', 1000);
$invoice = Invoice::factory()->create([
'company_id' => $company->id,
'customer_id' => $customer->id,
]);
// Cataloged item on the invoice
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id, 'company_id' => $company->id,
'item_id' => $cataloged->id, 'quantity' => 1, 'price' => 1000, 'total' => 1000,
]);
// Ad-hoc line without a catalog item — simulate via direct ::create
InvoiceItem::create([
'invoice_id' => $invoice->id,
'company_id' => $company->id,
'item_id' => null,
'name' => 'Ad hoc typed line',
'price' => 9999,
'quantity' => 1,
'total' => 9999,
'discount' => 0,
'discount_val' => 0,
'discount_type' => 'fixed',
'tax' => 0,
'base_price' => 9999,
'base_total' => 9999,
'base_discount_val' => 0,
'base_tax' => 0,
'exchange_rate' => 1,
]);
$result = (new RankTopItemsTool)->execute(
['metric' => 'revenue'],
$company->id,
1,
);
// Only the cataloged item should appear — ad-hoc line is excluded by whereNotNull.
expect($result['items'])->toHaveCount(1);
expect($result['items'][0]['name'])->toBe('Real Item');
});
test('rank_top_items does not leak across companies', function () {
$companyA = Company::first();
$companyB = Company::factory()->create();
$itemA = makeItem($companyA->id, 'A Only Item', 1000);
$customerA = Customer::factory()->create(['company_id' => $companyA->id]);
$invoiceA = Invoice::factory()->create([
'company_id' => $companyA->id,
'customer_id' => $customerA->id,
]);
InvoiceItem::factory()->create([
'invoice_id' => $invoiceA->id, 'company_id' => $companyA->id,
'item_id' => $itemA->id, 'quantity' => 1, 'price' => 1000, 'total' => 1000,
]);
// Call with companyB — should see no items at all
$result = (new RankTopItemsTool)->execute(
['metric' => 'revenue'],
$companyB->id,
1,
);
expect($result['items'])->toBeEmpty();
});