Files
InvoiceShelf/tests/Feature/Ai/Tools/RankTopItemsToolTest.php
Darko Gjorgjijoski 3df2a01832 feat(ai): add customer/item/expense ranking tools to the chat assistant
Adds three new read-only tools the chat LLM can call to answer
"who/what did the most X" questions that previously fell through
the cracks:

- rank_top_customers — ranks customers by invoiced_total, paid_total,
  invoice_count, or outstanding_balance over a named time period
- rank_top_items — ranks catalog items by quantity_sold or revenue
- rank_expense_categories — ranks expense categories by total spend

All three share a new ResolvesPeriod trait that centralizes the
period-name → [start, end] logic. GetCompanyStatsTool is refactored
onto the same trait (identical public schema — the 'all_time' option
is only exposed on the new ranking tools, where an unbounded window
makes sense; stats over "all time" collapses every record into one
giant bucket and is rarely useful).

Each tool follows the existing pattern: snake_case name, one-sentence
description tuned for LLM tool selection, JSON-schema parameters
with injected company scoping (never trusting LLM-supplied company
IDs), and JSON-encodable output. outstanding_balance on the customer
tool explicitly ignores the period param since it's a current-state
snapshot.

Multi-company scoping tests lock down the session-authoritative
boundary on every new tool. Per-metric ordering tests verify the
aggregate queries actually rank correctly, and an ad-hoc-item
exclusion test verifies rank_top_items skips invoice lines where
item_id is null (free-typed entries that have no catalog row to
rank by id).

15 new tests added (tests/Feature/Ai/Tools/); test suite grows from
398 to 413 passing. LLM tool count goes from 9 to 12 — the model
will discover the new tools automatically via the function-calling
schema with no prompt changes required.
2026-04-11 21:54:39 +02:00

194 lines
6.3 KiB
PHP
Raw Permalink 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\Models\Company;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Item;
use App\Models\Unit;
use App\Services\Ai\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();
});