Files
edr-platform/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts
2026-08-19 13:51:18 +00:00

181 lines
6.2 KiB
TypeScript

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 <h1>. */
title: string;
description?: string;
/** Log label handed to PdfRenderService, e.g. "report:bookings-list". */
label: string;
columns: ExportColumnLike[];
rows: Record<string, unknown>[];
kpis?: ExportKpiLike[];
}
const NUMBER_FORMAT: Partial<Record<ExportFieldType, string>> = {
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<Buffer> {
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<Buffer> {
const workbook = this.buildWorkbook(doc, { includeKpis: false });
const buffer = await workbook.csv.writeBuffer();
return Buffer.from(buffer);
}
async toPdf(doc: TabularDoc): Promise<Buffer> {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis?.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div class="tile"><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.subtitle { color: #666; margin: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(title)}</h1>
<p class="subtitle">${esc(description ?? '')}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}