import { FastifyInstance } from "fastify"; import type { Prisma, company_settings } from "@prisma/client"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { nasOffersManager } from "../../services/nas-offers-manager"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { parseId, success } from "../../utils/response"; import { formatDate, formatNum, formatCurrency, escapeHtml, cleanQuillHtml, buildQuillIndentCss, } from "../../utils/pdf-shared"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); interface AddressResult { name: string; lines: string[]; } function buildAddressLines( entity: Record | null, isSupplier: boolean, t: (key: string) => string, ): AddressResult { if (!entity) return { name: "", lines: [] }; const nameKey = isSupplier ? "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; } } } // Legacy PascalCase key compat 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 = `${t("ico")}: ${entity.company_id}`; if (entity.vat_id) fieldMap.vat_id = `${t("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 > 0) { 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 }; } const TRANSLATIONS: Record> = { title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" }, valid_until: { EN: "Valid until", CZ: "Platnost do" }, customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" }, supplier: { EN: "Supplier", CZ: "Dodavatel" }, no: { EN: "N.", CZ: "\u010C." }, description: { EN: "Description", CZ: "Popis" }, qty: { EN: "Qty", CZ: "Mn." }, unit_price: { EN: "Unit Price", CZ: "Jedn. cena" }, included: { EN: "Included", CZ: "Zahrnuto" }, total: { EN: "Total", CZ: "Celkem" }, total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" }, ico: { EN: "ID", CZ: "I\u010CO" }, dic: { EN: "VAT ID", CZ: "DI\u010C" }, page: { EN: "Page", CZ: "Strana" }, of: { EN: "of", CZ: "z" }, }; /** Quotation row with the relations the PDF template renders. */ export type OfferForPdf = Prisma.quotationsGetPayload<{ include: { customers: true; quotation_items: true; scope_sections: true; }; }>; /** * Build the full offer PDF/preview HTML. Extracted from the route handler so * the offers /:id/file live-render fallback (quotations.ts) can reuse the * exact same template without going through HTTP. */ export function renderOfferHtml( quotation: OfferForPdf, settings: company_settings | null, ): string { const isCzech = (quotation.language ?? "EN") !== "EN"; const langKey = isCzech ? "CZ" : "EN"; const currency = quotation.currency || "EUR"; const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key; let logoImg = ""; if (settings?.logo_data) { const buf = Buffer.from(settings.logo_data); 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"; logoImg = ``; } // Offers are NOT tax documents — the total is NET only (no VAT math). const items = quotation.quotation_items; let total = 0; for (const item of items) { if (item.is_included_in_total !== false) { total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); } } // Visibility uses the SAME per-language title rule as the render loop plus // a tag-stripped content check (an empty-Quill "


" doesn't count) // — otherwise a section with only the other language's title would count as // content and produce a scope page holding nothing but the header. Mirrors // issued-orders-pdf. const stripSectionTags = (html: string | null | undefined): string => (html || "") .replace(/<[^>]*>/g, "") .replace(/ /g, " ") .trim(); const resolveSectionTitle = (s: { title: string | null; title_cz: string | null; }): string => isCzech && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || ""; const visibleSections = quotation.scope_sections.filter( (s) => resolveSectionTitle(s).trim() || stripSectionTags(s.content), ); const hasScopeContent = visibleSections.length > 0; const cust = buildAddressLines( quotation.customers as unknown as Record, false, t, ); const supp = buildAddressLines( settings as unknown as Record, true, t, ); const custLinesHtml = cust.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); const suppLinesHtml = supp.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); // Indentation CSS for Quill const indentCSS = buildQuillIndentCss(); let itemsHtml = ""; items.forEach((item, i) => { const qty = Number(item.quantity) || 0; const lineTotal = qty * (Number(item.unit_price) || 0); // Integers render without decimals; fractional quantities keep 2 decimals // (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document). const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const subDesc = item.item_description || ""; const evenClass = i % 2 === 1 ? ' class="even"' : ""; itemsHtml += ` ${i + 1} ${escapeHtml(item.description)}${subDesc ? `
${escapeHtml(subDesc)}
` : ""} ${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""} ${formatCurrency(Number(item.unit_price) || 0, currency)} ${formatCurrency(lineTotal, currency)} `; }); // No Mezisoučet/VAT rows — they would only duplicate the net total. const totalsHtml = `
${escapeHtml(t("total_no_vat"))} ${formatCurrency(total, currency)}
`; const quotationNumber = escapeHtml(quotation.quotation_number); let scopeHtml = ""; if (hasScopeContent) { scopeHtml += '
'; 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 += "
"; } const pageLabel = escapeHtml(t("page")); const ofLabel = escapeHtml(t("of")); const html = ` ${quotationNumber}

${escapeHtml(t("customer"))}
${escapeHtml(cust.name)}
${custLinesHtml}
${escapeHtml(t("supplier"))}
${escapeHtml(supp.name)}
${suppLinesHtml}
${itemsHtml}
${escapeHtml(t("no"))} ${escapeHtml(t("description"))} ${escapeHtml(t("qty"))} ${escapeHtml(t("unit_price"))} ${escapeHtml(t("total"))}
${totalsHtml}
${scopeHtml} `; return html; } export default async function offersPdfRoutes( fastify: FastifyInstance, ): Promise { fastify.get<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("offers.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const query = request.query as Record; try { const quotation = await prisma.quotations.findUnique({ where: { id }, include: { customers: true, quotation_items: { orderBy: { position: "asc" } }, scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] }, }, }); if (!quotation) { return reply .status(404) .type("text/html") .send("

Nabídka nenalezena

"); } const settings = await prisma.company_settings.findFirst(); const html = renderOfferHtml(quotation, settings); const saveMode = query.save === "1"; // Save PDF to NAS if ( saveMode && nasOffersManager.isConfigured() && quotation.quotation_number ) { const created = quotation.created_at ? new Date(quotation.created_at) : new Date(); const pdfBuffer = await htmlToPdf(html); nasOffersManager.saveOfferPdf( quotation.quotation_number!, created.getFullYear(), pdfBuffer, ); return success(reply, null, 200, "PDF uloženo"); } return reply.type("text/html").send(html); } catch (err) { request.log.error(err, "PDF generation failed"); return reply .status(500) .type("text/html") .send( "

Chyba p\u0159i generov\u00E1n\u00ED PDF

", ); } }, ); }