/** * Customer-facing shipping price sheet (Preisliste Versand) — pdfmake, server-side, * LogYou brand (same header/footer as the offer PDFs). * * Input is the fully resolved `PriceSheet` (app/utils/shippingPriceSheet.ts): every * cell already carries the SELL price (Tier 2 → Tier 1 → derived contract price + markup). * This file only lays it out — no pricing logic lives here. * * Structure (2026-10-30: rates first, explanations last): cover (title, merchant, carrier logos, * validity, intro, table of contents) · one rate section per SECTION_ORDER key (national / EU / * non-EU / Warenpost; rows grouped per carrier with its logo on merged sheets) · surcharges · * service overview · notes (the section notes + customer notes + general notes). * `sheet.warnings` is internal and never printed. * Country rows carry a flag (PNG rasterised from public/flags by flagImages.ts, passed in as * `flags` — a missing flag just prints the name). * * pdfmake rules: tables are `headerRows: 1` + `dontBreakRows: true`, NEVER `unbreakable` * (overflowing unbreakable blocks are dropped silently); Roboto has no `→` — use `·`. */ import { BRAND, buildBrandHeader, buildBrandFooter, getQuoteLogos } from '../offers/quotePdf' import { PRICE_SHEET_TEXTS } from './priceSheetTexts' import { loadFlagImages, FLAG_PDF_MAX_WIDTH, type FlagImage } from './flagImages' import { loadCarrierLogos, type CarrierLogo } from './carrierLogos' import { SECTION_ORDER, carrierCodeOf, groupRowsByCarrier, priceAt, priceSheetFilename, type FixtureService, type PriceSheet, type SectionKey, type SheetRow, type SurchargeRow } from '../../../app/utils/shippingPriceSheet' const A4_PORTRAIT_WIDTH = 595.28 const A4_LANDSCAPE_WIDTH = 841.89 const PAGE_MARGINS: [number, number, number, number] = [40, 100, 40, 86] /** Weight columns used for Warenpost rows when the sheet columns carry no ≤ 1 kg steps. */ const WARENPOST_DERIVED_COLUMNS = [0.1, 0.25, 0.5, 0.75, 1] /** Landscape when the sheet has more than this many WEIGHT columns. */ const MAX_PORTRAIT_WEIGHT_COLUMNS = 8 const SUBHEAD_FILL = '#E6EBF1' const GENERAL_SERVICE_SUFFIX = '_GENERAL' type Lang = 'de' | 'en' const formatDateLocal = (iso: string, language: Lang): string => { if (!iso) return '—' const d = new Date(iso) if (isNaN(d.getTime())) return iso const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') return language === 'en' ? `${d.getFullYear()}-${mm}-${dd}` : `${dd}.${mm}.${d.getFullYear()}` } const serviceLabel = (service: FixtureService | undefined, fallback: string, language: Lang): string => { if (!service) return fallback return (language === 'en' ? service.nameEn : service.name) || service.name || fallback } export const buildPriceSheetDocDefinition = ( sheet: PriceSheet, logos: { light: string | null; dark: string | null }, flags: Record = {}, carrierLogos: Record = {} ): any => { const language: Lang = sheet?.options?.language === 'en' ? 'en' : 'de' const T = PRICE_SHEET_TEXTS[language] const locale = language === 'en' ? 'en-IE' : 'de-DE' const euro = (amount: any) => new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(Number(amount) || 0) const num = (n: any, maxFrac = 2) => new Intl.NumberFormat(locale, { maximumFractionDigits: maxFrac }).format(Number(n) || 0) const weightLabel = (w: number) => (w < 1 ? `${num(w * 1000, 0)} g` : `${num(w)} ${T.kg}`) /** Two-line header ("bis" / "31,5 kg") so every weight column header has the same height. */ const columnHeader = (w: number) => `${T.upTo}\n${weightLabel(w)}` const generatedAt = sheet?.generatedAt || new Date().toISOString() const validOn = sheet?.options?.validOn || generatedAt.slice(0, 10) const merchantName = String(sheet?.merchant?.name || sheet?.options?.customerName || '').trim() const subtitle = merchantName || T.standardSheet const carrierName = String(sheet?.shipperName || '').trim() const columns: number[] = (Array.isArray(sheet?.options?.columns) ? sheet.options.columns : []) .map(Number).filter(n => Number.isFinite(n) && n > 0).sort((a, b) => a - b) const servicesByCode = new Map() for (const s of sheet?.services || []) servicesByCode.set(String(s.code), s) // Merged multi-carrier sheet (sheet.carriers > 1): every service / row label carries its carrier ("DHL · Paket"). const multiCarrier = (sheet?.carriers?.length || 0) > 1 // No prefix when the service name already starts with the carrier ("DHL Paket" stays "DHL Paket"). const carrierPrefix = (name: string | undefined, label: string = '') => (multiCarrier && name && !String(label).toLowerCase().startsWith(String(name).toLowerCase()) ? `${name} · ` : '') const serviceName = (code: string, fallback: string) => { const label = serviceLabel(servicesByCode.get(code), fallback, language); return carrierPrefix(servicesByCode.get(code)?.shipperName, label) + label } // Carrier logo (PNG data URL, scaled with `fit`); the text prefix above is only used when no logo exists. const carrierLogoOf = (name: string | null | undefined): CarrierLogo | null => carrierLogos[carrierCodeOf(name, name || '')] || null const LOGO_FIT: [number, number] = [34, 11] const LOGO_FIT_LG: [number, number] = [46, 15] /** `[logo] Name` as a pdfmake columns node (plain text when the carrier has no logo). */ const carrierLabel = (name: string, opts: any = {}, fit: [number, number] = LOGO_FIT_LG) => { const logo = carrierLogoOf(name) if (!logo) return { text: String(name || ''), ...opts } return { columns: [{ width: fit[0], image: logo.dataUrl, fit, margin: [0, 0, 0, 0] }, { width: '*', text: String(name || ''), margin: [5, 1, 0, 0], ...opts }], columnGap: 0 } } /** Service label with the carrier logo in front (merged sheets); text prefix only without a logo. */ const serviceCell = (code: string, fallback: string, opts: any = {}) => { const svc = servicesByCode.get(code) const label = serviceLabel(svc, fallback, language) // Tables are grouped per carrier with a logo heading row → the rows themselves stay plain // (logo only once per group); the text prefix remains for carriers without a logo. if (multiCarrier && carrierLogoOf(svc?.shipperName)) return td(label, opts) return td(carrierPrefix(svc?.shipperName, label) + label, opts) } /** Full-width carrier heading row inside a table (merged sheets only). */ const carrierHeadRow = (name: string, colCount: number) => [ { ...carrierLabel(name, { fontSize: 9, bold: true, color: BRAND.navy }), colSpan: colCount, fillColor: '#F3F6FA', margin: [0, 3, 0, 3] }, ...Array.from({ length: colCount - 1 }, () => ({})) ] /** Empty spacer row between two carrier groups (white, no zebra fill). */ const spacerRow = (colCount: number) => [ { text: ' ', colSpan: colCount, fillColor: BRAND.white, fontSize: 5, margin: [0, 4, 0, 4] }, ...Array.from({ length: colCount - 1 }, () => ({})) ] const sections: Record = (sheet?.sections || {}) as any const rowsOf = (key: SectionKey): SheetRow[] => (Array.isArray(sections?.[key]) ? sections[key] : []) const surcharges: SurchargeRow[] = Array.isArray(sheet?.surcharges) ? sheet.surcharges : [] const serviceEnabled = (code: string) => sheet?.options?.services?.[code] !== false // ---------- shared pdfmake helpers (copied from softwareQuotePdf.ts — local consts there) ---------- const sectionTitle = (text: string, extra: any = {}) => ({ stack: [ { canvas: [{ type: 'rect', x: 0, y: 0, w: 26, h: 3, color: BRAND.orange }] }, { text: String(text), fontSize: 13, bold: true, color: BRAND.navy, characterSpacing: 0.3, margin: [0, 5, 0, 0], ...extra } ], margin: [0, 4, 0, 10] }) const zebraLayout = (padX: number = 8) => ({ hLineWidth: (i: number) => (i === 1 ? 0.75 : 0), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, fillColor: (rowIndex: number) => (rowIndex === 0 ? BRAND.navy : rowIndex % 2 === 0 ? BRAND.zebra : null), paddingLeft: () => padX, paddingRight: () => padX, paddingTop: () => 4, paddingBottom: () => 4 }) const th = (text: string, alignment: string = 'left', extra: any = {}) => ({ text, style: 'tableHeader', alignment, ...extra }) const td = (text: any, opts: any = {}) => ({ text: String(text ?? ''), fontSize: 8, ...opts }) /** Flag + text side by side. The flag column is a FIXED width (not the flag's own width) so the names align. */ const flagOf = (countryCode: any): FlagImage | null => flags[String(countryCode || '').toUpperCase()] || null const withFlag = (countryCode: any, content: any) => { const flag = flagOf(countryCode) if (!flag) return content return { columns: [ { width: FLAG_PDF_MAX_WIDTH, stack: [{ image: flag.dataUrl, width: flag.width, height: flag.height, margin: [0, 0.5, 0, 0] }] }, content ], columnGap: 4 } } const tdPrice = (value: any, opts: any = {}) => typeof value === 'number' ? td(euro(value), { alignment: 'right', ...opts }) : td(value ?? T.noPrice, { alignment: 'right', color: BRAND.grayText, ...opts }) const priceTable = (widths: any[], headers: any[], rows: any[][], padX: number = 8) => ({ table: { headerRows: 1, dontBreakRows: true, widths, body: [headers, ...rows] }, layout: zebraLayout(padX), margin: [0, 0, 0, 8] }) /** Full-width sub-heading row inside a table (service name over a group of countries). */ const subheadRow = (text: string, colCount: number) => [ { text, colSpan: colCount, fontSize: 8.5, bold: true, color: BRAND.navy, fillColor: SUBHEAD_FILL, margin: [0, 1, 0, 1] }, ...Array.from({ length: colCount - 1 }, () => ({})) ] /** Section notes are collected here and printed on the final "Hinweise" page (rates first, explanations last). */ const collectedNotes: string[] = [] const collectNotes = (lines: string[]) => { for (const l of lines) if (l && !collectedNotes.includes(l)) collectedNotes.push(l) } /** "Additional costs & notes" block (kept for the notes page). */ const noteBlock = (lines: string[]) => ({ stack: [ { text: T.additionalCosts, fontSize: 8, bold: true, color: BRAND.orange, margin: [0, 2, 0, 2] }, ...lines.filter(Boolean).map(l => ({ text: l, fontSize: 7.5, color: BRAND.grayText, margin: [0, 0, 0, 2] })) ], margin: [0, 0, 0, 10], unbreakable: true // 2–3 short lines only — keeps the heading with its note (never do this for tables) }) const pageBreak = (landscape: boolean) => ({ text: '', pageBreak: 'before', pageOrientation: landscape ? 'landscape' : 'portrait' }) const needsLandscape = (weightColumns: number) => weightColumns > MAX_PORTRAIT_WEIGHT_COLUMNS // ---------- cell lookups ---------- const cellPrice = (row: SheetRow, weight: number): number | null => { if (Number(row.maxWeight) > 0 && weight > Number(row.maxWeight) + 1e-9) return null const cell = (row.cells || []).find(c => Math.abs(Number(c.weight) - weight) < 1e-9) if (cell && typeof cell.price === 'number') return cell.price if (cell && cell.price === null) return null const base = row.base?.value, perKg = row.perKg?.value if (typeof base === 'number' && typeof perKg === 'number') return priceAt(base, perKg, weight) return null } const rowSurchargeNote = (row: SheetRow): string => (row.surcharges || []).filter(s => s && s.label).map(s => `+ ${s.label} ${euro(s.price)}`).join(' · ') const sortByCountry = (rows: SheetRow[]) => [...rows].sort((a, b) => String(a.countryName || a.countryCode || '').localeCompare(String(b.countryName || b.countryCode || ''), locale) || String(a.serviceCode).localeCompare(String(b.serviceCode))) /** Groups rows per service (in sheet.services order, unknown services last) — only when > 1 service. */ const groupByService = (rows: SheetRow[]): { code: string; name: string; rows: SheetRow[] }[] => { const order = (sheet?.services || []).map(s => String(s.code)) const map = new Map() for (const r of rows) { const k = String(r.serviceCode || '') if (!map.has(k)) map.set(k, []) map.get(k)!.push(r) } const codes = [...map.keys()].sort((a, b) => { const ia = order.indexOf(a), ib = order.indexOf(b) return (ia < 0 ? 9999 : ia) - (ib < 0 ? 9999 : ib) || a.localeCompare(b) }) return codes.map(code => ({ code, name: (multiCarrier && carrierLogoOf(servicesByCode.get(code)?.shipperName)) ? serviceLabel(servicesByCode.get(code), map.get(code)![0]?.serviceName || code, language) : serviceName(code, map.get(code)![0]?.serviceName || code), rows: map.get(code)! })) } // ---------- cover ---------- const coverFacts = [ [td(T.carrier, { color: BRAND.grayText, fontSize: 9 }), (sheet?.carriers?.length ? { columns: sheet.carriers.flatMap((c, i) => [...(i > 0 ? [{ width: 'auto', text: '+', fontSize: 9, color: BRAND.grayText, margin: [6, 1, 6, 0] }] : []), { width: 'auto', ...carrierLabel(c.shipperName, { bold: true, fontSize: 9 }) }]), columnGap: 0 } : carrierLabel(carrierName || '—', { bold: true, fontSize: 9 }))], [td(T.dateWord, { color: BRAND.grayText, fontSize: 9 }), td(formatDateLocal(generatedAt, language), { bold: true, fontSize: 9 })], [td(T.validFrom, { color: BRAND.grayText, fontSize: 9 }), td(formatDateLocal(validOn, language), { bold: true, fontSize: 9 })] ] const cover: any[] = [ { text: sheet?.options?.title || T.docTitle, fontSize: 28, bold: true, color: BRAND.navy, margin: [0, 70, 0, 6] }, { canvas: [{ type: 'rect', x: 0, y: 0, w: 60, h: 4, color: BRAND.orange }], margin: [0, 0, 0, 14] }, { text: subtitle, fontSize: 16, color: BRAND.navySoft, margin: [0, 0, 0, 4] }, { text: T.issuedBy, fontSize: 9, color: BRAND.grayText, margin: [0, 0, 0, 24] }, { table: { widths: [110, '*'], body: coverFacts }, layout: { hLineWidth: () => 0, vLineWidth: () => 0, paddingLeft: () => 0, paddingRight: () => 8, paddingTop: () => 2, paddingBottom: () => 2 }, margin: [0, 0, 0, 22] }, { text: T.intro, fontSize: 9.5, lineHeight: 1.3, color: BRAND.text, margin: [0, 0, 0, 30] }, { toc: { title: { text: T.toc, fontSize: 13, bold: true, color: BRAND.navy, margin: [0, 0, 0, 8] }, textStyle: { fontSize: 10, color: BRAND.text }, numberStyle: { fontSize: 10, bold: true, color: BRAND.navy }, textMargin: [0, 2, 0, 2] } } ] // ---------- service overview ---------- const sectionRowsByService = new Map() for (const key of SECTION_ORDER) for (const r of rowsOf(key)) sectionRowsByService.set(r.serviceCode, (sectionRowsByService.get(r.serviceCode) || 0) + 1) const overviewServices = (sheet?.services || []).filter(s => serviceEnabled(String(s.code)) && (sectionRowsByService.get(String(s.code)) || 0) > 0) const overview: any[] = [] if (overviewServices.length) { overview.push(pageBreak(false)) overview.push(sectionTitle(T.overviewTitle, { tocItem: true })) overview.push(priceTable( [140, 62, 118, 50, '*'], [th(T.overviewService), th(T.overviewScope), th(T.overviewDelivery), th(T.overviewMaxWeight, 'right'), th(T.overviewNote)], overviewServices.map(s => [ serviceCell(String(s.code), serviceLabel(s, s.code, language), { bold: true }), td(s.scope === 'national' ? T.scopeNational : T.scopeInternational), td(s.deliveryDays?.[language] || s.deliveryDays?.de || '—'), td(Number(s.maxWeightKg) > 0 ? `${num(s.maxWeightKg)} ${T.kg}` : '—', { alignment: 'right' }), td(s.note?.[language] || s.note?.de || '', { color: BRAND.grayText, fontSize: 7.5 }) ]) )) } // ---------- price sections ---------- const body: any[] = [] let currentLandscape = false const sectionTitleText: Record = { national: T.sectionNational, eu: T.sectionEu, nonEu: T.sectionNonEu, warenpost: T.sectionWarenpost, surcharges: T.sectionSurcharges } const nationalSection = (rows: SheetRow[]) => { const cols = columns const widths: any[] = [150, ...cols.map(() => '*')] const header = [th(T.colService), ...cols.map(w => th(columnHeader(w), 'right'))] const tableRows: any[][] = [] const groups = groupRowsByCarrier(rows, sheet?.carriers) groups.forEach((g, gi) => { if (multiCarrier && gi > 0) tableRows.push(spacerRow(cols.length + 1)) if (multiCarrier) tableRows.push(carrierHeadRow(g.shipperName, cols.length + 1)) for (const r of g.rows) tableRows.push([ // r.note is INTERNAL (Tier-table / "derived from contract" bookkeeping for the matrix) — never printed: // the sheet goes to merchants and is attached to offers / contracts. serviceCell(r.serviceCode, r.serviceName, { bold: true }), ...cols.map(w => tdPrice(cellPrice(r, w))) ]) }) collectNotes([T.nationalNote]) return { landscape: needsLandscape(cols.length), nodes: [priceTable(widths, header, tableRows, 6)] } } const countrySection = (rows: SheetRow[], cols: number[], extraNote: string) => { const colCount = cols.length + 2 const widths: any[] = [100 + FLAG_PDF_MAX_WIDTH, 40, ...cols.map(() => '*')] const header = [th(T.colCountry), th(T.colZone, 'center'), ...cols.map(w => th(columnHeader(w), 'right'))] const tableRows: any[][] = [] let anyRowSurcharge = false groupRowsByCarrier(rows, sheet?.carriers).forEach((cg, gi) => { if (multiCarrier && gi > 0) tableRows.push(spacerRow(colCount)) if (multiCarrier) tableRows.push(carrierHeadRow(cg.shipperName, colCount)) const groups = groupByService(cg.rows) for (const g of groups) { if (groups.length > 1) tableRows.push(subheadRow(g.name, colCount)) for (const r of sortByCountry(g.rows)) { const note = rowSurchargeNote(r) // customer-facing surcharge hint only; r.note (internal Tier bookkeeping) is never printed if (note) anyRowSurcharge = true tableRows.push([ withFlag(r.countryCode, note ? { stack: [td(r.countryName || r.countryCode, { bold: true }), td(note, { fontSize: 6.5, color: BRAND.grayText })] } : td(r.countryName || r.countryCode, { bold: true })), td(r.zone || '', { alignment: 'center', color: BRAND.grayText }), ...cols.map(w => tdPrice(cellPrice(r, w))) ]) } } }) collectNotes([extraNote, anyRowSurcharge ? T.haustuerNote : '']) return { landscape: needsLandscape(cols.length), nodes: [priceTable(widths, header, tableRows, 5)] } } const surchargeSection = (list: SurchargeRow[]) => { const order = (sheet?.services || []).map(s => String(s.code)) const rank = (code: string) => (code.endsWith(GENERAL_SERVICE_SUFFIX) ? 9998 : order.indexOf(code) < 0 ? 9999 : order.indexOf(code)) const map = new Map() for (const s of list) { const k = String(s.serviceCode || '') if (!map.has(k)) map.set(k, []) map.get(k)!.push(s) } const codes = [...map.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)) const header = [th(T.colSurcharge), th(T.colPrice, 'right'), th(T.colDescription)] const tableRows: any[][] = [] groupRowsByCarrier(list, sheet?.carriers).forEach((cg, gi) => { if (multiCarrier && gi > 0) tableRows.push(spacerRow(3)) if (multiCarrier) tableRows.push(carrierHeadRow(cg.shipperName, 3)) const inGroup = new Set(cg.rows) for (const code of codes) { const group = map.get(code)!.filter(x => inGroup.has(x)) if (!group.length) continue const plain = (fallback: string) => (multiCarrier && carrierLogoOf(servicesByCode.get(code)?.shipperName)) ? serviceLabel(servicesByCode.get(code), fallback, language) : serviceName(code, fallback) const name = plain(group[0]?.serviceName || (code.endsWith(GENERAL_SERVICE_SUFFIX) ? (language === 'en' ? 'General' : 'Allgemein') : code)) if (codes.length > 1) tableRows.push(subheadRow(name, 3)) for (const s of group) { const price = s.isPercentage ? (Number(s.price) > 0 ? `${num(s.price)} ${T.percentOfBase}` : `% ${language === 'en' ? 'of the base price' : 'des Basispreises'}`) : Number(s.price) const label = s.countryCode ? `${s.label} (${s.countryCode})` : s.label tableRows.push([ withFlag(s.countryCode, td(label, { bold: true })), typeof price === 'number' ? tdPrice(price) : td(price, { alignment: 'right' }), td(s.description || '', { color: BRAND.grayText }) ]) } } }) collectNotes([T.surchargeNote]) // label column 25 % narrower (142 instead of 190 pt) — long labels wrap, the description gets the rest return { landscape: false, nodes: [priceTable([142, 80, '*'], header, tableRows, 6)] } } const warenpostColumns = (): number[] => { const own = columns.filter(w => w <= 1 + 1e-9) return own.length >= 2 ? own : [...WARENPOST_DERIVED_COLUMNS] } for (const key of SECTION_ORDER) { let built: { landscape: boolean; nodes: any[] } | null = null if (key === 'surcharges') { if (sheet?.options?.includeSurcharges !== false && surcharges.length) built = surchargeSection(surcharges) } else { const rows = rowsOf(key).filter(r => serviceEnabled(String(r.serviceCode))) if (!rows.length) continue if (key === 'national') built = nationalSection(rows) else if (key === 'warenpost') built = countrySection(rows, warenpostColumns(), T.warenpostNote) else built = countrySection(rows, columns, T.internationalNote) } if (!built) continue body.push(pageBreak(built.landscape)) currentLandscape = built.landscape body.push(sectionTitle(sectionTitleText[key], { tocItem: true })) body.push(...built.nodes) } // ---------- notes ---------- const customerNotes = String(sheet?.options?.notes || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean) const notes: any[] = [ pageBreak(false), sectionTitle(T.sectionNotes, { tocItem: true }), ...(collectedNotes.length ? [noteBlock(collectedNotes)] : []), ...(customerNotes.length ? [ { text: T.customerNotes, fontSize: 9.5, bold: true, color: BRAND.navy, margin: [0, 0, 0, 4] }, { ul: customerNotes.map(l => ({ text: l, fontSize: 9, lineHeight: 1.25, margin: [0, 0, 0, 2] })), margin: [0, 0, 0, 14] } ] : []), { ul: [T.notesNet, T.notesWeight, T.notesVolumetric, T.notesValidity, T.notesChanges, T.notesContact].map(l => ({ text: l, fontSize: 9, lineHeight: 1.25, margin: [0, 0, 0, 3] })) } ] void currentLandscape // Header / footer adapt to the page orientation (landscape country tables). const headerSub = `${subtitle} · ${T.dateWord} ${formatDateLocal(generatedAt, language)}` const headerPortrait = buildBrandHeader(T.docTitle, headerSub, logos.dark, A4_PORTRAIT_WIDTH) const headerLandscape = buildBrandHeader(T.docTitle, headerSub, logos.dark, A4_LANDSCAPE_WIDTH) const footerPortrait = buildBrandFooter(T.page, T.of, A4_PORTRAIT_WIDTH) const footerLandscape = buildBrandFooter(T.page, T.of, A4_LANDSCAPE_WIDTH) const isLandscape = (pageSize: any) => Number(pageSize?.width) > A4_PORTRAIT_WIDTH + 1 return { pageSize: 'A4', pageMargins: PAGE_MARGINS, info: { title: `${T.docTitle} – ${subtitle}`, author: 'LogYou GmbH', subject: T.docTitle }, header: (currentPage: number, pageCount: number, pageSize: any) => (isLandscape(pageSize) ? headerLandscape : headerPortrait)(), footer: (currentPage: number, pageCount: number, pageSize: any) => (isLandscape(pageSize) ? footerLandscape : footerPortrait)(currentPage, pageCount), content: [...cover, ...body, ...overview, ...notes], styles: { tableHeader: { fontSize: 8, bold: true, color: BRAND.white } }, defaultStyle: { font: 'Roboto', fontSize: 9, color: BRAND.text } } } /** Build the final PDF buffer + authoritative filename (never `Angebot_…`). */ export const generatePriceSheetPdf = async (sheet: PriceSheet): Promise<{ buffer: Buffer; filename: string }> => { // Same defensive vfs extraction as generateQuotePdf (export shape varies by bundler). const pdfMake = await import('pdfmake/build/pdfmake.js') const pdfFonts = await import('pdfmake/build/vfs_fonts.js') // @ts-ignore const pdfMakeInstance = pdfMake.default || pdfMake let vfs: any = null if ((pdfFonts as any).pdfMake?.vfs) vfs = (pdfFonts as any).pdfMake.vfs else if ((pdfFonts as any).default?.pdfMake?.vfs) vfs = (pdfFonts as any).default.pdfMake.vfs else if ((pdfFonts as any).default?.vfs) vfs = (pdfFonts as any).default.vfs else if ((pdfFonts as any).vfs) vfs = (pdfFonts as any).vfs else if ((pdfFonts as any).default && Object.keys((pdfFonts as any).default).some((k: string) => k.endsWith('.ttf'))) vfs = (pdfFonts as any).default if (!vfs) throw new Error('Could not find vfs fonts in pdfmake fonts module') pdfMakeInstance.vfs = vfs const logos = await getQuoteLogos() const flagCodes: string[] = [] for (const key of SECTION_ORDER) for (const r of (sheet?.sections as any)?.[key] || []) if (r?.countryCode) flagCodes.push(String(r.countryCode)) for (const s of sheet?.surcharges || []) if (s?.countryCode) flagCodes.push(String(s.countryCode)) const flags = await loadFlagImages(flagCodes) const carrierLogos = await loadCarrierLogos([sheet?.shipperName, ...(sheet?.carriers || []).map(c => c.shipperName)]) const docDefinition = buildPriceSheetDocDefinition(sheet, logos, flags, carrierLogos) const language: Lang = sheet?.options?.language === 'en' ? 'en' : 'de' const filename = priceSheetFilename(language, sheet?.merchant?.name || sheet?.options?.customerName || '', sheet?.generatedAt || new Date().toISOString()) const buffer = await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) return { buffer, filename } }