feat(odin): forgiving lookups + per-customer aggregation + count awareness
- SPZ matching normalized on both sides (4SY7039 finds '4SY 7039') in list_vehicles and the list_trips vehicle filter; JS-side name matching diacritic-folded to keep utf8mb4_unicode_ci parity; ICO typed with spaces matches the space-less stored value (suppliers + customers) - get_top_customers: whole-table per-customer counts of invoices (sans drafts) / orders / projects, top 20 + customers_with_records, optional year, per-type permission re-checks — answers 'pro koho pracujeme nejvic' exactly instead of refusing - find_employee, find_supplier and stock search now return total_matching (stock search also gained its missing deterministic orderBy); system prompt teaches the model to answer counts from total_matching and use aggregation tools instead of summing 20-row list pages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -189,9 +189,11 @@ beforeAll(async () => {
|
||||
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||||
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||||
let fixOfferId = 0;
|
||||
let fixSupplierId = 0;
|
||||
let fixRiId = 0;
|
||||
let fixCustomerId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.quotations.deleteMany({
|
||||
@@ -205,6 +207,21 @@ beforeAll(async () => {
|
||||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||
});
|
||||
fixSupplierId = sup.id;
|
||||
// Customer + two projects for the per-customer aggregation tool.
|
||||
await prisma.projects.deleteMany({
|
||||
where: { name: { startsWith: "AiTest Projekt" } },
|
||||
});
|
||||
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||||
const cust = await prisma.customers.create({
|
||||
data: { name: FIX_CUSTOMER },
|
||||
});
|
||||
fixCustomerId = cust.id;
|
||||
await prisma.projects.createMany({
|
||||
data: [
|
||||
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||||
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||||
],
|
||||
});
|
||||
const ri = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: 6,
|
||||
@@ -265,6 +282,9 @@ afterAll(async () => {
|
||||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||
// Projects before the customer (Restrict FK on projects.customer_id).
|
||||
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||||
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -655,6 +675,18 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||
expect(s).toMatchObject({ ico: "99999998" });
|
||||
|
||||
// IČO typed with spaces finds the space-less stored value.
|
||||
const spacedIco = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: "999 99 998" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(
|
||||
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||||
(x) => x.name === FIX_SUPPLIER,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const veh = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: FIX.spz },
|
||||
@@ -669,6 +701,25 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||||
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||||
|
||||
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||||
// in list_vehicles AND in the list_trips vehicle filter.
|
||||
const spaced = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: "aitest 999" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||||
).toBe(FIX.spz);
|
||||
const tripsByPlate = await executeTool(
|
||||
"list_trips",
|
||||
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||
).toBe(false);
|
||||
@@ -714,6 +765,50 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||
const res = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
customers_with_records: number;
|
||||
top: { customer: string; count: number }[];
|
||||
};
|
||||
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine!.count).toBe(2);
|
||||
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||||
const denied = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("find_employee and find_supplier report total_matching", async () => {
|
||||
const emp = await executeTool(
|
||||
"find_employee",
|
||||
{ search: FIX.last },
|
||||
RECORDER,
|
||||
);
|
||||
expect(
|
||||
(emp.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
const sup = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: FIX_SUPPLIER },
|
||||
ADMIN,
|
||||
);
|
||||
expect(
|
||||
(sup.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||
const res = await executeTool(
|
||||
"get_received_invoice_detail",
|
||||
|
||||
Reference in New Issue
Block a user