Compare commits

..

4 Commits

Author SHA1 Message Date
BOHA
ea3b595b32 chore(release): v2.4.25 - autosize item descriptions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:43 +02:00
BOHA
e9b432bec6 feat(documents): autosize item descriptions (minRows 2, maxRows 16)
The 'Podrobny popis' textareas in the shared items editor and the invoice
page now grow with content like the rich-text editor (fixed rows={2} ->
minRows/maxRows autosize), capped at 16 rows; the manual vertical resize
handle stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:43 +02:00
BOHA
741c8109fa chore(release): v2.4.24 - Odin lookup normalization + aggregations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:07:58 +02:00
BOHA
07d9c8d9f7 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>
2026-06-11 15:07:58 +02:00
7 changed files with 338 additions and 78 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.23",
"version": "2.4.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.23",
"version": "2.4.25",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.23",
"version": "2.4.25",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -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",

View File

@@ -214,7 +214,8 @@ function SortableItemRow({
placeholder="Volitelný"
InputProps={{ readOnly }}
multiline
rows={2}
minRows={2}
maxRows={16}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{ "& textarea": { resize: "vertical" } }}
/>
@@ -352,7 +353,8 @@ function SortableItemRow({
placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }}
multiline
rows={2}
minRows={2}
maxRows={16}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{
"& textarea": {

View File

@@ -317,7 +317,8 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
placeholder="Volitelný"
multiline
rows={2}
minRows={2}
maxRows={16}
sx={{ "& textarea": { resize: "vertical" } }}
/>
<Box
@@ -418,7 +419,8 @@ function SortableInvoiceRow({
}
placeholder="Podrobný popis (volitelný)"
multiline
rows={2}
minRows={2}
maxRows={16}
sx={{
"& textarea": {
fontSize: "0.8rem",

View File

@@ -94,6 +94,14 @@ const hhmm = (d: Date | null): string | null =>
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
: null;
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. */
const stripHtml = (html: string | null | undefined): string =>
@@ -472,7 +480,7 @@ const TOOLS: ToolSpec[] = [
definition: {
name: "list_customers",
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: {
type: "object",
properties: {
@@ -485,12 +493,13 @@ const TOOLS: ToolSpec[] = [
},
handler: async (input) => {
// 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 where = search
? {
OR: [
{ 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
// unconsumed batch quantities.
const rows = await prisma.sklad_items.findMany({
where: {
is_active: true,
OR: [
{ name: { contains: search } },
{ item_number: { contains: search } },
],
},
take: 20,
select: {
item_number: true,
name: true,
unit: true,
batches: {
where: { is_consumed: false },
select: { quantity: true },
const where = {
is_active: true,
OR: [
{ name: { contains: search } },
{ item_number: { contains: search } },
],
};
const [rows, total] = await Promise.all([
prisma.sklad_items.findMany({
where,
orderBy: [{ name: "asc" }, { id: "asc" }],
take: 20,
select: {
item_number: true,
name: true,
unit: true,
batches: {
where: { is_consumed: false },
select: { quantity: true },
},
},
},
});
}),
prisma.sklad_items.count({ where }),
]);
return {
total_matching: total,
shown: rows.length,
items: rows.map((r) => ({
item_number: r.item_number,
@@ -789,20 +804,24 @@ const TOOLS: ToolSpec[] = [
if (!fullDirectory && populations.length === 0) {
return { error: DENIED("seznam zaměstnanců") };
}
const rows = await prisma.users.findMany({
where: fullDirectory
? { OR: or }
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] },
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
take: 10,
select: {
id: true,
first_name: true,
last_name: true,
username: true,
is_active: true,
},
});
const where = fullDirectory
? { OR: or }
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] };
const [rows, total] = await Promise.all([
prisma.users.findMany({
where,
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
take: 10,
select: {
id: true,
first_name: true,
last_name: true,
username: true,
is_active: true,
},
}),
prisma.users.count({ where }),
]);
// GET /trips/users deliberately omits usernames (login identifiers);
// only the attendance picker and the users admin expose them.
const showUsername =
@@ -810,6 +829,7 @@ const TOOLS: ToolSpec[] = [
ctxCan(ctx, "attendance.record") ||
ctxCan(ctx, "attendance.manage");
return {
total_matching: total,
shown: rows.length,
employees: rows.map((u) => ({
user_id: u.id,
@@ -883,14 +903,28 @@ const TOOLS: ToolSpec[] = [
}
const vehicle = str(input.vehicle);
if (vehicle) {
where.vehicles = {
is: {
OR: [
{ name: { contains: vehicle } },
{ spz: { contains: vehicle } },
],
},
};
// Resolve in JS so SPZ spacing is normalized on both sides
// ("4SY7039" must match the stored "4SY 7039") — SQL contains is
// literal. The vehicles table is tiny.
const allVehicles = await prisma.vehicles.findMany({
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([
@@ -1531,29 +1565,36 @@ const TOOLS: ToolSpec[] = [
handler: async (input, ctx) => {
const search = str(input.search);
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)
// filters is_active: true — only the warehouse.manage supplier admin
// sees deactivated (soft-deleted) suppliers.
const rows = await prisma.sklad_suppliers.findMany({
where: {
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
OR: [
{ name: { contains: search } },
{ ico: { contains: search } },
{ city: { contains: search } },
],
},
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
take: 10,
select: {
name: true,
city: true,
ico: true,
dic: true,
is_active: true,
},
});
const where = {
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
OR: [
{ name: { contains: search } },
{ ico: { contains: icoDigits } },
{ city: { contains: search } },
],
};
const [rows, total] = await Promise.all([
prisma.sklad_suppliers.findMany({
where,
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
take: 10,
select: {
name: true,
city: true,
ico: true,
dic: true,
is_active: true,
},
}),
prisma.sklad_suppliers.count({ where }),
]);
return {
total_matching: total,
shown: rows.length,
suppliers: rows.map((s) => ({
name: s.name,
@@ -1589,17 +1630,12 @@ const TOOLS: ToolSpec[] = [
},
handler: async (input) => {
const search = str(input.search);
const where = search
? {
OR: [{ name: { contains: search } }, { spz: { contains: search } }],
}
: {};
// Same odometer rule as GET /vehicles: max(initial_km, max trip end_km).
const [vehicles, total, tripStats] = await Promise.all([
// The vehicles table is tiny — fetch all and match in JS so SPZ
// spacing can be normalized on BOTH sides ("4SY7039" must find the
// stored "4SY 7039" and vice versa); a SQL contains is literal.
const [allVehicles, tripStats] = await Promise.all([
prisma.vehicles.findMany({
where,
orderBy: [{ name: "asc" }, { id: "asc" }],
take: 20,
select: {
id: true,
name: true,
@@ -1610,13 +1646,21 @@ const TOOLS: ToolSpec[] = [
is_active: true,
},
}),
prisma.vehicles.count({ where }),
prisma.trips.groupBy({
by: ["vehicle_id"],
_max: { end_km: 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(
tripStats.map((s) => [
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. */
@@ -1861,4 +2020,5 @@ export const TOOL_LABELS: Record<string, string> = {
list_vehicles: "Vozidla",
get_document_totals: "Součty dokumentů",
get_received_invoice_detail: "Detail přijaté faktury",
get_top_customers: "TOP zákazníci",
};

View File

@@ -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. " +
(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). " +
"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
? "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. "
: "") +