Compare commits
4 Commits
08af4cd0ae
...
v2.4.28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164ce54adf | ||
|
|
6877919133 | ||
|
|
5ec70599aa | ||
|
|
7e4469b29a |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.26",
|
"version": "2.4.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.26",
|
"version": "2.4.28",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.26",
|
"version": "2.4.28",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ describe("cleanQuillHtml — inline-style whitelist", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
|
||||||
|
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
|
||||||
|
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
|
||||||
|
"<p>A</p><p><br></p><p>B</p>",
|
||||||
|
);
|
||||||
|
// Attribute-carrying and whitespace-only empties too.
|
||||||
|
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
|
||||||
|
'<p class="ql-align-center"><br></p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
||||||
const out = cleanQuillHtml(
|
const out = cleanQuillHtml(
|
||||||
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatDate,
|
formatDate,
|
||||||
numberOr,
|
numberOr,
|
||||||
|
restoreQuillBlankLines,
|
||||||
todayLocalStr,
|
todayLocalStr,
|
||||||
} from "../utils/formatters";
|
} from "../utils/formatters";
|
||||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
@@ -1670,7 +1671,9 @@ export default function InvoiceDetail() {
|
|||||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||||
<RichTextView
|
<RichTextView
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: DOMPurify.sanitize(s.content || ""),
|
__html: DOMPurify.sanitize(
|
||||||
|
restoreQuillBlankLines(s.content),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1690,7 +1693,9 @@ export default function InvoiceDetail() {
|
|||||||
invoice.internal_notes !== "<p><br></p>" ? (
|
invoice.internal_notes !== "<p><br></p>" ? (
|
||||||
<RichTextView
|
<RichTextView
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: DOMPurify.sanitize(invoice.internal_notes),
|
__html: DOMPurify.sanitize(
|
||||||
|
restoreQuillBlankLines(invoice.internal_notes),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import Typography from "@mui/material/Typography";
|
|||||||
import OrderConfirmationModal from "../components/OrderConfirmationModal";
|
import OrderConfirmationModal from "../components/OrderConfirmationModal";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
restoreQuillBlankLines,
|
||||||
|
} from "../utils/formatters";
|
||||||
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
|
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -713,7 +717,9 @@ export default function OrderDetail() {
|
|||||||
<RichTextView
|
<RichTextView
|
||||||
sx={{ p: 2 }}
|
sx={{ p: 2 }}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: DOMPurify.sanitize(section.content),
|
__html: DOMPurify.sanitize(
|
||||||
|
restoreQuillBlankLines(section.content),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,3 +76,15 @@ export function czechPlural(
|
|||||||
if (n >= 2 && n <= 4) return few;
|
if (n >= 2 && n <= 4) return few;
|
||||||
return many;
|
return many;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
|
||||||
|
* collapse to zero height outside the editor — restore the <br> placeholder
|
||||||
|
* before rendering stored rich text read-only. (The PDF path does the same
|
||||||
|
* in cleanQuillHtml.)
|
||||||
|
*/
|
||||||
|
export function restoreQuillBlankLines(
|
||||||
|
html: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
return (html || "").replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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é") };
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ export function cleanQuillHtml(html: string | null | undefined): string {
|
|||||||
);
|
);
|
||||||
// Replace with regular space (outside of tags)
|
// Replace with regular space (outside of tags)
|
||||||
s = s.replace(/( )/g, " ");
|
s = s.replace(/( )/g, " ");
|
||||||
|
// Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
|
||||||
|
// collapse to zero height in the PDF — restore the <br> placeholder so an
|
||||||
|
// editor blank line stays a blank line.
|
||||||
|
s = s.replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
|
||||||
// Inline styles: Quill writes text/background colors as style attributes.
|
// Inline styles: Quill writes text/background colors as style attributes.
|
||||||
// Keep ONLY color/background-color with literal color values — everything
|
// Keep ONLY color/background-color with literal color values — everything
|
||||||
// else (url(), expression(), positioning…) must never reach Puppeteer.
|
// else (url(), expression(), positioning…) must never reach Puppeteer.
|
||||||
|
|||||||
Reference in New Issue
Block a user