mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor(reports): export through the shared tabular writer
Completes the writer extraction whose other half landed in fb21ad154.
reports.controller now builds a TabularDoc and calls TabularExportService,
so report-export.service.ts and report-export-request.util.ts are dead and
removed — HEAD was carrying both copies with the controller still on the old
one.
Reports gain CSV for free, and the PDF path now passes buildTabularFallbackPdf
as its fallback: previously it passed none, so a box without Chromium silently
returned PdfRenderService's ~900-character generic text dump instead of a
table. Adds a spec covering the CSV writer's quoting of embedded commas and
double quotes — the reason this uses ExcelJS's csv writer rather than a
hand-rolled join.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
import { TabularDoc, TabularExportService } from './tabular-export.service';
|
||||||
|
|
||||||
|
/** The PDF path is puppeteer-backed; these specs only cover the sheet writers. */
|
||||||
|
const service = new TabularExportService(null as unknown as PdfRenderService);
|
||||||
|
|
||||||
|
const doc: TabularDoc = {
|
||||||
|
title: 'Bookings',
|
||||||
|
description: 'every booking',
|
||||||
|
label: 'test',
|
||||||
|
columns: [
|
||||||
|
{ key: 'ref', label: 'Reference', type: 'string' },
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string' },
|
||||||
|
{ key: 'amount', label: 'Amount', type: 'money' },
|
||||||
|
{ key: 'gov', label: 'Government', type: 'boolean' },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ ref: 'BK-1', customer: 'Acme, Inc.', amount: 1234.5, gov: true },
|
||||||
|
{ ref: 'BK-2', customer: 'Quote "Q" Ltd', amount: null, gov: false },
|
||||||
|
],
|
||||||
|
kpis: [{ label: 'Bookings', value: 2 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TabularExportService.toCsv', () => {
|
||||||
|
it('quotes a value containing the delimiter — the reason we do not hand-roll join(",")', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv).toContain('"Acme, Inc."');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes embedded double quotes by doubling them', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv).toContain('"Quote ""Q"" Ltd"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts at the header row — no KPI preamble, so the file parses as a plain table', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv.split('\n')[0]).toBe('Reference,Customer,Amount,Government');
|
||||||
|
expect(csv).not.toContain('Bookings: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits one line per row plus the header', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv.trim().split('\n').filter(Boolean)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only the selected columns are written, in the order given', async () => {
|
||||||
|
const csv = (
|
||||||
|
await service.toCsv({ ...doc, columns: [doc.columns[2], doc.columns[0]] })
|
||||||
|
).toString('utf8');
|
||||||
|
expect(csv.split('\n')[0]).toBe('Amount,Reference');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TabularExportService.toXlsx', () => {
|
||||||
|
it('writes a real xlsx (a zip, so it starts with the PK magic bytes)', async () => {
|
||||||
|
const buffer = await service.toXlsx(doc);
|
||||||
|
expect(buffer.subarray(0, 2).toString('utf8')).toBe('PK');
|
||||||
|
expect(buffer.length).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a title longer than Excel\'s 31-char sheet-name limit does not throw', async () => {
|
||||||
|
const longTitle = 'A'.repeat(60);
|
||||||
|
await expect(service.toXlsx({ ...doc, title: longTitle })).resolves.toBeInstanceOf(Buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
|
|
||||||
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
|
|
||||||
import { ReportColumn } from './report.types';
|
|
||||||
|
|
||||||
describe('resolveExportFormat', () => {
|
|
||||||
it('only \'pdf\' exports as pdf', () => {
|
|
||||||
expect(resolveExportFormat('pdf')).toBe('pdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([undefined, 'xlsx', 'csv', ''])('%p falls back to xlsx', (raw) => {
|
|
||||||
expect(resolveExportFormat(raw)).toBe('xlsx');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveExportCap', () => {
|
|
||||||
it('missing limit uses the full format cap', () => {
|
|
||||||
expect(resolveExportCap('xlsx', undefined)).toBe(XLSX_ROW_CAP);
|
|
||||||
expect(resolveExportCap('pdf', undefined)).toBe(PDF_ROW_CAP);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a limit under the cap is used as-is', () => {
|
|
||||||
expect(resolveExportCap('pdf', '100')).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a limit over the cap is clamped down', () => {
|
|
||||||
expect(resolveExportCap('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
|
|
||||||
expect(resolveExportCap('xlsx', String(XLSX_ROW_CAP + 1))).toBe(XLSX_ROW_CAP);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p falls back to the cap', (raw) => {
|
|
||||||
expect(resolveExportCap('xlsx', raw)).toBe(XLSX_ROW_CAP);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveExportColumns', () => {
|
|
||||||
const columns: ReportColumn[] = [
|
|
||||||
{ key: 'a', label: 'A', type: 'string' },
|
|
||||||
{ key: 'b', label: 'B', type: 'number' },
|
|
||||||
{ key: 'c', label: 'C', type: 'money' },
|
|
||||||
];
|
|
||||||
const def = { columns };
|
|
||||||
|
|
||||||
it('missing fields returns every column', () => {
|
|
||||||
expect(resolveExportColumns(def, undefined)).toEqual(columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty fields string returns every column', () => {
|
|
||||||
expect(resolveExportColumns(def, '')).toEqual(columns);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a known subset filters to just those columns, in the report\'s own order', () => {
|
|
||||||
expect(resolveExportColumns(def, 'c,a')).toEqual([columns[0], columns[2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unknown keys are dropped, not passed through', () => {
|
|
||||||
expect(resolveExportColumns(def, 'a,ghost')).toEqual([columns[0]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all-unknown keys falls back to every column instead of a blank sheet', () => {
|
|
||||||
expect(resolveExportColumns(def, 'ghost,also-ghost')).toEqual(columns);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
|
|
||||||
import { ReportColumn, ReportDefinition } from './report.types';
|
|
||||||
|
|
||||||
export type ExportFormat = 'xlsx' | 'pdf';
|
|
||||||
|
|
||||||
/** Anything but the literal string 'pdf' exports as xlsx. */
|
|
||||||
export function resolveExportFormat(raw: string | undefined): ExportFormat {
|
|
||||||
return raw === 'pdf' ? 'pdf' : 'xlsx';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Caller's requested row limit, clamped to the format's hard cap. A
|
|
||||||
* missing/non-positive/non-numeric limit means "as many as the format allows". */
|
|
||||||
export function resolveExportCap(format: ExportFormat, rawLimit: string | undefined): number {
|
|
||||||
const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
|
|
||||||
const requested = Number(rawLimit);
|
|
||||||
return requested > 0 ? Math.min(requested, formatCap) : formatCap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Caller's requested column subset, whitelisted against the report's own
|
|
||||||
* columns. Missing, empty, or all-unknown `rawFields` falls back to every
|
|
||||||
* column rather than shipping a blank sheet. */
|
|
||||||
export function resolveExportColumns(
|
|
||||||
def: Pick<ReportDefinition, 'columns'>,
|
|
||||||
rawFields: string | undefined,
|
|
||||||
): ReportColumn[] {
|
|
||||||
const requested = rawFields?.split(',').filter(Boolean);
|
|
||||||
const filtered = requested?.length ? def.columns.filter((c) => requested.includes(c.key)) : def.columns;
|
|
||||||
return filtered.length ? filtered : def.columns;
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import ExcelJS from 'exceljs';
|
|
||||||
|
|
||||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
|
||||||
import { ReportColumn, ReportDefinition, ReportKpi } from './report.types';
|
|
||||||
|
|
||||||
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
|
|
||||||
// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP.
|
|
||||||
export const XLSX_ROW_CAP = 50_000;
|
|
||||||
// ponytail: HTML→PDF render cost grows with row count; larger exports must
|
|
||||||
// use XLSX instead.
|
|
||||||
export const PDF_ROW_CAP = 5_000;
|
|
||||||
|
|
||||||
const NUMBER_FORMAT: Partial<Record<ReportColumn['type'], string>> = {
|
|
||||||
money: '#,##0.00',
|
|
||||||
tons: '#,##0.0',
|
|
||||||
percent: '0"%"',
|
|
||||||
number: '#,##0',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatCell(value: unknown, type: ReportColumn['type']): 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}%`;
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ReportExportService {
|
|
||||||
constructor(private readonly pdfRender: PdfRenderService) {}
|
|
||||||
|
|
||||||
async toXlsx(
|
|
||||||
def: ReportDefinition,
|
|
||||||
rows: Record<string, unknown>[],
|
|
||||||
kpis: ReportKpi[],
|
|
||||||
columns: ReportColumn[] = def.columns,
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
|
|
||||||
|
|
||||||
if (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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const buffer = await workbook.xlsx.writeBuffer();
|
|
||||||
return Buffer.from(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async toPdf(
|
|
||||||
def: ReportDefinition,
|
|
||||||
rows: Record<string, unknown>[],
|
|
||||||
kpis: ReportKpi[],
|
|
||||||
columns: ReportColumn[] = def.columns,
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const html = this.buildHtml(def, rows, kpis, columns);
|
|
||||||
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildHtml(
|
|
||||||
def: ReportDefinition,
|
|
||||||
rows: Record<string, unknown>[],
|
|
||||||
kpis: ReportKpi[],
|
|
||||||
columns: ReportColumn[],
|
|
||||||
): string {
|
|
||||||
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><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.desc { color: #666; margin-top: 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(def.title)}</h1>
|
|
||||||
<p class="desc">${esc(def.description)}</p>
|
|
||||||
${kpiHtml}
|
|
||||||
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
|
||||||
</body></html>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,13 @@ import { BookingStaff } from '../../common/booking-guards';
|
|||||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
|
||||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
import { ReportExportService } from './report-export.service';
|
import {
|
||||||
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
|
EXPORT_MIME,
|
||||||
|
pickByKey,
|
||||||
|
resolveExportCap,
|
||||||
|
resolveExportFormat,
|
||||||
|
} from '../exports/export-request.util';
|
||||||
|
import { TabularExportService } from '../exports/tabular-export.service';
|
||||||
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
|
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
|
||||||
import { REPORTS, getReport } from './report.registry';
|
import { REPORTS, getReport } from './report.registry';
|
||||||
import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types';
|
import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types';
|
||||||
@@ -59,7 +64,7 @@ async function resolveFilterOptions(
|
|||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runner: ReportRunnerService,
|
private readonly runner: ReportRunnerService,
|
||||||
private readonly exportService: ReportExportService,
|
private readonly exportService: TabularExportService,
|
||||||
private readonly userTradeAccessService: UserTradeAccessService,
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
@@ -86,7 +91,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':key/export')
|
@Get(':key/export')
|
||||||
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
|
@ApiOperation({ summary: 'Export a report to xlsx, csv or pdf' })
|
||||||
async export(
|
async export(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
|
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
|
||||||
@@ -97,22 +102,27 @@ export class ReportsController {
|
|||||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
const format = resolveExportFormat(query.format);
|
const format = resolveExportFormat(query.format);
|
||||||
const cap = resolveExportCap(format, query.limit);
|
const cap = resolveExportCap(format, query.limit);
|
||||||
const exportColumns = resolveExportColumns(def, query.fields);
|
const exportColumns = pickByKey(def.columns, query.fields);
|
||||||
|
|
||||||
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
||||||
|
const doc = {
|
||||||
|
title: def.title,
|
||||||
|
description: def.description,
|
||||||
|
label: `report:${def.key}`,
|
||||||
|
columns: exportColumns,
|
||||||
|
rows: items,
|
||||||
|
kpis,
|
||||||
|
};
|
||||||
const buffer =
|
const buffer =
|
||||||
format === 'pdf'
|
format === 'pdf'
|
||||||
? await this.exportService.toPdf(def, items, kpis, exportColumns)
|
? await this.exportService.toPdf(doc)
|
||||||
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
|
: format === 'csv'
|
||||||
|
? await this.exportService.toCsv(doc)
|
||||||
|
: await this.exportService.toXlsx(doc);
|
||||||
|
|
||||||
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
const mime = EXPORT_MIME[format];
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${def.key}.${mime.ext}"`);
|
||||||
res.setHeader(
|
res.setHeader('Content-Type', mime.type);
|
||||||
'Content-Type',
|
|
||||||
format === 'pdf'
|
|
||||||
? 'application/pdf'
|
|
||||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
);
|
|
||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
import { ExportsModule } from '../exports/exports.module';
|
||||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { ReportExportService } from './report-export.service';
|
|
||||||
import { ReportRunnerService } from './report-runner.service';
|
import { ReportRunnerService } from './report-runner.service';
|
||||||
import { ReportsController } from './reports.controller';
|
import { ReportsController } from './reports.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UserTradeAccessModule, DocumentsModule],
|
// ExportsModule provides the shared tabular writer (xlsx/csv/pdf) and pulls
|
||||||
|
// DocumentsModule in for the PDF renderer.
|
||||||
|
imports: [UserTradeAccessModule, ExportsModule],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportRunnerService, ReportExportService],
|
providers: [ReportRunnerService],
|
||||||
})
|
})
|
||||||
export class ReportsModule {}
|
export class ReportsModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user