/** * Custom forecast ("Individuelle Prognose") PDF — pdfmake, server-side, same * brand look as the quote (header/footer/colours reused from quotePdf.ts). * * Pure renderer: labels, calculation texts and every amount arrive pre-computed * in the payload (app/composables/useOfferForecast.ts → buildForecastPayload), * so the PDF always matches what the user saw in the modal. * * Up to 2 volume scenarios print on A4 portrait; 3–4 switch to landscape so the * amount columns stay readable. */ import { BRAND, buildBrandHeader, buildBrandFooter, getQuoteLogos } from './quotePdf' const A4_PORTRAIT_WIDTH = 595.28 const A4_LANDSCAPE_WIDTH = 841.89 export const FORECAST_TEXTS: any = { de: { docTitle: 'Individuelle Kostenprognose', dateWord: 'Prognose vom', date: 'Datum', basis: 'Grundlage', basisValue: 'Unverbindliche Beispielrechnung', intro: 'Auf Basis der mit Ihnen besprochenen Mengen haben wir Ihre voraussichtlichen monatlichen Kosten für das Fulfillment bei LogYou kalkuliert.', tableTitle: 'Ihre voraussichtlichen monatlichen Kosten', position: 'Position', calculation: 'Berechnungsgrundlage', variableTotal: 'Zwischensumme variable Kosten (mengenabhängig)', fixedTotal: 'Zwischensumme fixe monatliche Kosten', total: 'Gesamt pro Monat', perOrder: 'Gesamtkosten pro Auftrag', yearly: 'Gesamt pro Jahr (12 Monate)', notesTitle: 'Hinweise', disclaimer: 'Unverbindliche, beispielhafte Kalkulation auf Basis der genannten Annahmen — kein Vertragsbestandteil. Abgerechnet wird ausschließlich nach tatsächlichem Verbrauch. Alle Preise verstehen sich netto zzgl. 19 % MwSt.', closing: 'Wir freuen uns auf die Zusammenarbeit!', closingTeam: 'Ihr LogYou Team', page: 'Seite', of: 'von' }, en: { docTitle: 'Individual cost forecast', dateWord: 'Forecast of', date: 'Date', basis: 'Basis', basisValue: 'Non-binding example calculation', intro: 'Based on the volumes we discussed, we have calculated your expected monthly costs for fulfillment with LogYou.', tableTitle: 'Your expected monthly costs', position: 'Position', calculation: 'Calculation basis', variableTotal: 'Subtotal variable costs (volume-dependent)', fixedTotal: 'Subtotal fixed monthly costs', total: 'Total per month', perOrder: 'Total cost per order', yearly: 'Total per year (12 months)', notesTitle: 'Notes', disclaimer: 'Non-binding example calculation based on the stated assumptions — not part of the contract. Billing is based solely on actual consumption. All prices are net plus 19 % German VAT.', closing: 'We look forward to working with you!', closingTeam: 'Your LogYou team', page: 'Page', of: 'of' } } // Authoritative filename — never trust the client's. export const sanitizeForecastFilename = (company: string, date?: string): string => { const safe = String(company || 'Kunde') .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') .replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue') .replace(/ß/g, 'ss') .replace(/[^A-Za-z0-9-_ ]/g, '') .trim() .replace(/\s+/g, '-') || 'Kunde' const d = date || new Date().toISOString().slice(0, 10) return `Prognose_LogYou_${safe}_${d}.pdf` } const formatDateLocal = (iso: string, language: string): string => { 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()}` } export const buildForecastDocDefinition = (payload: any, logos: { light: string | null; dark: string | null }) => { const language = payload?.meta?.language === 'en' ? 'en' : 'de' const T = FORECAST_TEXTS[language] const c = payload?.customer || {} const display = payload?.display || {} const dateIso = payload?.meta?.date || new Date().toISOString().slice(0, 10) const scenarios: any[] = (Array.isArray(payload?.scenarios) ? payload.scenarios : []).slice(0, 4) const lines: any[] = (Array.isArray(payload?.lines) ? payload.lines : []).filter((l: any) => l?.enabled !== false) const totals = payload?.totals || {} const showCalc = display.showCalculation !== false const landscape = scenarios.length > 2 const pageWidth = landscape ? A4_LANDSCAPE_WIDTH : A4_PORTRAIT_WIDTH const docTitle = String(payload?.title || '').trim() || T.docTitle // Landscape has ~400 pt of usable height — tighter rows keep a typical // forecast (≈10 positions + subtotals + totals) on ONE page. const rowFont = landscape ? 8.5 : 9 const rowPad = landscape ? 3 : 5 const euro = (amount: any) => new Intl.NumberFormat(language === 'en' ? 'en-IE' : 'de-DE', { style: 'currency', currency: 'EUR' }).format(Number(amount) || 0) const sectionTitle = (text: string) => ({ stack: [ { canvas: [{ type: 'rect', x: 0, y: 0, w: 26, h: 3, color: BRAND.orange }] }, { text: String(text).toUpperCase(), fontSize: 11, bold: true, color: BRAND.navy, characterSpacing: 0.4, margin: [0, 5, 0, 0] } ], margin: [0, landscape ? 10 : 16, 0, landscape ? 6 : 8] }) const th = (text: string, alignment: string = 'left') => ({ text, style: 'tableHeader', alignment }) const td = (text: any, opts: any = {}) => ({ text: String(text), fontSize: rowFont, ...opts }) const amountCells = (values: any, opts: any = {}) => scenarios.map((_s: any, i: number) => td(euro(Array.isArray(values) ? values[i] : 0), { alignment: 'right', bold: true, ...opts })) // Special (non-zebra) rows are styled per cell; remember their indexes so the // zebra fill skips them. const body: any[][] = [] const styledRows = new Set() const pushLine = (line: any) => { body.push([ td(line.displayLabel || line.label || line.key || '—'), ...(showCalc ? [td(line.calculation || '', { color: BRAND.grayText, fontSize: rowFont - 0.5 })] : []), ...amountCells(line.amounts) ]) } const pushSummary = (label: string, values: any, style: 'subtotal' | 'total' | 'extra') => { const fill = style === 'total' ? BRAND.navy : style === 'subtotal' ? '#E9EEF3' : null const labelCell: any = { text: label, bold: true, fontSize: style === 'total' ? rowFont + 1 : rowFont, color: style === 'total' ? BRAND.white : BRAND.navy, colSpan: showCalc ? 2 : 1, ...(fill ? { fillColor: fill } : {}) } const row: any[] = [labelCell] if (showCalc) row.push({ text: '' }) row.push(...amountCells(values, { fontSize: style === 'total' ? rowFont + 1 : rowFont, color: style === 'total' ? BRAND.orange : BRAND.navy, ...(fill ? { fillColor: fill } : {}) })) if (fill) styledRows.add(body.length + 1) // +1 = header row offset body.push(row) } const variableLines = lines.filter((l: any) => l.basis !== 'fixed') const fixedLines = lines.filter((l: any) => l.basis === 'fixed') if (display.showSubtotals !== false && variableLines.length && fixedLines.length) { variableLines.forEach(pushLine) pushSummary(T.variableTotal, totals.variable, 'subtotal') fixedLines.forEach(pushLine) pushSummary(T.fixedTotal, totals.fixed, 'subtotal') } else { lines.forEach(pushLine) } pushSummary(T.total, totals.total, 'total') if (display.showPerOrder !== false) pushSummary(T.perOrder, totals.perOrder, 'extra') if (display.showYearly === true) pushSummary(T.yearly, totals.yearly, 'extra') const amountWidth = landscape ? 92 : (scenarios.length > 1 ? 84 : 110) const widths: any[] = ['*', ...(showCalc ? [landscape ? 170 : (scenarios.length > 1 ? 118 : 150)] : []), ...scenarios.map(() => amountWidth)] const header = [ th(T.position), ...(showCalc ? [th(T.calculation)] : []), ...scenarios.map((s: any) => th(String(s?.heading || s?.label || ''), 'right')) ] const table = { table: { headerRows: 1, widths, body: [header, ...body], dontBreakRows: true }, layout: { hLineWidth: (i: number) => (i === 1 ? 0.75 : 0), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, fillColor: (rowIndex: number) => { if (rowIndex === 0) return BRAND.navy if (styledRows.has(rowIndex)) return null return rowIndex % 2 === 0 ? BRAND.zebra : null }, paddingLeft: () => 7, paddingRight: () => 7, paddingTop: () => rowPad, paddingBottom: () => rowPad } } const kv = (label: string, value: any) => [ td(label, { color: BRAND.grayText }), td(value || '—', { bold: true }) ] const intro = String(payload?.intro || '').trim() || T.intro const notes = String(payload?.notes || '').trim() const content: any[] = [ { columns: [ { width: '*', stack: [ { text: c.company || '', bold: true, fontSize: 12, color: BRAND.navy }, ...(c.contactName ? [{ text: c.contactName, margin: [0, 3, 0, 0] }] : []) ] }, { width: 240, table: { widths: [70, '*'], body: [ kv(T.date, formatDateLocal(dateIso, language)), kv(T.basis, T.basisValue) ] }, layout: { hLineWidth: () => 0, vLineWidth: () => 0, fillColor: () => BRAND.zebra, paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 } } ], columnGap: 24, margin: [0, 4, 0, 0] }, { text: intro, color: BRAND.grayText, margin: [0, landscape ? 8 : 14, 0, 0] }, sectionTitle(T.tableTitle), table ] if (display.showDisclaimer !== false) { content.push({ text: T.disclaimer, fontSize: 7.5, italics: true, color: BRAND.grayText, margin: [0, 6, 0, 0] }) } if (notes) { content.push({ unbreakable: notes.length < 1500, stack: [sectionTitle(T.notesTitle), { text: notes, fontSize: 9.5, margin: [0, 2, 0, 0] }] }) } content.push({ unbreakable: true, columns: [ { width: 'auto', canvas: [{ type: 'rect', x: 0, y: 2, w: 3, h: 26, color: BRAND.orange }] }, { width: '*', stack: [ { text: T.closing, bold: true, fontSize: 11, color: BRAND.navy }, { text: T.closingTeam, fontSize: 9.5, color: BRAND.grayText, margin: [0, 3, 0, 0] } ], margin: [10, 0, 0, 0] } ], margin: [0, 22, 0, 0] }) return { pageSize: 'A4', pageOrientation: landscape ? 'landscape' : 'portrait', pageMargins: [40, 106, 40, 86], info: { title: `${docTitle} – ${c.company || ''}`, author: 'LogYou GmbH', subject: docTitle }, header: buildBrandHeader(docTitle, `logyou.de · ${T.dateWord} ${formatDateLocal(dateIso, language)}`, logos.dark, pageWidth), footer: buildBrandFooter(T.page, T.of, pageWidth), content, styles: { tableHeader: { fontSize: 8.5, bold: true, color: BRAND.white } }, defaultStyle: { font: 'Roboto', fontSize: 9.5, color: BRAND.text, lineHeight: 1.25 } } } /** Build the final PDF buffer + authoritative filename. */ export const generateForecastPdf = async (payload: any): Promise<{ buffer: Buffer; filename: string }> => { // Defensive vfs extraction — the export shape varies by pdfmake version/bundler // (same block as quotePdf.ts / agbPdf.ts, do not simplify). 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 docDefinition = buildForecastDocDefinition(payload, logos) const filename = sanitizeForecastFilename(payload?.customer?.company, payload?.meta?.date) const buffer = await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) return { buffer, filename } } /** Shape-check shared by the preview and send routes. */ export const validateForecastPayload = (body: any): string => { if (!String(body?.customer?.company || '').trim()) return 'Company is required' if (!Array.isArray(body?.scenarios) || !body.scenarios.length) return 'At least one scenario is required' if (!Array.isArray(body?.lines) || !body.lines.some((l: any) => l?.enabled !== false)) return 'At least one active position is required' return '' } /** Persistable editor state (offer_conditions.forecast) — computed values stripped. */ export const conditionsFromForecastPayload = (p: any) => ({ language: p?.meta?.language === 'en' ? 'en' : 'de', customer: p?.customer || {}, title: String(p?.title || ''), intro: String(p?.intro || ''), notes: String(p?.notes || ''), scenarios: (Array.isArray(p?.scenarios) ? p.scenarios : []).slice(0, 4).map((s: any) => ({ label: String(s?.label || ''), orders: Number(s?.orders) || 0 })), lines: (Array.isArray(p?.lines) ? p.lines : []).map((l: any) => ({ key: String(l?.key || 'custom'), enabled: l?.enabled !== false, label: String(l?.label || ''), basis: l?.basis === 'fixed' ? 'fixed' : 'perOrder', factor: Number(l?.factor) || 0, factorMode: l?.factorMode === 'pct' ? 'pct' : 'x', qty: Number(l?.qty) || 0, unitPrice: Number(l?.unitPrice) || 0 })), display: p?.display && typeof p.display === 'object' ? p.display : {} })