import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { parseId, success } from "../../utils/response"; import { localDateCzStr } from "../../utils/date"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { nasOrdersManager } from "../../services/nas-financials-manager"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); /* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */ function formatDate(date: Date | string | null | undefined): string { if (!date) return ""; const d = new Date(date); if (isNaN(d.getTime())) return String(date); return localDateCzStr(d); } function formatNum(n: number, decimals = 2): string { const abs = Math.abs(n); const fixed = abs.toFixed(decimals); const [intPart, decPart] = fixed.split("."); const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " "); const result = decPart ? `${withSep},${decPart}` : withSep; return n < 0 ? `-${result}` : result; } function escapeHtml(str: string | null | undefined): string { if (!str) return ""; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function cleanQuillHtml(html: string | null | undefined): string { if (!html) return ""; let s = html; s = s.replace( /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, "", ); s = s.replace( /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, "", ); s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ""); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/( )/g, " "); s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); let prev = ""; while (prev !== s) { prev = s; s = s.replace(/]*)>(.*?)<\/span>\s*/gs, "$2"); } return s; } interface AddressResult { name: string; lines: string[]; } function buildAddressLines( entity: Record | null, isCompany: boolean, tObj: Record, ): 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}`; cfData.forEach((cf, i) => { 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. Unlike customers (which * have structured street/city/postal columns), suppliers.address is a single * Text blob — split it on newlines into one rendered line each (a blob without * newlines renders as one line). IČO/DIČ come from the supplier's ico/dic * columns, prefixed with the same translated labels the customer block used. */ function buildSupplierLines( supplier: Record | null, tObj: Record, ): AddressResult { if (!supplier) return { name: "", lines: [] }; const name = String(supplier.name || ""); const lines: string[] = []; if (supplier.address) { for (const part of String(supplier.address).split(/\r?\n/)) { const line = part.trim(); if (line) lines.push(line); } } if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`); if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`); 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 u Vás:", col_no: "Č.", col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", col_price: "Cena", col_vat_pct: "%DPH", col_vat: "DPH", col_total: "Celkem", subtotal: "Mezisoučet:", vat_label: "DPH", total: "Celkem", amounts_in: "Částky jsou uvedeny v", notes: "Poznámky", delivery_terms: "Dodací podmínky:", payment_terms: "Platební podmínky:", 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_price: "Price", col_vat_pct: "VAT%", col_vat: "VAT", col_total: "Total", subtotal: "Subtotal:", vat_label: "VAT", total: "Total", amounts_in: "Amounts are in", notes: "Notes", delivery_terms: "Delivery terms:", payment_terms: "Payment terms:", 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; apply_vat: boolean | null; vat_rate: unknown; notes: string | null; delivery_terms: string | null; payment_terms: string | null; issued_by: string | null; } interface IssuedOrderPdfItem { description: string | null; item_description: string | null; quantity: unknown; unit: string | null; unit_price: unknown; vat_rate: unknown; } export function renderIssuedOrderHtml( order: IssuedOrderPdfData, items: IssuedOrderPdfItem[], supplier: Record | null, settings: Record | null, lang: Lang, issuer: { name: string }, ): string { const t = translations[lang]; const applyVat = order.apply_vat !== false; const currency = order.currency || "CZK"; const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21; const poNumber = escapeHtml(order.po_number || ""); // Logo embedding (same logic as the confirmation template). let logoImg = ""; if (settings?.logo_data) { const buf = Buffer.from(settings.logo_data as Buffer); let mime = "image/png"; if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; const b64 = buf.toString("base64"); logoImg = ``; } // PO direction: our company (settings) = Odběratel (buyer); // the sklad_suppliers record = Dodavatel (supplier). const buyer = buildAddressLines(settings, true, t); // 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 + per-line VAT-on-top, rounded per line (same math the // service computeIssuedOrderTotals uses; mirrors the confirmation loop). let subtotal = 0; let totalVat = 0; const vatSummary: Record = {}; const itemsHtml = items .map((it, i) => { const qty = Number(it.quantity) || 0; const unitPrice = Number(it.unit_price) || 0; const rate = it.vat_rate != null && it.vat_rate !== "" ? Number(it.vat_rate) : docRate; const lineSubtotal = qty * unitPrice; const lineVat = applyVat ? Math.round(lineSubtotal * (rate / 100) * 100) / 100 : 0; const lineTotal = lineSubtotal + lineVat; subtotal += lineSubtotal; totalVat += lineVat; const key = String(rate); if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; vatSummary[key].base += lineSubtotal; vatSummary[key].vat += lineVat; 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)}` : ""} ${formatNum(unitPrice)} ${formatNum(lineSubtotal)} ${applyVat ? Math.floor(rate) : 0}% ${formatNum(lineVat)} ${formatNum(lineTotal)} `; }) .join(""); subtotal = Math.round(subtotal * 100) / 100; totalVat = Math.round(totalVat * 100) / 100; const totalToPay = Math.round((subtotal + totalVat) * 100) / 100; let vatDetailHtml = ""; if (applyVat) { for (const [rate, data] of Object.entries(vatSummary)) { if (data.vat > 0) { vatDetailHtml += `
${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%: ${formatNum(data.vat)} ${escapeHtml(currency)}
`; } } } const notesRaw = order.notes ?? ""; const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); const notesHtml = notesStripped ? `
${escapeHtml(t.notes)}
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
` : ""; const issueDateStr = formatDate(order.order_date); const deliveryDateStr = formatDate(order.delivery_date); // Order-specific terms (purchase-order addition; not on invoices). let termsHtml = ""; if (order.delivery_terms) { termsHtml += `
${escapeHtml(t.delivery_terms)} ${escapeHtml(order.delivery_terms)}
`; } if (order.payment_terms) { termsHtml += `
${escapeHtml(t.payment_terms)} ${escapeHtml(order.payment_terms)}
`; } const termsBlock = termsHtml ? `
${termsHtml}
` : ""; // Quill indent CSS let indentCSS = ""; for (let n = 1; n <= 9; n++) { const pad = n * 3; const liPad = n * 3 + 1.5; indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; } 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)}
` : ""}
${escapeHtml(t.billing)}
${itemsHtml}
${escapeHtml(t.col_no)} ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} ${escapeHtml(t.col_price)} ${escapeHtml(t.col_vat_pct)} ${escapeHtml(t.col_vat)} ${escapeHtml(t.col_total)}
${escapeHtml(t.subtotal)} ${formatNum(subtotal)} ${escapeHtml(currency)}
${vatDetailHtml}
${escapeHtml(t.total)} ${formatNum(totalToPay)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
${notesHtml} ${termsBlock}
`; } 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; const lang: Lang = query.lang === "en" ? "en" : "cs"; try { const order = await prisma.issued_orders.findUnique({ where: { id } }); if (!order) { return reply .status(404) .type("text/html") .send("

Objednávka nenalezena

"); } const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: id }, orderBy: { position: "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, ); // ?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); nasOrdersManager.saveIssuedPdf( order.po_number, baseDate.getFullYear(), baseDate.getMonth() + 1, pdfBuffer, ); return success(reply, null, 200, "PDF uloženo"); } const pdf = await htmlToPdf(html); 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

"); } }, ); }