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}
`;
}
}