diff --git a/src/__tests__/ai-tools.test.ts b/src/__tests__/ai-tools.test.ts index e9e5c4c..8aae250 100644 --- a/src/__tests__/ai-tools.test.ts +++ b/src/__tests__/ai-tools.test.ts @@ -112,6 +112,16 @@ beforeAll(async () => { }, }); fixUser2Id = u2.id; + // Leave balance for the HR tools (far-future year per fixture hygiene). + await prisma.leave_balances.create({ + data: { + user_id: fixUserId, + year: 2098, + vacation_total: 25, + vacation_used: 5, + sick_used: 2, + }, + }); await prisma.attendance.create({ data: { user_id: fixUserId, @@ -177,12 +187,40 @@ 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."; let fixOfferId = 0; +let fixSupplierId = 0; +let fixRiId = 0; beforeAll(async () => { await prisma.quotations.deleteMany({ where: { quotation_number: FIX_OFFER_NUMBER }, }); + await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } }); + await prisma.received_invoices.deleteMany({ + where: { supplier_name: FIX_RI_SUPPLIER }, + }); + const sup = await prisma.sklad_suppliers.create({ + data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" }, + }); + fixSupplierId = sup.id; + const ri = await prisma.received_invoices.create({ + data: { + month: 6, + year: 2098, + supplier_name: FIX_RI_SUPPLIER, + invoice_number: "AITEST-RI-1", + amount: 1210, + vat_rate: 21, + vat_amount: 210, + currency: "CZK", + issue_date: new Date(Date.UTC(2098, 5, 1)), + due_date: new Date(Date.UTC(2098, 5, 15)), + status: "unpaid", + }, + }); + fixRiId = ri.id; const q = await prisma.quotations.create({ data: { quotation_number: FIX_OFFER_NUMBER, @@ -225,6 +263,8 @@ beforeAll(async () => { afterAll(async () => { // Items/sections cascade with the quotation. await prisma.quotations.deleteMany({ where: { id: fixOfferId } }); + await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } }); + await prisma.received_invoices.deleteMany({ where: { id: fixRiId } }); }); afterAll(async () => { @@ -511,6 +551,195 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => { expect(denied.ok).toBe(false); }); + it("get_leave_balance: own for a recorder; foreign needs balances/manage", async () => { + const own = await executeTool( + "get_leave_balance", + { year: 2098 }, + { + userId: fixUserId, + roleName: "viewer", + permissions: ["attendance.record"], + }, + ); + expect(own.ok).toBe(true); + expect(own.result).toMatchObject({ + user_id: fixUserId, + year: 2098, + vacation_total: 25, + vacation_used: 5, + vacation_remaining: 20, + sick_used: 2, + }); + + // Bare attendance.record asking about someone else → denied. + const denied = await executeTool( + "get_leave_balance", + { user_id: fixUserId, year: 2098 }, + RECORDER, + ); + expect(denied.ok).toBe(false); + + // attendance.balances (the overview route's permission) unlocks others. + const hr = await executeTool( + "get_leave_balance", + { user_id: fixUserId, year: 2098 }, + { + userId: 999999998, + roleName: "viewer", + permissions: ["attendance.balances"], + }, + ); + expect(hr.ok).toBe(true); + }); + + it("get_work_fund returns the current-month fund; foreign needs manage", async () => { + const res = await executeTool( + "get_work_fund", + { user_id: fixUserId }, + ADMIN, + ); + expect(res.ok).toBe(true); + const r = res.result as { + fund_hours: number; + worked_hours: number; + business_days: number; + }; + expect(r.fund_hours).toBeGreaterThan(0); + expect(r.business_days).toBeGreaterThan(0); + expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098 + + const denied = await executeTool( + "get_work_fund", + { user_id: fixUserId }, + RECORDER, + ); + expect(denied.ok).toBe(false); + }); + + it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => { + const denied = await executeTool("get_project_hours_report", {}, RECORDER); + expect(denied.ok).toBe(false); + + // The 2098 fixture shift (8.00 h, no project) lands in the labeled + // no-project bucket with per-user hours. + const res = await executeTool( + "get_project_hours_report", + { year: 2098 }, + ADMIN, + ); + expect(res.ok).toBe(true); + const r = res.result as { + year: number; + projects: { + label: string; + hours: number; + by_user: Record; + }[]; + }; + expect(r.year).toBe(2098); + const bucket = r.projects.find((p) => p.label === "(bez projektu)"); + expect(bucket).toBeDefined(); + expect(bucket!.hours).toBe(8); + expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8); + }); + + it("find_supplier + list_vehicles return their compact shapes", async () => { + const sup = await executeTool( + "find_supplier", + { search: "AiTest Dodavatel" }, + { userId: 999999998, roleName: "viewer", permissions: ["orders.view"] }, + ); + expect(sup.ok).toBe(true); + const s = ( + sup.result as { suppliers: { name: string; ico: string | null }[] } + ).suppliers.find((x) => x.name === FIX_SUPPLIER); + expect(s).toMatchObject({ ico: "99999998" }); + + const veh = await executeTool( + "list_vehicles", + { search: FIX.spz }, + { userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] }, + ); + expect(veh.ok).toBe(true); + const v = ( + veh.result as { + vehicles: { spz: string; current_km: number; trip_count: number }[]; + } + ).vehicles[0]; + // 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( + (await executeTool("find_supplier", { search: "x" }, NOBODY)).ok, + ).toBe(false); + }); + + it("get_document_totals sums the WHOLE filtered set per currency", async () => { + const res = await executeTool( + "get_document_totals", + { type: "offers", search: FIX_OFFER_NUMBER }, + ADMIN, + ); + expect(res.ok).toBe(true); + expect( + (res.result as { totals: { currency: string; amount: number }[] }).totals, + ).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted + + // orders.view alone cannot read offer totals (per-type re-check). + const denied = await executeTool( + "get_document_totals", + { type: "offers" }, + { userId: 999999998, roleName: "viewer", permissions: ["orders.view"] }, + ); + expect(denied.ok).toBe(false); + + // Offers have no period filter — month/year must error, not be ignored. + const offersMonth = await executeTool( + "get_document_totals", + { type: "offers", month: 6, year: 2026 }, + ADMIN, + ); + expect(offersMonth.ok).toBe(false); + + // A bare month defaults the year (never an unmarked all-time sum) and + // the applied period is echoed back. + const bareMonth = await executeTool( + "get_document_totals", + { type: "orders", month: 6 }, + ADMIN, + ); + expect(bareMonth.ok).toBe(true); + expect((bareMonth.result as { period: string }).period).toBe( + `6/${new Date().getFullYear()}`, + ); + }); + + it("get_received_invoice_detail returns the gross-amount detail", async () => { + const res = await executeTool( + "get_received_invoice_detail", + { search: "AiTest Supplier" }, + VIEWER_INVOICES, + ); + expect(res.ok).toBe(true); + expect(res.result).toMatchObject({ + supplier: FIX_RI_SUPPLIER, + number: "AITEST-RI-1", + amount_with_vat: 1210, + vat_amount: 210, + status: "unpaid", + }); + + expect( + ( + await executeTool( + "get_received_invoice_detail", + { search: "x" }, + NOBODY, + ) + ).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 e14bf2a..e1a57ad 100644 --- a/src/services/ai-tools.ts +++ b/src/services/ai-tools.ts @@ -1,12 +1,21 @@ import type Anthropic from "@anthropic-ai/sdk"; import prisma from "../config/database"; 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 { listOffers, getOffer, getOfferTotals } from "./offers.service"; +import { listOrders, getOrder, getOrderTotals } from "./orders.service"; +import { + listIssuedOrders, + getIssuedOrder, + getIssuedOrderTotals, +} from "./issued-orders.service"; import { listProjects, getProject } from "./projects.service"; import { getBelowMinimumItems } from "./warehouse.service"; -import { calcWorkedHours } from "./attendance.service"; +import { + calcWorkedHours, + getBalances, + getWorkfund, + getProjectReport, +} from "./attendance.service"; import { resolveGrid, listPlanUsers } from "./plan.service"; /** @@ -1281,6 +1290,503 @@ const TOOLS: ToolSpec[] = [ }; }, }, + { + // Own balance parity: getStatus exposes it to every attendance.record + // holder; the all-users overview route requires attendance.balances. + // attendance.manage also unlocks foreign balances — DELIBERATE widening: + // the manage-gated print route (getPrintData) already returns + // vacation_total/remaining for every user, so manage holders gain + // nothing new here. + permission: [ + "attendance.record", + "attendance.manage", + "attendance.balances", + ], + definition: { + name: "get_leave_balance", + description: + "Zůstatek dovolené a čerpání nemocenské za rok (nárok, vyčerpáno, zbývá). Bez user_id vrací zůstatek PŘIHLÁŠENÉHO uživatele; cizí zůstatky vyžadují oprávnění na zůstatky/správu docházky.", + input_schema: { + type: "object", + properties: { + user_id: { + type: "number", + description: "ID jiného uživatele (zjistíš přes find_employee)", + }, + year: { type: "number", description: "Rok (výchozí aktuální)" }, + }, + }, + }, + handler: async (input, ctx) => { + const targetUserId = num(input.user_id) ?? ctx.userId; + if ( + targetUserId !== ctx.userId && + !ctxCan(ctx, "attendance.balances") && + !ctxCan(ctx, "attendance.manage") + ) { + return { error: DENIED("zůstatky dovolené jiných uživatelů") }; + } + const year = num(input.year) ?? new Date().getFullYear(); + const { balances } = await getBalances(year); + const b = balances[String(targetUserId)]; + if (!b) { + return { + error: + "Uživatel není v přehledu docházky (neaktivní nebo bez docházky).", + }; + } + return { user_id: targetUserId, year, ...b }; + }, + }, + { + // Own month parity: getStatus exposes the fund to the user; the + // all-users workfund route requires attendance.manage. + permission: ["attendance.record", "attendance.manage"], + definition: { + name: "get_work_fund", + description: + "Fond pracovní doby za měsíc: fond hodin (i k dnešnímu dni), odpracováno, pokryto (práce + dovolená/nemoc), přesčas a chybějící hodiny. Bez user_id vrací PŘIHLÁŠENÉHO uživatele; cizí fond vyžaduje správu docházky.", + input_schema: { + type: "object", + properties: { + user_id: { + type: "number", + description: "ID jiného uživatele (jen se správou docházky)", + }, + month: { + type: "number", + description: "Měsíc 1-12 (výchozí aktuální)", + }, + year: { type: "number", description: "Rok (výchozí aktuální)" }, + }, + }, + }, + handler: async (input, ctx) => { + const targetUserId = num(input.user_id) ?? ctx.userId; + if (targetUserId !== ctx.userId && !ctxCan(ctx, "attendance.manage")) { + return { error: DENIED("fond pracovní doby jiných uživatelů") }; + } + const now = new Date(); + const month = num(input.month) ?? now.getMonth() + 1; + const year = num(input.year) ?? now.getFullYear(); + if (!Number.isInteger(month) || month > 12) { + return { error: "Měsíc musí být celé číslo 1-12." }; + } + const fund = await getWorkfund(year); + // getWorkfund's future-year early return types months as `{}` — pin + // the indexable shape. + const months: Record< + string, + { + month_name: string; + fund: number; + fund_to_date: number; + business_days: number; + users: Record< + string, + { + name: string; + worked: number; + covered: number; + overtime: number; + missing: number; + } + >; + } + > = fund.months; + const m = months[String(month)]; + if (!m) { + return { + error: "Pro tento měsíc zatím nejsou data (budoucí období).", + }; + } + const u = m.users[String(targetUserId)]; + if (!u) { + return { + error: + "Uživatel není v přehledu docházky (neaktivní nebo bez docházky).", + }; + } + return { + user_id: targetUserId, + month, + year, + month_name: m.month_name, + business_days: m.business_days, + fund_hours: m.fund, + fund_hours_to_date: m.fund_to_date, + worked_hours: u.worked, + covered_hours: u.covered, + overtime_hours: u.overtime, + missing_hours: u.missing, + note: "Pokryto = odpracováno + dovolená/nemoc; fond k dnešnímu dni počítá jen uplynulé pracovní dny.", + }; + }, + }, + { + // Route parity: action=project_report requires attendance.manage. + permission: "attendance.manage", + definition: { + name: "get_project_hours_report", + description: + "Report odpracovaných hodin podle PROJEKTŮ za rok (z docházky). Volitelně filtr na jeden projekt (číslo nebo část názvu) a měsíc; vrací hodiny celkem, po měsících a po lidech. Volej na dotazy typu 'kolik hodin jsme strávili na projektu X'.", + input_schema: { + type: "object", + properties: { + year: { type: "number", description: "Rok (výchozí aktuální)" }, + month: { + type: "number", + description: "Jen měsíc 1-12 (vynech pro celý rok)", + }, + project: { + type: "string", + description: + "Číslo nebo část názvu projektu (vynech pro přehled všech)", + }, + }, + }, + }, + handler: async (input) => { + const year = num(input.year) ?? new Date().getFullYear(); + const monthFilter = num(input.month); + const projectFilter = str(input.project)?.toLowerCase(); + const report = await getProjectReport(year); + + type Agg = { + label: string; + project_number?: string; + project_name?: string; + hours: number; + by_month: Record; + by_user: Record; + }; + const byProject = new Map(); + for (const [monthKey, m] of Object.entries(report.months)) { + if (monthFilter && Number(monthKey) !== monthFilter) continue; + for (const p of m.projects) { + const label = p.project_number || p.project_name || "(bez projektu)"; + if ( + projectFilter && + !`${p.project_number ?? ""} ${p.project_name ?? ""}` + .toLowerCase() + .includes(projectFilter) + ) { + continue; + } + const agg = byProject.get(label) ?? { + // Always-present label: the no-project bucket has undefined + // number/name, which JSON.stringify would drop entirely. + label, + project_number: p.project_number, + project_name: p.project_name, + hours: 0, + by_month: {}, + by_user: {}, + }; + agg.hours += p.hours; + agg.by_month[monthKey] = round2( + (agg.by_month[monthKey] || 0) + p.hours, + ); + for (const u of p.users) { + agg.by_user[u.user_name] = round2( + (agg.by_user[u.user_name] || 0) + u.hours, + ); + } + byProject.set(label, agg); + } + } + const projects = [...byProject.values()] + .sort((a, b) => b.hours - a.hours) + .slice(0, 20) + .map((p) => ({ ...p, hours: round2(p.hours) })); + return { + year, + ...(monthFilter ? { month: monthFilter } : {}), + projects_matched: byProject.size, + projects, + note: "Hodiny pocházejí z docházky (záznamy práce s projektem).", + }; + }, + }, + { + // The issued-orders supplier picker serves orders.view; the warehouse + // supplier admin serves warehouse.manage — the fields returned here are + // the same set getIssuedOrder already exposes to orders.view holders. + permission: ["warehouse.manage", "orders.view"], + definition: { + name: "find_supplier", + description: + "Najde dodavatele podle názvu, IČO nebo města (sklad + objednávky vydané). Vrací max 10 odpovídajících.", + input_schema: { + type: "object", + properties: { + search: { + type: "string", + description: "Název, IČO nebo město (stačí část)", + }, + }, + required: ["search"], + }, + }, + handler: async (input, ctx) => { + const search = str(input.search); + if (!search) return { error: "Zadej název, IČO nebo město dodavatele." }; + // 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, + }, + }); + return { + shown: rows.length, + suppliers: rows.map((s) => ({ + name: s.name, + city: s.city, + ico: s.ico, + dic: s.dic, + active: s.is_active, + })), + }; + }, + }, + { + // Mirrors GET /vehicles: any trips permission or vehicle management. + permission: [ + "vehicles.manage", + "trips.record", + "trips.manage", + "trips.history", + ], + definition: { + name: "list_vehicles", + description: + "Firemní vozidla: SPZ, značka/model, aktuální stav tachometru a počet jízd. Volej na dotazy typu 'kolik má najeto dodávka'.", + input_schema: { + type: "object", + properties: { + search: { + type: "string", + description: "Hledání podle názvu nebo SPZ (vynech pro všechna)", + }, + }, + }, + }, + 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([ + prisma.vehicles.findMany({ + where, + orderBy: [{ name: "asc" }, { id: "asc" }], + take: 20, + select: { + id: true, + name: true, + spz: true, + brand: true, + model: true, + initial_km: true, + is_active: true, + }, + }), + prisma.vehicles.count({ where }), + prisma.trips.groupBy({ + by: ["vehicle_id"], + _max: { end_km: true }, + _count: { id: true }, + }), + ]); + const statsMap = new Map( + tripStats.map((s) => [ + s.vehicle_id, + { maxKm: s._max.end_km ?? 0, count: s._count.id }, + ]), + ); + return { + total_matching: total, + shown: vehicles.length, + vehicles: vehicles.map((v) => { + const stats = statsMap.get(v.id); + return { + name: v.name, + spz: v.spz, + brand: [v.brand, v.model].filter(Boolean).join(" ") || null, + current_km: stats + ? Math.max(v.initial_km, stats.maxKm) + : v.initial_km, + trip_count: stats?.count ?? 0, + active: v.is_active, + }; + }), + }; + }, + }, + { + // Coarse gate any-of; the handler re-checks the exact permission per + // document type (offers vs orders families). + permission: ["offers.view", "orders.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.", + input_schema: { + type: "object", + properties: { + type: { + type: "string", + enum: ["offers", "orders", "issued_orders"], + description: "Typ dokumentů", + }, + status: { + type: "string", + description: "Filtr stavu (vynech pro všechny)", + }, + search: { + type: "string", + description: "Hledání jako v list_* nástroji", + }, + month: { + type: "number", + description: "Měsíc 1-12 (jen orders/issued_orders)", + }, + year: { + type: "number", + description: "Rok (jen orders/issued_orders)", + }, + }, + required: ["type"], + }, + }, + handler: async (input, ctx) => { + const type = str(input.type); + const month = num(input.month); + // A bare month means the current year — the where-builders apply the + // period only when BOTH are set, and a silently dropped filter would + // let all-time totals masquerade as the asked-about month. + const year = + num(input.year) ?? (month ? new Date().getFullYear() : undefined); + if (month && (!Number.isInteger(month) || month > 12)) { + return { error: "Měsíc musí být celé číslo 1-12." }; + } + const filters = { + search: str(input.search), + status: str(input.status), + month, + year, + }; + // Echoed so the model (and the user) always see which period the sum + // actually covers. + const period = + month && year ? `${month}/${year}` : "vše (bez filtru období)"; + if (type === "offers") { + if (!ctxCan(ctx, "offers.view")) return { error: DENIED("nabídky") }; + if (month || num(input.year)) { + // OfferFilterParams has no month/year — they'd be silently ignored + // and the all-time sum would pose as a monthly figure. + return { + error: + "Nabídky nelze filtrovat podle měsíce/roku — souhrn nabídek je vždy za všechny odpovídající; vynech month/year.", + }; + } + const { totals } = await getOfferTotals(filters); + return { + type, + period: "vše", + totals, + note: "Součty bez DPH, po měnách.", + }; + } + if (type === "orders") { + if (!ctxCan(ctx, "orders.view")) return { error: DENIED("objednávky") }; + const { totals } = await getOrderTotals(filters); + return { type, period, totals, note: "Součty bez DPH, po měnách." }; + } + if (type === "issued_orders") { + if (!ctxCan(ctx, "orders.view")) { + return { error: DENIED("objednávky vydané") }; + } + const { totals } = await getIssuedOrderTotals(filters); + return { type, period, totals, note: "Součty bez DPH, po měnách." }; + } + return { error: "Neznámý typ dokumentu." }; + }, + }, + { + permission: "invoices.view", + definition: { + name: "get_received_invoice_detail", + description: + "Detail jedné přijaté faktury (od dodavatele): částka VČETNĚ DPH, stav, splatnost/uhrazení, popis a interní poznámky. Hledej podle čísla faktury nebo názvu dodavatele.", + input_schema: { + type: "object", + properties: { + search: { + type: "string", + description: "Číslo faktury nebo název dodavatele (stačí část)", + }, + }, + required: ["search"], + }, + }, + handler: async (input) => { + const search = str(input.search); + if (!search) return { error: "Zadej číslo faktury nebo dodavatele." }; + const where = { + OR: [ + { invoice_number: { contains: search } }, + { supplier_name: { contains: search } }, + ], + }; + const [inv, matched] = await Promise.all([ + prisma.received_invoices.findFirst({ + where, + orderBy: [{ issue_date: "desc" }, { id: "desc" }], + }), + prisma.received_invoices.count({ where }), + ]); + if (!inv) { + return { error: "Přijatá faktura odpovídající hledání neexistuje." }; + } + return { + total_matching: matched, + supplier: inv.supplier_name, + number: inv.invoice_number, + description: inv.description, + amount_with_vat: Number(inv.amount), + vat_rate: Number(inv.vat_rate), + vat_amount: Number(inv.vat_amount), + currency: inv.currency, + issue_date: inv.issue_date, + due_date: inv.due_date, + paid_date: inv.paid_date, + status: inv.status, + attachment: inv.file_name, + internal_notes: stripHtml(inv.notes).slice(0, 300) || null, + note: "Částka je včetně DPH (gross). Zobrazena nejnovější odpovídající faktura.", + }; + }, + }, ]; /** Tool definitions the caller may use — filtered by their permissions. */ @@ -1348,4 +1854,11 @@ export const TOOL_LABELS: Record = { get_issued_order_detail: "Detail obj. vydané", get_invoice_detail: "Detail faktury", get_project_detail: "Detail projektu", + get_leave_balance: "Zůstatek dovolené", + get_work_fund: "Fond pracovní doby", + get_project_hours_report: "Hodiny na projektech", + find_supplier: "Dodavatelé", + list_vehicles: "Vozidla", + get_document_totals: "Součty dokumentů", + get_received_invoice_detail: "Detail přijaté faktury", };