import { Injectable } from '@nestjs/common'; import ExcelJS from 'exceljs'; import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; // ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming // WorkbookWriter if an export ever needs to outgrow XLSX_ROW_CAP. export const XLSX_ROW_CAP = 50_000; // ponytail: CSV is buffered through the same Workbook as xlsx, so it shares the // cap. Switch to qb.stream() + res.write() if a dataset needs more than this. export const CSV_ROW_CAP = 50_000; // ponytail: HTML→PDF render cost grows with row count; larger exports must // use XLSX or CSV instead. export const PDF_ROW_CAP = 5_000; /** * Value types a tabular export understands. A superset of `ReportColumn['type']` * so a report's own columns are assignable here unchanged. */ export type ExportFieldType = | 'string' | 'number' | 'money' | 'tons' | 'percent' | 'date' | 'datetime' | 'boolean'; /** The minimum a column must describe to be written to a sheet. */ export interface ExportColumnLike { key: string; label: string; type: ExportFieldType; } /** Headline figures printed above the table. xlsx/pdf only — never in CSV. */ export interface ExportKpiLike { label: string; value: number; unit?: string; } /** * One tabular document, independent of where the rows came from. A report and a * dataset export both reduce to this, which is what lets them share one writer. */ export interface TabularDoc { /** Sheet name (truncated to Excel's 31-char limit) and the PDF's

. */ title: string; description?: string; /** Log label handed to PdfRenderService, e.g. "report:bookings-list". */ label: string; columns: ExportColumnLike[]; rows: Record[]; kpis?: ExportKpiLike[]; } const NUMBER_FORMAT: Partial> = { money: '#,##0.00', tons: '#,##0.0', percent: '0"%"', number: '#,##0', }; function formatCell(value: unknown, type: ExportFieldType): string { if (value === null || value === undefined) return ''; if (type === 'money' || type === 'number') { return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 }); } if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`; if (type === 'percent') return `${value}%`; if (type === 'boolean') return value ? 'Yes' : 'No'; return String(value); } @Injectable() export class TabularExportService { constructor(private readonly pdfRender: PdfRenderService) {} async toXlsx(doc: TabularDoc): Promise { const workbook = this.buildWorkbook(doc, { includeKpis: true }); const buffer = await workbook.xlsx.writeBuffer(); return Buffer.from(buffer); } /** * CSV via ExcelJS's own writer, off the same Workbook xlsx builds — it already * handles quoting, embedded commas and embedded newlines. Hand-rolling * `row.join(',')` breaks on the first customer name containing a comma. * * KPIs are deliberately omitted: a preamble row plus a blank row before the * header stops the file parsing as a plain table, and CSV's whole point here * is being machine-readable. */ async toCsv(doc: TabularDoc): Promise { const workbook = this.buildWorkbook(doc, { includeKpis: false }); const buffer = await workbook.csv.writeBuffer(); return Buffer.from(buffer); } async toPdf(doc: TabularDoc): Promise { const html = this.buildHtml(doc); return this.pdfRender.htmlToPdfBuffer(html, { label: doc.label, landscape: true, // Without this, a box with no Chromium silently degrades to // genericFallbackPdf — a ~900-character text dump instead of a table. // buildTabularFallbackPdf parses exactly the markup buildHtml emits. fallback: buildTabularFallbackPdf, }); } private buildWorkbook(doc: TabularDoc, opts: { includeKpis: boolean }): ExcelJS.Workbook { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet(doc.title.slice(0, 31)); const { columns, rows, kpis } = doc; if (opts.includeKpis && kpis?.length) { sheet.addRow( kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`), ); sheet.addRow([]); } const headerRow = sheet.addRow(columns.map((c) => c.label)); headerRow.font = { bold: true }; for (const row of rows) { sheet.addRow(columns.map((c) => row[c.key] ?? null)); } columns.forEach((col, i) => { const format = NUMBER_FORMAT[col.type]; const excelCol = sheet.getColumn(i + 1); excelCol.width = Math.max(col.label.length + 2, 12); if (format) excelCol.numFmt = format; }); return workbook; } private buildHtml(doc: TabularDoc): string { const { title, description, columns, rows, kpis } = doc; const esc = (v: unknown) => String(v ?? '').replace(/&/g, '&').replace(//g, '>'); const kpiHtml = kpis?.length ? `
${kpis .map( (k) => `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, ) .join('')}
` : ''; const head = columns.map((c) => `${esc(c.label)}`).join(''); const body = rows .map( (row) => `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, ) .join(''); return `

${esc(title)}

${esc(description ?? '')}

${kpiHtml} ${head}${body}
`; } }