Compare commits
8 Commits
0928b3636e
...
v2.4.27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec70599aa | ||
|
|
7e4469b29a | ||
|
|
08af4cd0ae | ||
|
|
eb279a87a9 | ||
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.23",
|
||||
"version": "2.4.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.23",
|
||||
"version": "2.4.27",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.23",
|
||||
"version": "2.4.27",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -189,9 +189,11 @@ 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.";
|
||||
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||||
let fixOfferId = 0;
|
||||
let fixSupplierId = 0;
|
||||
let fixRiId = 0;
|
||||
let fixCustomerId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.quotations.deleteMany({
|
||||
@@ -205,6 +207,21 @@ beforeAll(async () => {
|
||||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||
});
|
||||
fixSupplierId = sup.id;
|
||||
// Customer + two projects for the per-customer aggregation tool.
|
||||
await prisma.projects.deleteMany({
|
||||
where: { name: { startsWith: "AiTest Projekt" } },
|
||||
});
|
||||
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||||
const cust = await prisma.customers.create({
|
||||
data: { name: FIX_CUSTOMER },
|
||||
});
|
||||
fixCustomerId = cust.id;
|
||||
await prisma.projects.createMany({
|
||||
data: [
|
||||
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||||
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||||
],
|
||||
});
|
||||
const ri = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: 6,
|
||||
@@ -265,6 +282,9 @@ afterAll(async () => {
|
||||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||
// Projects before the customer (Restrict FK on projects.customer_id).
|
||||
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||||
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -655,6 +675,18 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||
expect(s).toMatchObject({ ico: "99999998" });
|
||||
|
||||
// IČO typed with spaces finds the space-less stored value.
|
||||
const spacedIco = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: "999 99 998" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(
|
||||
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||||
(x) => x.name === FIX_SUPPLIER,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const veh = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: FIX.spz },
|
||||
@@ -669,6 +701,25 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
// 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 });
|
||||
|
||||
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||||
// in list_vehicles AND in the list_trips vehicle filter.
|
||||
const spaced = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: "aitest 999" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||||
).toBe(FIX.spz);
|
||||
const tripsByPlate = await executeTool(
|
||||
"list_trips",
|
||||
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||
).toBe(false);
|
||||
@@ -714,6 +765,50 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||
const res = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
customers_with_records: number;
|
||||
top: { customer: string; count: number }[];
|
||||
};
|
||||
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine!.count).toBe(2);
|
||||
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||||
const denied = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("find_employee and find_supplier report total_matching", async () => {
|
||||
const emp = await executeTool(
|
||||
"find_employee",
|
||||
{ search: FIX.last },
|
||||
RECORDER,
|
||||
);
|
||||
expect(
|
||||
(emp.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
const sup = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: FIX_SUPPLIER },
|
||||
ADMIN,
|
||||
);
|
||||
expect(
|
||||
(sup.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||
const res = await executeTool(
|
||||
"get_received_invoice_detail",
|
||||
|
||||
55
src/__tests__/pdf-shared.test.ts
Normal file
55
src/__tests__/pdf-shared.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cleanQuillHtml } from "../utils/pdf-shared";
|
||||
|
||||
describe("cleanQuillHtml — inline-style whitelist", () => {
|
||||
it("keeps Quill text and background colors", () => {
|
||||
const html =
|
||||
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
|
||||
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
|
||||
'<span style="color: #06c;">modře</span></p>';
|
||||
const out = cleanQuillHtml(html);
|
||||
expect(out).toContain('style="color: rgb(230, 0, 0)"');
|
||||
expect(out).toContain('style="background-color: #ffff00"');
|
||||
expect(out).toContain('style="color: #06c"');
|
||||
});
|
||||
|
||||
it("drops every non-color style declaration", () => {
|
||||
const out = cleanQuillHtml(
|
||||
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
|
||||
);
|
||||
expect(out).toBe('<span style="color: red">x</span>');
|
||||
});
|
||||
|
||||
it("drops unsafe color values (url, expression, quotes)", () => {
|
||||
expect(
|
||||
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
|
||||
).toBe("<span>x</span>");
|
||||
expect(
|
||||
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
|
||||
).toBe("<span>x</span>");
|
||||
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
|
||||
"<span>x</span>",
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const out = cleanQuillHtml(
|
||||
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||
'<a href="javascript:alert(1)">x</a></p>',
|
||||
);
|
||||
expect(out).not.toContain("script");
|
||||
expect(out).not.toContain("onclick");
|
||||
expect(out).not.toContain("javascript:");
|
||||
});
|
||||
});
|
||||
@@ -214,7 +214,8 @@ function SortableItemRow({
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
@@ -352,7 +353,8 @@ function SortableItemRow({
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
numberOr,
|
||||
restoreQuillBlankLines,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
@@ -317,7 +318,8 @@ function SortableInvoiceRow({
|
||||
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
@@ -418,7 +420,8 @@ function SortableInvoiceRow({
|
||||
}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
@@ -1668,7 +1671,9 @@ export default function InvoiceDetail() {
|
||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(s.content || ""),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(s.content),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1688,7 +1693,9 @@ export default function InvoiceDetail() {
|
||||
invoice.internal_notes !== "<p><br></p>" ? (
|
||||
<RichTextView
|
||||
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 Forbidden from "../components/Forbidden";
|
||||
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 {
|
||||
Button,
|
||||
@@ -713,7 +717,9 @@ export default function OrderDetail() {
|
||||
<RichTextView
|
||||
sx={{ p: 2 }}
|
||||
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;
|
||||
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>");
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
|
||||
const t = translations[lang] || translations.cs;
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
@@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes(
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
|
||||
const t = translations[lang];
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -293,7 +293,7 @@ export function renderOfferHtml(
|
||||
img, table, pre, code { max-width: 100%; }
|
||||
|
||||
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
|
||||
/* ---- Quill alignment ---- */
|
||||
.ql-align-center { text-align: center; }
|
||||
@@ -502,7 +502,7 @@ ${indentCSS}
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
|
||||
@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -94,6 +94,14 @@ const hhmm = (d: Date | null): string | null =>
|
||||
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||||
: null;
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
/** SPZ normalization: users type plates with/without spaces or dashes. */
|
||||
const normPlate = (s: string): string => s.replace(/[\s-]/g, "").toUpperCase();
|
||||
/**
|
||||
* Diacritic-folded lowercase for JS-side matching — parity with the DB's
|
||||
* utf8mb4_unicode_ci contains ("dodavka" must match "Dodávka").
|
||||
*/
|
||||
const normText = (s: string): string =>
|
||||
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||
|
||||
/** Quill/HTML rich text → compact plain text for model context. */
|
||||
const stripHtml = (html: string | null | undefined): string =>
|
||||
@@ -472,7 +480,7 @@ const TOOLS: ToolSpec[] = [
|
||||
definition: {
|
||||
name: "list_customers",
|
||||
description:
|
||||
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících.",
|
||||
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících; total_matching = CELKOVÝ počet zákazníků odpovídajících filtru (bez search = všichni).",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -485,12 +493,13 @@ const TOOLS: ToolSpec[] = [
|
||||
},
|
||||
handler: async (input) => {
|
||||
// Mirrors the customers route's read (search on name/company_id).
|
||||
// IČO is stored without spaces but users type "123 45 678".
|
||||
const search = str(input.search);
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
{ company_id: { contains: search.replace(/\s+/g, "") } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
@@ -554,26 +563,32 @@ const TOOLS: ToolSpec[] = [
|
||||
}
|
||||
// Stock math mirrors the service's total-stock rule: sum of
|
||||
// unconsumed batch quantities.
|
||||
const rows = await prisma.sklad_items.findMany({
|
||||
where: {
|
||||
is_active: true,
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ item_number: { contains: search } },
|
||||
],
|
||||
},
|
||||
take: 20,
|
||||
select: {
|
||||
item_number: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
batches: {
|
||||
where: { is_consumed: false },
|
||||
select: { quantity: true },
|
||||
const where = {
|
||||
is_active: true,
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ item_number: { contains: search } },
|
||||
],
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.sklad_items.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
take: 20,
|
||||
select: {
|
||||
item_number: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
batches: {
|
||||
where: { is_consumed: false },
|
||||
select: { quantity: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.sklad_items.count({ where }),
|
||||
]);
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: rows.length,
|
||||
items: rows.map((r) => ({
|
||||
item_number: r.item_number,
|
||||
@@ -789,20 +804,24 @@ const TOOLS: ToolSpec[] = [
|
||||
if (!fullDirectory && populations.length === 0) {
|
||||
return { error: DENIED("seznam zaměstnanců") };
|
||||
}
|
||||
const rows = await prisma.users.findMany({
|
||||
where: fullDirectory
|
||||
? { OR: or }
|
||||
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] },
|
||||
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
username: true,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
const where = fullDirectory
|
||||
? { OR: or }
|
||||
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.users.findMany({
|
||||
where,
|
||||
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
username: true,
|
||||
is_active: true,
|
||||
},
|
||||
}),
|
||||
prisma.users.count({ where }),
|
||||
]);
|
||||
// GET /trips/users deliberately omits usernames (login identifiers);
|
||||
// only the attendance picker and the users admin expose them.
|
||||
const showUsername =
|
||||
@@ -810,6 +829,7 @@ const TOOLS: ToolSpec[] = [
|
||||
ctxCan(ctx, "attendance.record") ||
|
||||
ctxCan(ctx, "attendance.manage");
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: rows.length,
|
||||
employees: rows.map((u) => ({
|
||||
user_id: u.id,
|
||||
@@ -883,14 +903,28 @@ const TOOLS: ToolSpec[] = [
|
||||
}
|
||||
const vehicle = str(input.vehicle);
|
||||
if (vehicle) {
|
||||
where.vehicles = {
|
||||
is: {
|
||||
OR: [
|
||||
{ name: { contains: vehicle } },
|
||||
{ spz: { contains: vehicle } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Resolve in JS so SPZ spacing is normalized on both sides
|
||||
// ("4SY7039" must match the stored "4SY 7039") — SQL contains is
|
||||
// literal. The vehicles table is tiny.
|
||||
const allVehicles = await prisma.vehicles.findMany({
|
||||
select: { id: true, name: true, spz: true },
|
||||
});
|
||||
const ids = allVehicles
|
||||
.filter(
|
||||
(v) =>
|
||||
normText(v.name).includes(normText(vehicle)) ||
|
||||
normPlate(v.spz).includes(normPlate(vehicle)),
|
||||
)
|
||||
.map((v) => v.id);
|
||||
if (ids.length === 0) {
|
||||
return {
|
||||
total_matching: 0,
|
||||
shown: 0,
|
||||
trips: [],
|
||||
note: "Žádné vozidlo neodpovídá hledání (zkontroluj název nebo SPZ).",
|
||||
};
|
||||
}
|
||||
where.vehicle_id = { in: ids };
|
||||
}
|
||||
|
||||
const [rows, all] = await Promise.all([
|
||||
@@ -1531,29 +1565,36 @@ const TOOLS: ToolSpec[] = [
|
||||
handler: async (input, ctx) => {
|
||||
const search = str(input.search);
|
||||
if (!search) return { error: "Zadej název, IČO nebo město dodavatele." };
|
||||
// IČO is stored without spaces but users type "123 45 678".
|
||||
const icoDigits = search.replace(/\s+/g, "");
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
const where = {
|
||||
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ ico: { contains: icoDigits } },
|
||||
{ city: { contains: search } },
|
||||
],
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.sklad_suppliers.findMany({
|
||||
where,
|
||||
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
name: true,
|
||||
city: true,
|
||||
ico: true,
|
||||
dic: true,
|
||||
is_active: true,
|
||||
},
|
||||
}),
|
||||
prisma.sklad_suppliers.count({ where }),
|
||||
]);
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: rows.length,
|
||||
suppliers: rows.map((s) => ({
|
||||
name: s.name,
|
||||
@@ -1589,17 +1630,12 @@ const TOOLS: ToolSpec[] = [
|
||||
},
|
||||
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([
|
||||
// The vehicles table is tiny — fetch all and match in JS so SPZ
|
||||
// spacing can be normalized on BOTH sides ("4SY7039" must find the
|
||||
// stored "4SY 7039" and vice versa); a SQL contains is literal.
|
||||
const [allVehicles, tripStats] = await Promise.all([
|
||||
prisma.vehicles.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -1610,13 +1646,21 @@ const TOOLS: ToolSpec[] = [
|
||||
is_active: true,
|
||||
},
|
||||
}),
|
||||
prisma.vehicles.count({ where }),
|
||||
prisma.trips.groupBy({
|
||||
by: ["vehicle_id"],
|
||||
_max: { end_km: true },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
const matched = search
|
||||
? allVehicles.filter(
|
||||
(v) =>
|
||||
normText(v.name).includes(normText(search)) ||
|
||||
normPlate(v.spz).includes(normPlate(search)),
|
||||
)
|
||||
: allVehicles;
|
||||
const total = matched.length;
|
||||
const vehicles = matched.slice(0, 20);
|
||||
const statsMap = new Map(
|
||||
tripStats.map((s) => [
|
||||
s.vehicle_id,
|
||||
@@ -1787,6 +1831,121 @@ const TOOLS: ToolSpec[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// Coarse gate any-of; the handler re-checks the exact permission per
|
||||
// aggregation source. Counts only — no amounts beyond what the lists
|
||||
// already expose to the same permission.
|
||||
permission: ["invoices.view", "orders.view", "projects.view"],
|
||||
definition: {
|
||||
name: "get_top_customers",
|
||||
description:
|
||||
"Agregace podle ZÁKAZNÍKA přes celou databázi: počet vydaných faktur / přijatých objednávek / projektů na zákazníka (TOP 20 + počet zákazníků se záznamem). Volej na dotazy 'pro koho pracujeme nejvíc', 'největší zákazník'. Celkový počet zákazníků zjistíš z list_customers (total_matching).",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["invoices", "orders", "projects"],
|
||||
description: "Podle čeho agregovat",
|
||||
},
|
||||
year: {
|
||||
type: "number",
|
||||
description: "Jen daný rok (vynech pro celou historii)",
|
||||
},
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
},
|
||||
handler: async (input, ctx) => {
|
||||
const type = str(input.type);
|
||||
const year = num(input.year);
|
||||
// Year ranges as UTC midnights (issue_date is @db.Date; created_at
|
||||
// DateTime comparisons are unaffected by the choice).
|
||||
const range = year
|
||||
? {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
}
|
||||
: undefined;
|
||||
// Normalized per branch — Prisma's groupBy generics don't survive a
|
||||
// shared pre-declared variable type.
|
||||
let groups: { customer_id: number | null; count: number }[];
|
||||
let note: string;
|
||||
if (type === "invoices") {
|
||||
if (!ctxCan(ctx, "invoices.view")) return { error: DENIED("faktury") };
|
||||
const g = await prisma.invoices.groupBy({
|
||||
by: ["customer_id"],
|
||||
where: {
|
||||
status: { not: "draft" },
|
||||
...(range ? { issue_date: range } : {}),
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
groups = g.map((x) => ({
|
||||
customer_id: x.customer_id,
|
||||
count: x._count._all,
|
||||
}));
|
||||
note = "Počty vydaných faktur (bez konceptů).";
|
||||
} else if (type === "orders") {
|
||||
if (!ctxCan(ctx, "orders.view")) {
|
||||
return { error: DENIED("objednávky") };
|
||||
}
|
||||
const g = await prisma.orders.groupBy({
|
||||
by: ["customer_id"],
|
||||
where: range ? { created_at: range } : {},
|
||||
_count: { _all: true },
|
||||
});
|
||||
groups = g.map((x) => ({
|
||||
customer_id: x.customer_id,
|
||||
count: x._count._all,
|
||||
}));
|
||||
note = "Počty přijatých objednávek (všechny stavy).";
|
||||
} else if (type === "projects") {
|
||||
if (!ctxCan(ctx, "projects.view")) {
|
||||
return { error: DENIED("projekty") };
|
||||
}
|
||||
const g = await prisma.projects.groupBy({
|
||||
by: ["customer_id"],
|
||||
where: range ? { created_at: range } : {},
|
||||
_count: { _all: true },
|
||||
});
|
||||
groups = g.map((x) => ({
|
||||
customer_id: x.customer_id,
|
||||
count: x._count._all,
|
||||
}));
|
||||
note = "Počty projektů (všechny stavy).";
|
||||
} else {
|
||||
return { error: "Neznámý typ agregace." };
|
||||
}
|
||||
const assigned = groups
|
||||
.filter((g) => g.customer_id != null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.count - a.count || (a.customer_id ?? 0) - (b.customer_id ?? 0),
|
||||
);
|
||||
const unassigned = groups.find((g) => g.customer_id == null)?.count ?? 0;
|
||||
const top = assigned.slice(0, 20);
|
||||
const names = await prisma.customers.findMany({
|
||||
where: { id: { in: top.map((g) => g.customer_id as number) } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameById = new Map(names.map((c) => [c.id, c.name]));
|
||||
return {
|
||||
type,
|
||||
...(year ? { year } : {}),
|
||||
customers_with_records: assigned.length,
|
||||
shown: top.length,
|
||||
top: top.map((g) => ({
|
||||
customer:
|
||||
nameById.get(g.customer_id as number) ??
|
||||
`Zákazník #${g.customer_id}`,
|
||||
count: g.count,
|
||||
})),
|
||||
...(unassigned ? { records_without_customer: unassigned } : {}),
|
||||
note,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Tool definitions the caller may use — filtered by their permissions. */
|
||||
@@ -1861,4 +2020,5 @@ export const TOOL_LABELS: Record<string, string> = {
|
||||
list_vehicles: "Vozidla",
|
||||
get_document_totals: "Součty dokumentů",
|
||||
get_received_invoice_detail: "Detail přijaté faktury",
|
||||
get_top_customers: "TOP zákazníci",
|
||||
};
|
||||
|
||||
@@ -291,6 +291,7 @@ function agentSystemPrompt(
|
||||
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
||||
(tools.length > 0
|
||||
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
||||
"Seznamové nástroje zobrazují max ~20 řádků, ale total_matching je CELKOVÝ počet odpovídajících záznamů — na otázky 'kolik' odpovídej z total_matching. Na 'pro koho nejvíc' a součty používej agregační nástroje (get_top_customers, get_document_totals, get_invoice_stats) — nikdy nesčítej řádky ze seznamů. " +
|
||||
(hasFindEmployee
|
||||
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
||||
: "") +
|
||||
|
||||
@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
|
||||
logoDataUri: string,
|
||||
heading: string,
|
||||
): string {
|
||||
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||
return `<div style="width:100%; font-family:'Segoe UI', Tahoma, Arial, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
||||
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
||||
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
||||
@@ -108,6 +108,13 @@ export function buildPdfHeaderTemplate(
|
||||
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
||||
* (regex pass = defense-in-depth, not the primary sanitizer).
|
||||
*/
|
||||
/**
|
||||
* Literal CSS color values only: #hex, rgb()/rgba() with numeric components,
|
||||
* or a bare keyword. No quotes, parentheses beyond rgb(a), url(), variables.
|
||||
*/
|
||||
const SAFE_CSS_COLOR =
|
||||
/^(#[0-9a-f]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)|[a-z]{3,20})$/i;
|
||||
|
||||
export function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
@@ -134,7 +141,32 @@ export function cleanQuillHtml(html: string | null | undefined): string {
|
||||
);
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// 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.
|
||||
// Keep ONLY color/background-color with literal color values — everything
|
||||
// else (url(), expression(), positioning…) must never reach Puppeteer.
|
||||
s = s.replace(
|
||||
/\s+style\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
|
||||
(_m, dq, sq, bare) => {
|
||||
const kept = String(dq ?? sq ?? bare ?? "")
|
||||
.split(";")
|
||||
.map((decl) => {
|
||||
const i = decl.indexOf(":");
|
||||
if (i < 0) return null;
|
||||
const prop = decl.slice(0, i).trim().toLowerCase();
|
||||
const val = decl.slice(i + 1).trim();
|
||||
return (prop === "color" || prop === "background-color") &&
|
||||
SAFE_CSS_COLOR.test(val)
|
||||
? `${prop}: ${val}`
|
||||
: null;
|
||||
})
|
||||
.filter((d): d is string => d !== null);
|
||||
return kept.length ? ` style="${kept.join("; ")}"` : "";
|
||||
},
|
||||
);
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
|
||||
Reference in New Issue
Block a user