mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-17 22:35:19 +00:00
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.
194 lines
6.3 KiB
PHP
194 lines
6.3 KiB
PHP
<?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();
|
||
});
|