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_OFFER_NUMBER = "2098/NA/99999";
|
||||||
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||||
const FIX_RI_SUPPLIER = "AiTest Supplier 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 fixOfferId = 0;
|
||||||
let fixSupplierId = 0;
|
let fixSupplierId = 0;
|
||||||
let fixRiId = 0;
|
let fixRiId = 0;
|
||||||
|
let fixCustomerId = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.quotations.deleteMany({
|
await prisma.quotations.deleteMany({
|
||||||
@@ -205,6 +207,21 @@ beforeAll(async () => {
|
|||||||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||||
});
|
});
|
||||||
fixSupplierId = sup.id;
|
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({
|
const ri = await prisma.received_invoices.create({
|
||||||
data: {
|
data: {
|
||||||
month: 6,
|
month: 6,
|
||||||
@@ -265,6 +282,9 @@ afterAll(async () => {
|
|||||||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
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 () => {
|
afterAll(async () => {
|
||||||
@@ -655,6 +675,18 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||||
expect(s).toMatchObject({ ico: "99999998" });
|
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(
|
const veh = await executeTool(
|
||||||
"list_vehicles",
|
"list_vehicles",
|
||||||
{ search: FIX.spz },
|
{ 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.
|
// 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 });
|
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(
|
expect(
|
||||||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||||
).toBe(false);
|
).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 () => {
|
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||||
const res = await executeTool(
|
const res = await executeTool(
|
||||||
"get_received_invoice_detail",
|
"get_received_invoice_detail",
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ const hhmm = (d: Date | null): string | null =>
|
|||||||
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||||||
: null;
|
: null;
|
||||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
/** SPZ normalization: users type plates with/without spaces or dashes. */
|
||||||
|
const normPlate = (s: string): string => s.replace(/[\s-]/g, "").toUpperCase();
|
||||||
|
/**
|
||||||
|
* Diacritic-folded lowercase for JS-side matching — parity with the DB's
|
||||||
|
* utf8mb4_unicode_ci contains ("dodavka" must match "Dodávka").
|
||||||
|
*/
|
||||||
|
const normText = (s: string): string =>
|
||||||
|
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||||
|
|
||||||
/** Quill/HTML rich text → compact plain text for model context. */
|
/** Quill/HTML rich text → compact plain text for model context. */
|
||||||
const stripHtml = (html: string | null | undefined): string =>
|
const stripHtml = (html: string | null | undefined): string =>
|
||||||
@@ -472,7 +480,7 @@ const TOOLS: ToolSpec[] = [
|
|||||||
definition: {
|
definition: {
|
||||||
name: "list_customers",
|
name: "list_customers",
|
||||||
description:
|
description:
|
||||||
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících.",
|
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících; total_matching = CELKOVÝ počet zákazníků odpovídajících filtru (bez search = všichni).",
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -485,12 +493,13 @@ const TOOLS: ToolSpec[] = [
|
|||||||
},
|
},
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
// Mirrors the customers route's read (search on name/company_id).
|
// Mirrors the customers route's read (search on name/company_id).
|
||||||
|
// IČO is stored without spaces but users type "123 45 678".
|
||||||
const search = str(input.search);
|
const search = str(input.search);
|
||||||
const where = search
|
const where = search
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: search } },
|
{ name: { contains: search } },
|
||||||
{ company_id: { contains: search } },
|
{ company_id: { contains: search.replace(/\s+/g, "") } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
@@ -554,26 +563,32 @@ const TOOLS: ToolSpec[] = [
|
|||||||
}
|
}
|
||||||
// Stock math mirrors the service's total-stock rule: sum of
|
// Stock math mirrors the service's total-stock rule: sum of
|
||||||
// unconsumed batch quantities.
|
// unconsumed batch quantities.
|
||||||
const rows = await prisma.sklad_items.findMany({
|
const where = {
|
||||||
where: {
|
is_active: true,
|
||||||
is_active: true,
|
OR: [
|
||||||
OR: [
|
{ name: { contains: search } },
|
||||||
{ name: { contains: search } },
|
{ item_number: { contains: search } },
|
||||||
{ item_number: { contains: search } },
|
],
|
||||||
],
|
};
|
||||||
},
|
const [rows, total] = await Promise.all([
|
||||||
take: 20,
|
prisma.sklad_items.findMany({
|
||||||
select: {
|
where,
|
||||||
item_number: true,
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
name: true,
|
take: 20,
|
||||||
unit: true,
|
select: {
|
||||||
batches: {
|
item_number: true,
|
||||||
where: { is_consumed: false },
|
name: true,
|
||||||
select: { quantity: true },
|
unit: true,
|
||||||
|
batches: {
|
||||||
|
where: { is_consumed: false },
|
||||||
|
select: { quantity: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
prisma.sklad_items.count({ where }),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
|
total_matching: total,
|
||||||
shown: rows.length,
|
shown: rows.length,
|
||||||
items: rows.map((r) => ({
|
items: rows.map((r) => ({
|
||||||
item_number: r.item_number,
|
item_number: r.item_number,
|
||||||
@@ -789,20 +804,24 @@ const TOOLS: ToolSpec[] = [
|
|||||||
if (!fullDirectory && populations.length === 0) {
|
if (!fullDirectory && populations.length === 0) {
|
||||||
return { error: DENIED("seznam zaměstnanců") };
|
return { error: DENIED("seznam zaměstnanců") };
|
||||||
}
|
}
|
||||||
const rows = await prisma.users.findMany({
|
const where = fullDirectory
|
||||||
where: fullDirectory
|
? { OR: or }
|
||||||
? { OR: or }
|
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] };
|
||||||
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] },
|
const [rows, total] = await Promise.all([
|
||||||
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
prisma.users.findMany({
|
||||||
take: 10,
|
where,
|
||||||
select: {
|
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
||||||
id: true,
|
take: 10,
|
||||||
first_name: true,
|
select: {
|
||||||
last_name: true,
|
id: true,
|
||||||
username: true,
|
first_name: true,
|
||||||
is_active: true,
|
last_name: true,
|
||||||
},
|
username: true,
|
||||||
});
|
is_active: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.users.count({ where }),
|
||||||
|
]);
|
||||||
// GET /trips/users deliberately omits usernames (login identifiers);
|
// GET /trips/users deliberately omits usernames (login identifiers);
|
||||||
// only the attendance picker and the users admin expose them.
|
// only the attendance picker and the users admin expose them.
|
||||||
const showUsername =
|
const showUsername =
|
||||||
@@ -810,6 +829,7 @@ const TOOLS: ToolSpec[] = [
|
|||||||
ctxCan(ctx, "attendance.record") ||
|
ctxCan(ctx, "attendance.record") ||
|
||||||
ctxCan(ctx, "attendance.manage");
|
ctxCan(ctx, "attendance.manage");
|
||||||
return {
|
return {
|
||||||
|
total_matching: total,
|
||||||
shown: rows.length,
|
shown: rows.length,
|
||||||
employees: rows.map((u) => ({
|
employees: rows.map((u) => ({
|
||||||
user_id: u.id,
|
user_id: u.id,
|
||||||
@@ -883,14 +903,28 @@ const TOOLS: ToolSpec[] = [
|
|||||||
}
|
}
|
||||||
const vehicle = str(input.vehicle);
|
const vehicle = str(input.vehicle);
|
||||||
if (vehicle) {
|
if (vehicle) {
|
||||||
where.vehicles = {
|
// Resolve in JS so SPZ spacing is normalized on both sides
|
||||||
is: {
|
// ("4SY7039" must match the stored "4SY 7039") — SQL contains is
|
||||||
OR: [
|
// literal. The vehicles table is tiny.
|
||||||
{ name: { contains: vehicle } },
|
const allVehicles = await prisma.vehicles.findMany({
|
||||||
{ spz: { contains: vehicle } },
|
select: { id: true, name: true, spz: true },
|
||||||
],
|
});
|
||||||
},
|
const ids = allVehicles
|
||||||
};
|
.filter(
|
||||||
|
(v) =>
|
||||||
|
normText(v.name).includes(normText(vehicle)) ||
|
||||||
|
normPlate(v.spz).includes(normPlate(vehicle)),
|
||||||
|
)
|
||||||
|
.map((v) => v.id);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return {
|
||||||
|
total_matching: 0,
|
||||||
|
shown: 0,
|
||||||
|
trips: [],
|
||||||
|
note: "Žádné vozidlo neodpovídá hledání (zkontroluj název nebo SPZ).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
where.vehicle_id = { in: ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, all] = await Promise.all([
|
const [rows, all] = await Promise.all([
|
||||||
@@ -1531,29 +1565,36 @@ const TOOLS: ToolSpec[] = [
|
|||||||
handler: async (input, ctx) => {
|
handler: async (input, ctx) => {
|
||||||
const search = str(input.search);
|
const search = str(input.search);
|
||||||
if (!search) return { error: "Zadej název, IČO nebo město dodavatele." };
|
if (!search) return { error: "Zadej název, IČO nebo město dodavatele." };
|
||||||
|
// IČO is stored without spaces but users type "123 45 678".
|
||||||
|
const icoDigits = search.replace(/\s+/g, "");
|
||||||
// Route parity: the orders.view picker (GET /issued-orders/suppliers)
|
// Route parity: the orders.view picker (GET /issued-orders/suppliers)
|
||||||
// filters is_active: true — only the warehouse.manage supplier admin
|
// filters is_active: true — only the warehouse.manage supplier admin
|
||||||
// sees deactivated (soft-deleted) suppliers.
|
// sees deactivated (soft-deleted) suppliers.
|
||||||
const rows = await prisma.sklad_suppliers.findMany({
|
const where = {
|
||||||
where: {
|
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
|
||||||
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
|
OR: [
|
||||||
OR: [
|
{ name: { contains: search } },
|
||||||
{ name: { contains: search } },
|
{ ico: { contains: icoDigits } },
|
||||||
{ ico: { contains: search } },
|
{ city: { contains: search } },
|
||||||
{ city: { contains: search } },
|
],
|
||||||
],
|
};
|
||||||
},
|
const [rows, total] = await Promise.all([
|
||||||
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
|
prisma.sklad_suppliers.findMany({
|
||||||
take: 10,
|
where,
|
||||||
select: {
|
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
|
||||||
name: true,
|
take: 10,
|
||||||
city: true,
|
select: {
|
||||||
ico: true,
|
name: true,
|
||||||
dic: true,
|
city: true,
|
||||||
is_active: true,
|
ico: true,
|
||||||
},
|
dic: true,
|
||||||
});
|
is_active: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.sklad_suppliers.count({ where }),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
|
total_matching: total,
|
||||||
shown: rows.length,
|
shown: rows.length,
|
||||||
suppliers: rows.map((s) => ({
|
suppliers: rows.map((s) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -1589,17 +1630,12 @@ const TOOLS: ToolSpec[] = [
|
|||||||
},
|
},
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
const search = str(input.search);
|
const search = str(input.search);
|
||||||
const where = search
|
// The vehicles table is tiny — fetch all and match in JS so SPZ
|
||||||
? {
|
// spacing can be normalized on BOTH sides ("4SY7039" must find the
|
||||||
OR: [{ name: { contains: search } }, { spz: { contains: search } }],
|
// stored "4SY 7039" and vice versa); a SQL contains is literal.
|
||||||
}
|
const [allVehicles, tripStats] = await Promise.all([
|
||||||
: {};
|
|
||||||
// Same odometer rule as GET /vehicles: max(initial_km, max trip end_km).
|
|
||||||
const [vehicles, total, tripStats] = await Promise.all([
|
|
||||||
prisma.vehicles.findMany({
|
prisma.vehicles.findMany({
|
||||||
where,
|
|
||||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
take: 20,
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -1610,13 +1646,21 @@ const TOOLS: ToolSpec[] = [
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.vehicles.count({ where }),
|
|
||||||
prisma.trips.groupBy({
|
prisma.trips.groupBy({
|
||||||
by: ["vehicle_id"],
|
by: ["vehicle_id"],
|
||||||
_max: { end_km: true },
|
_max: { end_km: true },
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
const matched = search
|
||||||
|
? allVehicles.filter(
|
||||||
|
(v) =>
|
||||||
|
normText(v.name).includes(normText(search)) ||
|
||||||
|
normPlate(v.spz).includes(normPlate(search)),
|
||||||
|
)
|
||||||
|
: allVehicles;
|
||||||
|
const total = matched.length;
|
||||||
|
const vehicles = matched.slice(0, 20);
|
||||||
const statsMap = new Map(
|
const statsMap = new Map(
|
||||||
tripStats.map((s) => [
|
tripStats.map((s) => [
|
||||||
s.vehicle_id,
|
s.vehicle_id,
|
||||||
@@ -1787,6 +1831,121 @@ const TOOLS: ToolSpec[] = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Coarse gate any-of; the handler re-checks the exact permission per
|
||||||
|
// aggregation source. Counts only — no amounts beyond what the lists
|
||||||
|
// already expose to the same permission.
|
||||||
|
permission: ["invoices.view", "orders.view", "projects.view"],
|
||||||
|
definition: {
|
||||||
|
name: "get_top_customers",
|
||||||
|
description:
|
||||||
|
"Agregace podle ZÁKAZNÍKA přes celou databázi: počet vydaných faktur / přijatých objednávek / projektů na zákazníka (TOP 20 + počet zákazníků se záznamem). Volej na dotazy 'pro koho pracujeme nejvíc', 'největší zákazník'. Celkový počet zákazníků zjistíš z list_customers (total_matching).",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["invoices", "orders", "projects"],
|
||||||
|
description: "Podle čeho agregovat",
|
||||||
|
},
|
||||||
|
year: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jen daný rok (vynech pro celou historii)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["type"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input, ctx) => {
|
||||||
|
const type = str(input.type);
|
||||||
|
const year = num(input.year);
|
||||||
|
// Year ranges as UTC midnights (issue_date is @db.Date; created_at
|
||||||
|
// DateTime comparisons are unaffected by the choice).
|
||||||
|
const range = year
|
||||||
|
? {
|
||||||
|
gte: new Date(Date.UTC(year, 0, 1)),
|
||||||
|
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
// Normalized per branch — Prisma's groupBy generics don't survive a
|
||||||
|
// shared pre-declared variable type.
|
||||||
|
let groups: { customer_id: number | null; count: number }[];
|
||||||
|
let note: string;
|
||||||
|
if (type === "invoices") {
|
||||||
|
if (!ctxCan(ctx, "invoices.view")) return { error: DENIED("faktury") };
|
||||||
|
const g = await prisma.invoices.groupBy({
|
||||||
|
by: ["customer_id"],
|
||||||
|
where: {
|
||||||
|
status: { not: "draft" },
|
||||||
|
...(range ? { issue_date: range } : {}),
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
groups = g.map((x) => ({
|
||||||
|
customer_id: x.customer_id,
|
||||||
|
count: x._count._all,
|
||||||
|
}));
|
||||||
|
note = "Počty vydaných faktur (bez konceptů).";
|
||||||
|
} else if (type === "orders") {
|
||||||
|
if (!ctxCan(ctx, "orders.view")) {
|
||||||
|
return { error: DENIED("objednávky") };
|
||||||
|
}
|
||||||
|
const g = await prisma.orders.groupBy({
|
||||||
|
by: ["customer_id"],
|
||||||
|
where: range ? { created_at: range } : {},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
groups = g.map((x) => ({
|
||||||
|
customer_id: x.customer_id,
|
||||||
|
count: x._count._all,
|
||||||
|
}));
|
||||||
|
note = "Počty přijatých objednávek (všechny stavy).";
|
||||||
|
} else if (type === "projects") {
|
||||||
|
if (!ctxCan(ctx, "projects.view")) {
|
||||||
|
return { error: DENIED("projekty") };
|
||||||
|
}
|
||||||
|
const g = await prisma.projects.groupBy({
|
||||||
|
by: ["customer_id"],
|
||||||
|
where: range ? { created_at: range } : {},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
groups = g.map((x) => ({
|
||||||
|
customer_id: x.customer_id,
|
||||||
|
count: x._count._all,
|
||||||
|
}));
|
||||||
|
note = "Počty projektů (všechny stavy).";
|
||||||
|
} else {
|
||||||
|
return { error: "Neznámý typ agregace." };
|
||||||
|
}
|
||||||
|
const assigned = groups
|
||||||
|
.filter((g) => g.customer_id != null)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.count - a.count || (a.customer_id ?? 0) - (b.customer_id ?? 0),
|
||||||
|
);
|
||||||
|
const unassigned = groups.find((g) => g.customer_id == null)?.count ?? 0;
|
||||||
|
const top = assigned.slice(0, 20);
|
||||||
|
const names = await prisma.customers.findMany({
|
||||||
|
where: { id: { in: top.map((g) => g.customer_id as number) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const nameById = new Map(names.map((c) => [c.id, c.name]));
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
...(year ? { year } : {}),
|
||||||
|
customers_with_records: assigned.length,
|
||||||
|
shown: top.length,
|
||||||
|
top: top.map((g) => ({
|
||||||
|
customer:
|
||||||
|
nameById.get(g.customer_id as number) ??
|
||||||
|
`Zákazník #${g.customer_id}`,
|
||||||
|
count: g.count,
|
||||||
|
})),
|
||||||
|
...(unassigned ? { records_without_customer: unassigned } : {}),
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tool definitions the caller may use — filtered by their permissions. */
|
/** Tool definitions the caller may use — filtered by their permissions. */
|
||||||
@@ -1861,4 +2020,5 @@ export const TOOL_LABELS: Record<string, string> = {
|
|||||||
list_vehicles: "Vozidla",
|
list_vehicles: "Vozidla",
|
||||||
get_document_totals: "Součty dokumentů",
|
get_document_totals: "Součty dokumentů",
|
||||||
get_received_invoice_detail: "Detail přijaté faktury",
|
get_received_invoice_detail: "Detail přijaté faktury",
|
||||||
|
get_top_customers: "TOP zákazníci",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ function agentSystemPrompt(
|
|||||||
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
||||||
(tools.length > 0
|
(tools.length > 0
|
||||||
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
||||||
|
"Seznamové nástroje zobrazují max ~20 řádků, ale total_matching je CELKOVÝ počet odpovídajících záznamů — na otázky 'kolik' odpovídej z total_matching. Na 'pro koho nejvíc' a součty používej agregační nástroje (get_top_customers, get_document_totals, get_invoice_stats) — nikdy nesčítej řádky ze seznamů. " +
|
||||||
(hasFindEmployee
|
(hasFindEmployee
|
||||||
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
||||||
: "") +
|
: "") +
|
||||||
|
|||||||
Reference in New Issue
Block a user