mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
181 lines
6.2 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
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>`;
|
|
}
|
|
}
|