diff --git a/src/__tests__/ai-tools.test.ts b/src/__tests__/ai-tools.test.ts index cf4aadd..e9e5c4c 100644 --- a/src/__tests__/ai-tools.test.ts +++ b/src/__tests__/ai-tools.test.ts @@ -176,6 +176,57 @@ beforeAll(async () => { }); }); +const FIX_OFFER_NUMBER = "2098/NA/99999"; +let fixOfferId = 0; + +beforeAll(async () => { + await prisma.quotations.deleteMany({ + where: { quotation_number: FIX_OFFER_NUMBER }, + }); + const q = await prisma.quotations.create({ + data: { + quotation_number: FIX_OFFER_NUMBER, + status: "active", + currency: "EUR", + quotation_items: { + create: [ + { + position: 1, + description: "Rozvaděč RVO-1", + item_description: "

Hlavní rozvaděč včetně montáže

", + quantity: 2, + unit: "ks", + unit_price: 1000, + }, + { + position: 2, + description: "Doprava (informativní)", + quantity: 1, + unit: "ks", + unit_price: 500, + is_included_in_total: false, + }, + ], + }, + scope_sections: { + create: [ + { + position: 1, + title_cz: "Rozsah projektu", + content: "

Dodávka & montáž na klíč.

", + }, + ], + }, + }, + }); + fixOfferId = q.id; +}); + +afterAll(async () => { + // Items/sections cascade with the quotation. + await prisma.quotations.deleteMany({ where: { id: fixOfferId } }); +}); + afterAll(async () => { // FK-safe order: plan entries carry a Restrict FK on created_by. const fixUserIds = [fixUserId, fixUser2Id]; @@ -410,6 +461,56 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => { expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too }); + it("get_offer_detail returns line items, totals and stripped sections", async () => { + const res = await executeTool( + "get_offer_detail", + { number: "99999" }, // partial number lookup + ADMIN, + ); + expect(res.ok).toBe(true); + const r = res.result as { + number: string; + currency: string; + item_count: number; + items_total: number; + items: { + name: string; + detail?: string; + line_total: number; + excluded_from_total?: boolean; + }[]; + sections: { title: string | null; text: string }[]; + }; + expect(r.number).toBe(FIX_OFFER_NUMBER); + expect(r.currency).toBe("EUR"); + expect(r.item_count).toBe(2); + expect(r.items[0]).toMatchObject({ + name: "Rozvaděč RVO-1", + detail: "Hlavní rozvaděč včetně montáže", // HTML stripped + line_total: 2000, + }); + expect(r.items[1].excluded_from_total).toBe(true); + expect(r.items_total).toBe(2000); // excluded row not counted + expect(r.sections[0]).toMatchObject({ title: "Rozsah projektu" }); + expect(r.sections[0].text).toContain("Dodávka & montáž na klíč"); + + // Unknown number → explicit Czech error, flagged not-ok. + const miss = await executeTool( + "get_offer_detail", + { number: "NEEXISTUJE-123" }, + ADMIN, + ); + expect(miss.ok).toBe(false); + + // No offers.view → denied. + const denied = await executeTool( + "get_offer_detail", + { number: "99999" }, + NOBODY, + ); + expect(denied.ok).toBe(false); + }); + it("get_work_plan resolves the range-entry into per-day rows", async () => { const res = await executeTool( "get_work_plan", diff --git a/src/services/ai-tools.ts b/src/services/ai-tools.ts index 61293b4..e14bf2a 100644 --- a/src/services/ai-tools.ts +++ b/src/services/ai-tools.ts @@ -1,10 +1,10 @@ import type Anthropic from "@anthropic-ai/sdk"; import prisma from "../config/database"; -import { listInvoices, getInvoiceStats } from "./invoices.service"; -import { listOffers } from "./offers.service"; -import { listOrders } from "./orders.service"; -import { listIssuedOrders } from "./issued-orders.service"; -import { listProjects } from "./projects.service"; +import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service"; +import { listOffers, getOffer } from "./offers.service"; +import { listOrders, getOrder } from "./orders.service"; +import { listIssuedOrders, getIssuedOrder } from "./issued-orders.service"; +import { listProjects, getProject } from "./projects.service"; import { getBelowMinimumItems } from "./warehouse.service"; import { calcWorkedHours } from "./attendance.service"; import { resolveGrid, listPlanUsers } from "./plan.service"; @@ -86,6 +86,77 @@ const hhmm = (d: Date | null): string | null => : null; const round2 = (n: number): number => Math.round(n * 100) / 100; +/** Quill/HTML rich text → compact plain text for model context. */ +const stripHtml = (html: string | null | undefined): string => + (html ?? "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/\s+/g, " ") + .trim(); + +interface DocItemRow { + description: string | null; + item_description: string | null; + quantity: unknown; + unit: string | null; + unit_price: unknown; + is_included_in_total?: boolean | null; +} + +/** + * Shared line-item mapping for the document detail tools. Shows up to 50 + * rows; the items_total still covers ALL included rows (same rule as the + * editors: is_included_in_total === false rows don't count). + */ +const mapDocItems = (rows: DocItemRow[]) => { + let total = 0; + for (const r of rows) { + if (r.is_included_in_total !== false) { + total += Number(r.quantity ?? 0) * Number(r.unit_price ?? 0); + } + } + return { + item_count: rows.length, + items_shown: Math.min(rows.length, 50), + items: rows.slice(0, 50).map((r) => { + const qty = Number(r.quantity ?? 0); + const price = Number(r.unit_price ?? 0); + const detail = stripHtml(r.item_description); + return { + name: r.description, + ...(detail ? { detail: detail.slice(0, 300) } : {}), + quantity: qty, + unit: r.unit, + unit_price: price, + line_total: round2(qty * price), + ...(r.is_included_in_total === false + ? { excluded_from_total: true } + : {}), + }; + }), + items_total: round2(total), + }; +}; + +interface DocSectionRow { + title: string | null; + title_cz: string | null; + content: string | null; +} + +/** Rich-text sections → short plain-text excerpts (max 10 × 500 chars). */ +const mapDocSections = (rows: DocSectionRow[]) => + rows + .map((s) => ({ + title: s.title_cz || s.title || null, + text: stripHtml(s.content).slice(0, 500), + })) + .filter((s) => s.title || s.text) + .slice(0, 10); + const TOOLS: ToolSpec[] = [ { permission: "invoices.view", @@ -980,6 +1051,236 @@ const TOOLS: ToolSpec[] = [ }; }, }, + { + permission: "offers.view", + definition: { + name: "get_offer_detail", + description: + "Detail jedné nabídky včetně POLOŽEK (rozpis řádků s množstvím, jednotkovými cenami a součtem) a textových sekcí. Volej, když se uživatel ptá na obsah/položky konkrétní nabídky. Číslo nabídky zjistíš z list_offers nebo od uživatele; koncepty bez čísla detail nemají. Ceny BEZ DPH.", + input_schema: { + type: "object", + properties: { + number: { + type: "string", + description: "Číslo nabídky, stačí část (např. 10035)", + }, + }, + required: ["number"], + }, + }, + handler: async (input) => { + const number = str(input.number); + if (!number) return { error: "Zadej číslo nabídky." }; + const found = await prisma.quotations.findFirst({ + where: { quotation_number: { contains: number } }, + orderBy: { id: "desc" }, + select: { id: true }, + }); + if (!found) return { error: "Nabídka s tímto číslem neexistuje." }; + const o = await getOffer(found.id); + if (!o) return { error: "Nabídka nenalezena." }; + return { + number: o.quotation_number, + status: o.status, + customer: o.customer_name, + project_code: o.project_code, + valid_until: o.valid_until, + currency: o.currency, + order_number: o.order?.order_number ?? null, + note: "Ceny jsou bez DPH (nabídka není daňový doklad).", + ...mapDocItems(o.items), + sections: mapDocSections(o.sections), + }; + }, + }, + { + permission: "orders.view", + definition: { + name: "get_order_detail", + description: + "Detail jedné přijaté objednávky (od zákazníka) včetně POLOŽEK a textových sekcí, s vazbami na nabídku, projekt a fakturu. Číslo objednávky zjistíš z list_orders. Ceny BEZ DPH.", + input_schema: { + type: "object", + properties: { + number: { + type: "string", + description: "Číslo objednávky, stačí část", + }, + }, + required: ["number"], + }, + }, + handler: async (input) => { + const number = str(input.number); + if (!number) return { error: "Zadej číslo objednávky." }; + const found = await prisma.orders.findFirst({ + where: { order_number: { contains: number } }, + orderBy: { id: "desc" }, + select: { id: true }, + }); + if (!found) return { error: "Objednávka s tímto číslem neexistuje." }; + const o = await getOrder(found.id); + if (!o) return { error: "Objednávka nenalezena." }; + return { + number: o.order_number, + customer_order_number: o.customer_order_number, + status: o.status, + customer: o.customer_name, + currency: o.currency, + quotation_number: o.quotation_number, + project_number: o.project?.project_number ?? null, + invoice_number: o.invoice_number, + note: "Ceny jsou bez DPH.", + ...mapDocItems(o.items), + sections: mapDocSections(o.sections), + }; + }, + }, + { + permission: "orders.view", + definition: { + name: "get_issued_order_detail", + description: + "Detail jedné vydané objednávky (nákup u dodavatele) včetně POLOŽEK a obsahu. Číslo zjistíš z list_issued_orders. Ceny BEZ DPH.", + input_schema: { + type: "object", + properties: { + number: { + type: "string", + description: "Číslo vydané objednávky, stačí část", + }, + }, + required: ["number"], + }, + }, + handler: async (input) => { + const number = str(input.number); + if (!number) return { error: "Zadej číslo vydané objednávky." }; + const found = await prisma.issued_orders.findFirst({ + where: { po_number: { contains: number } }, + orderBy: { id: "desc" }, + select: { id: true }, + }); + if (!found) { + return { error: "Vydaná objednávka s tímto číslem neexistuje." }; + } + const o = await getIssuedOrder(found.id); + if (!o) return { error: "Vydaná objednávka nenalezena." }; + return { + number: o.po_number, + status: o.status, + supplier: o.supplier_name, + order_date: o.order_date, + currency: o.currency, + note: "Ceny jsou bez DPH.", + ...mapDocItems(o.items), + sections: mapDocSections(o.sections), + }; + }, + }, + { + permission: "invoices.view", + definition: { + name: "get_invoice_detail", + description: + "Detail jedné vydané faktury včetně POLOŽEK a rozpisu DPH (základ, DPH, celkem s DPH). Číslo faktury zjistíš z list_invoices.", + input_schema: { + type: "object", + properties: { + number: { + type: "string", + description: "Číslo faktury, stačí část", + }, + }, + required: ["number"], + }, + }, + handler: async (input) => { + const number = str(input.number); + if (!number) return { error: "Zadej číslo faktury." }; + const found = await prisma.invoices.findFirst({ + where: { invoice_number: { contains: number } }, + orderBy: { id: "desc" }, + select: { id: true }, + }); + if (!found) return { error: "Faktura s tímto číslem neexistuje." }; + const inv = await getInvoice(found.id); + if (!inv) return { error: "Faktura nenalezena." }; + // Same VAT rule as the invoice editor: per-item vat_rate, applied only + // when the header apply_vat switch is on. + let net = 0; + let vat = 0; + for (const it of inv.items) { + const line = Number(it.quantity ?? 0) * Number(it.unit_price ?? 0); + net += line; + if (inv.apply_vat !== false) { + vat += line * (Number(it.vat_rate ?? 0) / 100); + } + } + return { + number: inv.invoice_number, + status: inv.status, + customer: inv.customer_name, + order_number: inv.order_number, + issue_date: inv.issue_date, + due_date: inv.due_date, + currency: inv.currency, + net_total: round2(net), + vat_total: round2(vat), + total_with_vat: round2(net + vat), + ...mapDocItems(inv.items), + sections: mapDocSections(inv.sections), + }; + }, + }, + { + permission: "projects.view", + definition: { + name: "get_project_detail", + description: + "Detail jednoho projektu: stav, zákazník, odpovědná osoba, termíny, vazby na nabídku/objednávku a poslední poznámky. Číslo projektu zjistíš z list_projects.", + input_schema: { + type: "object", + properties: { + number: { + type: "string", + description: "Číslo projektu, stačí část", + }, + }, + required: ["number"], + }, + }, + handler: async (input) => { + const number = str(input.number); + if (!number) return { error: "Zadej číslo projektu." }; + const found = await prisma.projects.findFirst({ + where: { project_number: { contains: number } }, + orderBy: { id: "desc" }, + select: { id: true }, + }); + if (!found) return { error: "Projekt s tímto číslem neexistuje." }; + const p = await getProject(found.id); + if (!p) return { error: "Projekt nenalezen." }; + return { + number: p.project_number, + name: p.name, + status: p.status, + customer: p.customer_name, + responsible: p.responsible_user_name, + start_date: p.start_date, + end_date: p.end_date, + quotation_number: p.quotation_number, + order_number: p.order_number, + order_status: p.order_status, + description: stripHtml(p.notes).slice(0, 500) || null, + recent_notes: p.project_notes.slice(0, 5).map((n) => ({ + author: n.user_name, + date: n.created_at, + text: stripHtml(n.content).slice(0, 300), + })), + }; + }, + }, ]; /** Tool definitions the caller may use — filtered by their permissions. */ @@ -1042,4 +1343,9 @@ export const TOOL_LABELS: Record = { find_employee: "Zaměstnanci", list_trips: "Kniha jízd", get_work_plan: "Plán práce", + get_offer_detail: "Detail nabídky", + get_order_detail: "Detail objednávky", + get_issued_order_detail: "Detail obj. vydané", + get_invoice_detail: "Detail faktury", + get_project_detail: "Detail projektu", };