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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-12 09:34:51 +02:00
parent 5ec70599aa
commit 6877919133
2 changed files with 94 additions and 9 deletions

View File

@@ -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 () => { it("get_top_customers aggregates per customer across the whole table", async () => {
const res = await executeTool( const res = await executeTool(
"get_top_customers", "get_top_customers",

View File

@@ -16,6 +16,7 @@ import {
getWorkfund, getWorkfund,
getProjectReport, getProjectReport,
} from "./attendance.service"; } from "./attendance.service";
import { toCzk } from "./exchange-rates";
import { resolveGrid, listPlanUsers } from "./plan.service"; 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 // Coarse gate any-of; the handler re-checks the exact permission per
// document type (offers vs orders families). // document type (offers vs orders vs invoices families).
permission: ["offers.view", "orders.view"], permission: ["offers.view", "orders.view", "invoices.view"],
definition: { definition: {
name: "get_document_totals", name: "get_document_totals",
description: 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: { input_schema: {
type: "object", type: "object",
properties: { properties: {
type: { type: {
type: "string", type: "string",
enum: ["offers", "orders", "issued_orders"], enum: ["offers", "orders", "issued_orders", "received_invoices"],
description: "Typ dokumentů", description:
"Typ dokumentů (received_invoices = přijaté faktury / výdaje, VČETNĚ DPH + přepočet do CZK)",
}, },
status: { status: {
type: "string", type: "string",
description: "Filtr stavu (vynech pro všechny)", description:
"Filtr stavu (vynech pro všechny; received_invoices: unpaid/paid)",
}, },
search: { search: {
type: "string", type: "string",
description: "Hledání jako v list_* nástroji", description:
"Hledání jako v list_* nástroji (ne received_invoices)",
}, },
month: { month: {
type: "number", type: "number",
description: "Měsíc 1-12 (jen orders/issued_orders)", description: "Měsíc 1-12 (ne offers)",
}, },
year: { year: {
type: "number", type: "number",
description: "Rok (jen orders/issued_orders)", description: "Rok (ne offers)",
}, },
}, },
required: ["type"], required: ["type"],
@@ -1766,6 +1770,59 @@ const TOOLS: ToolSpec[] = [
const { totals } = await getOrderTotals(filters); const { totals } = await getOrderTotals(filters);
return { type, period, totals, note: "Součty bez DPH, po měnách." }; 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<string, unknown> = {};
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<string, number> = {};
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 (type === "issued_orders") {
if (!ctxCan(ctx, "orders.view")) { if (!ctxCan(ctx, "orders.view")) {
return { error: DENIED("objednávky vydané") }; return { error: DENIED("objednávky vydané") };