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:
Nathnael
2026-08-20 05:16:15 +00:00
parent fb21ad1541
commit fc2b5ee0e3
6 changed files with 95 additions and 227 deletions

View File

@@ -10,8 +10,13 @@ import { BookingStaff } from '../../common/booking-guards';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportExportService } from './report-export.service';
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
import {
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 { REPORTS, getReport } from './report.registry';
import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types';
@@ -59,7 +64,7 @@ async function resolveFilterOptions(
export class ReportsController {
constructor(
private readonly runner: ReportRunnerService,
private readonly exportService: ReportExportService,
private readonly exportService: TabularExportService,
private readonly userTradeAccessService: UserTradeAccessService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -86,7 +91,7 @@ export class ReportsController {
}
@Get(':key/export')
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
@ApiOperation({ summary: 'Export a report to xlsx, csv or pdf' })
async export(
@Param('key') key: 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 format = resolveExportFormat(query.format);
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 doc = {
title: def.title,
description: def.description,
label: `report:${def.key}`,
columns: exportColumns,
rows: items,
kpis,
};
const buffer =
format === 'pdf'
? await this.exportService.toPdf(def, items, kpis, exportColumns)
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
? await this.exportService.toPdf(doc)
: format === 'csv'
? await this.exportService.toCsv(doc)
: await this.exportService.toXlsx(doc);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader(
'Content-Type',
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
const mime = EXPORT_MIME[format];
res.setHeader('Content-Disposition', `attachment; filename="${def.key}.${mime.ext}"`);
res.setHeader('Content-Type', mime.type);
res.send(buffer);
}