import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { parseId, success } from "../../utils/response"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { nasOrdersManager } from "../../services/nas-financials-manager"; import { parseSelectedCustomFields } from "../../utils/custom-fields"; import { formatDate, formatNum, formatCurrency, escapeHtml, cleanQuillHtml, buildQuillIndentCss, logoDataUriFromSettings, buildPdfHeaderTemplate, } from "../../utils/pdf-shared"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); /* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */ interface AddressResult { name: string; lines: string[]; } function buildAddressLines( entity: Record | null, isCompany: boolean, tObj: Record, selectedCustomFields?: number[], ): AddressResult { if (!entity) return { name: "", lines: [] }; const nameKey = isCompany ? "company_name" : "name"; const name = String(entity[nameKey] || ""); let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = []; let fieldOrder: string[] | null = null; const raw = entity.custom_fields; if (raw) { let parsed: unknown; try { parsed = typeof raw === "string" ? JSON.parse(raw) : raw; } catch { parsed = null; } if (parsed && typeof parsed === "object") { if ((parsed as Record).fields) { cfData = ((parsed as Record).fields as typeof cfData) || []; fieldOrder = ((parsed as Record).field_order || (parsed as Record).fieldOrder) as string[] | null; } else if (Array.isArray(parsed)) { cfData = parsed; } } } if (Array.isArray(fieldOrder)) { const legacyMap: Record = { Name: "name", CompanyName: "company_name", Street: "street", CityPostal: "city_postal", Country: "country", CompanyId: "company_id", VatId: "vat_id", }; fieldOrder = fieldOrder.map((k) => legacyMap[k] || k); } const fieldMap: Record = {}; if (name) fieldMap[nameKey] = name; if (entity.street) fieldMap.street = String(entity.street); const cityParts = [entity.city || "", entity.postal_code || ""] .filter(Boolean) .map(String); const cityPostal = cityParts.join(" ").trim(); if (cityPostal) fieldMap.city_postal = cityPostal; if (entity.country) fieldMap.country = String(entity.country); if (entity.company_id) fieldMap.company_id = `${tObj.ico}${entity.company_id}`; if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`; const filterCustom = Array.isArray(selectedCustomFields); cfData.forEach((cf, i) => { if (filterCustom && !selectedCustomFields!.includes(i)) return; const cfName = (cf.name || "").trim(); const cfValue = (cf.value || "").trim(); const showLabel = cf.showLabel !== false; if (cfValue) { fieldMap[`custom_${i}`] = showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue; } }); const lines: string[] = []; if (Array.isArray(fieldOrder) && fieldOrder.length) { for (const key of fieldOrder) { if (key === nameKey) continue; if (fieldMap[key]) lines.push(fieldMap[key]); } for (const [key, line] of Object.entries(fieldMap)) { if (key === nameKey) continue; if (!fieldOrder!.includes(key)) lines.push(line); } } else { for (const [key, line] of Object.entries(fieldMap)) { if (key === nameKey) continue; lines.push(line); } } return { name, lines }; } /** * Address block for the sklad_suppliers counterparty — full customer model * (structured street/city/postal_code/country + custom_fields JSON; the old * dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from * the supplier's ico/dic columns, prefixed with the same translated labels * the customer block uses; custom fields render as appended lines in array * order (suppliers have no PDF field-order picker). */ function buildSupplierLines( supplier: Record | null, tObj: Record, ): AddressResult { if (!supplier) return { name: "", lines: [] }; const name = String(supplier.name || ""); const lines: string[] = []; if (supplier.street) lines.push(String(supplier.street)); const cityLine = [supplier.postal_code, supplier.city] .filter(Boolean) .map(String) .join(" ") .trim(); if (cityLine) lines.push(cityLine); if (supplier.country) lines.push(String(supplier.country)); if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`); if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`); // Custom fields ("Vlastní pole") — same blob format as customers; malformed // JSON degrades to no extra lines rather than failing the PDF. if (supplier.custom_fields) { let parsed: unknown; try { parsed = typeof supplier.custom_fields === "string" ? JSON.parse(supplier.custom_fields) : supplier.custom_fields; } catch { parsed = null; } const fields = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? ((parsed as Record).fields as Array<{ name?: string; value?: string; showLabel?: boolean; }>) : Array.isArray(parsed) ? (parsed as Array<{ name?: string; value?: string; showLabel?: boolean; }>) : []; for (const f of fields ?? []) { const value = (f?.value || "").trim(); if (!value) continue; const label = (f?.name || "").trim(); lines.push( f?.showLabel !== false && label ? `${label}: ${value}` : value, ); } } return { name, lines }; } /* ── Translations ────────────────────────────────────────────────── */ type Lang = "cs" | "en"; const translations: Record> = { cs: { title: "OBJEDNÁVKA", heading: "OBJEDNÁVKA č.", // PO direction: WE (company) are the buyer (Odběratel), the // selected sklad_suppliers record is the supplier (Dodavatel). supplier: "Dodavatel", buyer: "Odběratel", issue_date: "Datum vystavení:", delivery_date: "Požadované dodání:", billing: "Objednáváme si u Vás:", col_no: "Č.", col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", col_total: "Celkem", total_no_vat: "Celkem bez DPH", issued_by: "Vystavil:", ico: "IČ: ", dic: "DIČ: ", }, en: { title: "PURCHASE ORDER", heading: "PURCHASE ORDER No.", supplier: "Supplier", buyer: "Buyer", issue_date: "Issue date:", delivery_date: "Requested delivery:", billing: "We order from you:", col_no: "No.", col_desc: "Description", col_qty: "Quantity", col_unit_price: "Unit price", col_total: "Total", total_no_vat: "Total excl. VAT", issued_by: "Issued by:", ico: "Reg. No.: ", dic: "Tax ID: ", }, }; interface IssuedOrderPdfData { po_number: string | null; order_date: Date | null; delivery_date: Date | null; currency: string | null; // Editable heading above the items table; null → t.billing default. order_text?: string | null; } interface IssuedOrderPdfItem { description: string | null; item_description: string | null; quantity: unknown; unit: string | null; unit_price: unknown; } export interface IssuedOrderPdfSection { title: string | null; title_cz: string | null; content: string | null; } /** Tag-stripped emptiness check — "


" (empty Quill) counts as empty. */ function stripTags(html: string | null | undefined): string { return (html || "").replace(/<[^>]*>/g, "").trim(); } /** * Repeating per-page footer for the issued-order PDF, rendered by Puppeteer * in the bottom page margin (the only true repeating footer Chromium * supports): "Vystavil: " left, "Strana X z Y" / "Page X of Y" * (by document language) centered. Styles must stay inline and the * font-size explicit — Chromium defaults footer text to 0. */ export function buildIssuedOrderPdfFooter( lang: Lang, issuerName: string, ): string { const t = translations[lang]; const pageWord = lang === "cs" ? "Strana" : "Page"; const ofWord = lang === "cs" ? "z" : "of"; return `
${escapeHtml(t.issued_by)} ${escapeHtml(issuerName)}
${pageWord} ${ofWord}
`; } /** * Repeating per-page header for the issued-order PDF — the unified * red-accent family header (shared with invoices): logo left, red * "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's * own header is print-hidden so page 1 doesn't show it twice. */ export function buildIssuedOrderPdfHeader( lang: Lang, logoDataUri: string, poNumber: string, ): string { const t = translations[lang]; return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`); } export function renderIssuedOrderHtml( order: IssuedOrderPdfData, items: IssuedOrderPdfItem[], supplier: Record | null, settings: Record | null, lang: Lang, // Kept for signature stability across the many call sites: the issuer no // longer renders in the BODY — it prints in the Puppeteer footer template // (buildIssuedOrderPdfFooter) on every page. _issuer: { name: string }, sections: IssuedOrderPdfSection[] = [], ): string { const t = translations[lang]; const currency = order.currency || "CZK"; const poNumber = escapeHtml(order.po_number || ""); // Logo embedding — shared helper (also feeds the Puppeteer headerTemplate). const logoDataUri = logoDataUriFromSettings(settings); const logoImg = logoDataUri ? `` : ""; // PO direction: our company (settings) = Odběratel (buyer); // the sklad_suppliers record = Dodavatel (supplier). const buyer = buildAddressLines( settings, true, t, parseSelectedCustomFields( (order as { selected_custom_fields?: unknown }).selected_custom_fields, ), ); // company → Odběratel const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel const buyerLinesHtml = buyer.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); const supplierLinesHtml = supplierAddr.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); // Items — NET only. A purchase order is not a tax document: no VAT columns, // no VAT math (same as the service computeIssuedOrderTotals). let total = 0; const itemsHtml = items .map((it, i) => { const qty = Number(it.quantity) || 0; const unitPrice = Number(it.unit_price) || 0; const lineTotal = qty * unitPrice; total += lineTotal; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const descHtml = `${escapeHtml(it.description)}${ it.item_description ? `
${escapeHtml(it.item_description)}
` : "" }`; return ` ${i + 1} ${descHtml} ${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""} ${formatCurrency(unitPrice, currency)} ${formatCurrency(lineTotal, currency)} `; }) .join(""); total = Math.round(total * 100) / 100; const issueDateStr = formatDate(order.order_date); const deliveryDateStr = formatDate(order.delivery_date); // Quill indent CSS const indentCSS = buildQuillIndentCss(); // ── Sections ("Obsah") — flow INLINE after the items/totals block (user // decision: no separate page, no repeated header). Each section keeps // break-inside: avoid; long content paginates naturally and the Puppeteer // footer template stamps every page. Suppressed entirely when every // section is empty (tag-stripped check, so an empty-Quill "


