From 687791913322572512d964b762246023e3b6fbdc Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 12 Jun 2026 09:34:51 +0200 Subject: [PATCH] feat(odin): received-invoices expense totals with CZK conversion get_document_totals gains type=received_invoices: whole-set gross sums per currency + total_czk via the same toCzk logic as the stats route (unknown currencies skipped + reported), month/year via the literal columns (bare year supported), unpaid/paid filter, period echo. invoices.view joined the coarse gate (the per-type re-check keeps other families locked); the tool description routes 'kolik jsme utratili' questions here. Co-Authored-By: Claude Fable 5 --- src/__tests__/ai-tools.test.ts | 28 +++++++++++++ src/services/ai-tools.ts | 75 ++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/__tests__/ai-tools.test.ts b/src/__tests__/ai-tools.test.ts index 4854a71..2571773 100644 --- a/src/__tests__/ai-tools.test.ts +++ b/src/__tests__/ai-tools.test.ts @@ -765,6 +765,34 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => { ); }); + it("get_document_totals: received_invoices sums gross + converts to CZK", async () => { + // invoices.view alone unlocks the received-invoices branch. + const res = await executeTool( + "get_document_totals", + { type: "received_invoices", month: 6, year: 2098 }, + VIEWER_INVOICES, + ); + expect(res.ok).toBe(true); + const r = res.result as { + period: string; + invoice_count: number; + totals: { currency: string; amount: number }[]; + total_czk: number; + }; + expect(r.period).toBe("6/2098"); + expect(r.invoice_count).toBe(1); + expect(r.totals).toEqual([{ currency: "CZK", amount: 1210 }]); + expect(r.total_czk).toBe(1210); // CZK rows convert 1:1, no rate fetch + + // orders.view alone cannot read expense totals. + const denied = await executeTool( + "get_document_totals", + { type: "received_invoices" }, + { userId: 999999998, roleName: "viewer", permissions: ["orders.view"] }, + ); + expect(denied.ok).toBe(false); + }); + it("get_top_customers aggregates per customer across the whole table", async () => { const res = await executeTool( "get_top_customers", diff --git a/src/services/ai-tools.ts b/src/services/ai-tools.ts index 8bb3493..e8d3bad 100644 --- a/src/services/ai-tools.ts +++ b/src/services/ai-tools.ts @@ -16,6 +16,7 @@ import { getWorkfund, getProjectReport, } from "./attendance.service"; +import { toCzk } from "./exchange-rates"; import { resolveGrid, listPlanUsers } from "./plan.service"; /** @@ -1688,35 +1689,38 @@ const TOOLS: ToolSpec[] = [ }, { // Coarse gate any-of; the handler re-checks the exact permission per - // document type (offers vs orders families). - permission: ["offers.view", "orders.view"], + // document type (offers vs orders vs invoices families). + permission: ["offers.view", "orders.view", "invoices.view"], definition: { name: "get_document_totals", description: - "Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách, BEZ DPH. Typy: offers (nabídky), orders (objednávky přijaté), issued_orders (objednávky vydané). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů, když je výsledků víc než 20.", + "Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách. Typy: offers (nabídky, bez DPH), orders (objednávky přijaté, bez DPH), issued_orders (objednávky vydané, bez DPH), received_invoices (přijaté faktury = VÝDAJE, včetně DPH, s přepočtem do CZK). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů. Na dotazy 'kolik jsme utratili' použij received_invoices a/nebo issued_orders.", input_schema: { type: "object", properties: { type: { type: "string", - enum: ["offers", "orders", "issued_orders"], - description: "Typ dokumentů", + enum: ["offers", "orders", "issued_orders", "received_invoices"], + description: + "Typ dokumentů (received_invoices = přijaté faktury / výdaje, VČETNĚ DPH + přepočet do CZK)", }, status: { type: "string", - description: "Filtr stavu (vynech pro všechny)", + description: + "Filtr stavu (vynech pro všechny; received_invoices: unpaid/paid)", }, search: { type: "string", - description: "Hledání jako v list_* nástroji", + description: + "Hledání jako v list_* nástroji (ne received_invoices)", }, month: { type: "number", - description: "Měsíc 1-12 (jen orders/issued_orders)", + description: "Měsíc 1-12 (ne offers)", }, year: { type: "number", - description: "Rok (jen orders/issued_orders)", + description: "Rok (ne offers)", }, }, required: ["type"], @@ -1766,6 +1770,59 @@ const TOOLS: ToolSpec[] = [ const { totals } = await getOrderTotals(filters); return { type, period, totals, note: "Součty bez DPH, po měnách." }; } + if (type === "received_invoices") { + if (!ctxCan(ctx, "invoices.view")) { + return { error: DENIED("přijaté faktury") }; + } + // month/year are literal columns on received_invoices; gross sums + // per currency + CZK conversion mirror GET /received-invoices/stats. + const where: Record = {}; + if (month && year) { + where.month = month; + where.year = year; + } else if (year) { + where.year = year; + } + const status = str(input.status); + if (status === "unpaid" || status === "paid") where.status = status; + const rows = await prisma.received_invoices.findMany({ + where, + select: { amount: true, currency: true }, + }); + const byCurrency: Record = {}; + for (const r of rows) { + const cur = r.currency || "CZK"; + byCurrency[cur] = (byCurrency[cur] || 0) + (Number(r.amount) || 0); + } + const totals = Object.entries(byCurrency).map(([currency, amount]) => ({ + currency, + amount: round2(amount), + })); + // CZK conversion mirrors the stats route: a row with an unknown + // currency is skipped (logged), never fails the whole sum. + let totalCzk = 0; + let skipped = 0; + for (const r of rows) { + try { + totalCzk += await toCzk(Number(r.amount) || 0, r.currency); + } catch (e) { + skipped += 1; + console.error( + `[ai-tools] toCzk failed for currency "${r.currency}"`, + e, + ); + } + } + return { + type, + period: month && year ? `${month}/${year}` : year ? `${year}` : "vše", + invoice_count: rows.length, + totals, + total_czk: round2(totalCzk), + ...(skipped ? { czk_conversion_skipped_rows: skipped } : {}), + note: "Částky jsou VČETNĚ DPH (gross); total_czk je přepočet aktuálním kurzem.", + }; + } if (type === "issued_orders") { if (!ctxCan(ctx, "orders.view")) { return { error: DENIED("objednávky vydané") };