" // doesn't count). cs prefers the Czech title when present; en (or a // missing Czech title) falls back to the EN title (same rule as offers). const resolveSectionTitle = (s: IssuedOrderPdfSection): string => lang === "cs" && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || ""; // Visibility must use the SAME per-language title rule as the render loop — // otherwise a section with only the other language's title would count as // content and produce an empty sections block. const visibleSections = sections.filter( (s) => resolveSectionTitle(s).trim() || stripTags(s.content), ); const hasSectionContent = visibleSections.length > 0; let scopeHtml = ""; if (hasSectionContent) { scopeHtml += '
'; for (const section of visibleSections) { const title = resolveSectionTitle(section); const content = (section.content || "").trim(); scopeHtml += '
'; if (title.trim()) scopeHtml += `
${escapeHtml(title)}
`; if (content) scopeHtml += `
${cleanQuillHtml(DOMPurify.sanitize(content))}
`; scopeHtml += "
"; } scopeHtml += "
"; } return ` ${escapeHtml(t.heading)} ${poNumber}
${logoImg ? `
${logoImg}
` : ""}
${escapeHtml(t.heading)} ${poNumber}
${escapeHtml(t.buyer)}
${escapeHtml(buyer.name)}
${buyerLinesHtml}
${escapeHtml(t.supplier)}
${escapeHtml(supplierAddr.name)}
${supplierLinesHtml}
${escapeHtml(t.issue_date)} ${escapeHtml(issueDateStr) || "—"}
${deliveryDateStr ? `
${escapeHtml(t.delivery_date)} ${escapeHtml(deliveryDateStr)}
` : ""}
${ items.length > 0 ? `
${escapeHtml(order.order_text || t.billing)}
${itemsHtml}
${escapeHtml(t.col_no)} ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} ${escapeHtml(t.col_total)}
${escapeHtml(t.total_no_vat)} ${formatCurrency(total, currency)}
` : "" } ${scopeHtml}
`; } export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) { fastify.get<{ Params: { id: string } }>( "/:id", // Mirror invoices-pdf (invoices.view): view-level access serves BOTH the // user-facing Export PDF and the ?save=1 NAS archive. There is no separate // orders.export permission (it was dead — gated nothing — and was removed); // orders.view is the single gate for both paths. { preHandler: requirePermission("orders.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const query = request.query as Record; try { const order = await prisma.issued_orders.findUnique({ where: { id } }); if (!order) { return reply .status(404) .type("text/html") .send("

Objednávka nenalezena

"); } // Language comes from the document's own column; ?lang= is an // explicit override only (mirrors the /:id/file fallback semantics). const lang: Lang = query.lang === "en" || query.lang === "cs" ? query.lang : order.language === "en" ? "en" : "cs"; const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: id }, orderBy: { position: "asc" }, }); // id tiebreak: position can repeat, keep the order deterministic. const sections = await prisma.issued_order_sections.findMany({ where: { issued_order_id: id }, orderBy: [{ position: "asc" }, { id: "asc" }], }); const supplier = order.supplier_id ? ((await prisma.sklad_suppliers.findUnique({ where: { id: order.supplier_id }, })) as Record | null) : null; const settings = (await prisma.company_settings.findFirst()) as Record< string, unknown > | null; // Footer issuer = the logged-in user (there is no user FK on the // order). The route runs under requirePermission("orders.view") so // authData is always present here. Mirrors orders-pdf.ts userName. const issuer = { name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(), }; const html = renderIssuedOrderHtml( order, items, supplier, settings, lang, issuer, sections, ); // ?save=1 → archive the PDF to NAS instead of returning it (mirrors // invoices-pdf). A draft has no po_number → the guard skips it. const saveMode = query.save === "1"; if (saveMode && nasOrdersManager.isConfigured() && order.po_number) { const baseDate = order.order_date ? new Date(order.order_date) : new Date(); nasOrdersManager.cleanIssued(order.po_number); const pdfBuffer = await htmlToPdf(html, { headerTemplate: buildIssuedOrderPdfHeader( lang, logoDataUriFromSettings(settings), order.po_number || "", ), footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), }); nasOrdersManager.saveIssuedPdf( order.po_number, baseDate.getFullYear(), baseDate.getMonth() + 1, pdfBuffer, ); return success(reply, null, 200, "PDF uloženo"); } const pdf = await htmlToPdf(html, { headerTemplate: buildIssuedOrderPdfHeader( lang, logoDataUriFromSettings(settings), order.po_number || "", ), footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), }); const filename = `Objednavka-${order.po_number || String(id)}.pdf`; return reply .type("application/pdf") .header("Content-Disposition", `attachment; filename="${filename}"`) .send(pdf); } catch (err) { request.log.error(err, "Issued order PDF generation failed"); return reply .status(500) .type("text/html") .send("

Chyba při generování PDF

"); } }, ); }