mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 01:13:26 +00:00
Container filters on the booking-requests list:
- "Container type" — bookings carrying that type.
- "Containers" — a count of BOXES (booking_container is one row per line with
a quantity, so this sums quantity rather than counting rows), as an exact
value or a range. It reads the container-type filter when one is set, so the
one control answers both "10 containers in total" and "10 forty-footers".
Export gains a column per container type ("20FT containers", "40FT
containers"), plus the total "Containers" column and the two filters. Container
types are reference rows, not a constant, so `ExportDataset` gains an optional
`dynamicFields` resolver — DB-driven columns appended to the static list and
cached for the process, mirroring the existing `ExportFilterDef.optionsQuery`.
Adding a 45ft container type adds its column with no code change. The type id
is interpolated into raw SQL (ExportField.select has no parameter bag), so the
resolver drops any id that is not a uuid.
Also repoints the export's "Container VGM" column at the per-line sum. It was
projecting bookings.cargo_total_weight_vgm, which the portal wizard leaves at 0
for container freight — the same trap the tonnage fix addressed — so the column
read 0 for every portal-created container booking. Non-zero on dev data goes
from 54 to 170 of 208 container bookings.
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from '@nestjs/common';
|
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource } from 'typeorm';
|
|
import { CurrentUser } from '@edr/api-common';
|
|
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
|
import type { Response } from 'express';
|
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
|
|
|
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
|
import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util';
|
|
import {
|
|
EXPORT_MIME,
|
|
formatRowCap,
|
|
pickByKey,
|
|
resolveExportFormat,
|
|
resolveRowLimit,
|
|
} from './export-request.util';
|
|
import { ExportRunnerService } from './export-runner.service';
|
|
import { DATASETS, getDataset } from './export.registry';
|
|
import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types';
|
|
import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service';
|
|
|
|
/** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */
|
|
type RawExportQuery = Record<string, string | undefined>;
|
|
|
|
const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
|
|
|
|
/**
|
|
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
|
|
* the schema — they never leave the server.
|
|
*/
|
|
const toCatalogEntry = (
|
|
dataset: ExportDataset,
|
|
fields: ExportField[],
|
|
): ExportCatalogEntry => ({
|
|
key: dataset.key,
|
|
title: dataset.title,
|
|
description: dataset.description,
|
|
group: dataset.group,
|
|
groups: dataset.groups,
|
|
fields: fields.map(({ key, label, type, group, default: isDefault }) => ({
|
|
key,
|
|
label,
|
|
type,
|
|
group,
|
|
default: isDefault,
|
|
})),
|
|
filters: dataset.filters,
|
|
formats: ['csv', 'xlsx', 'pdf'],
|
|
caps: CAPS,
|
|
defaultSort: dataset.defaultSort,
|
|
});
|
|
|
|
/**
|
|
* Generic table export. One dataset per major table, each describing far more
|
|
* fields than its list page shows — including related-entity detail.
|
|
*/
|
|
@ApiTags('Exports')
|
|
@ApiBearerAuth()
|
|
@Controller('exports')
|
|
@UseGuards(FreightJwtGuard)
|
|
export class ExportsController {
|
|
constructor(
|
|
private readonly runner: ExportRunnerService,
|
|
private readonly writer: TabularExportService,
|
|
private readonly userTradeAccessService: UserTradeAccessService,
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
@Get()
|
|
@ApiOperation({ summary: 'List datasets the caller has permission to export' })
|
|
async catalog(@CurrentUser() user: TCurrentUser): Promise<ExportCatalogEntry[]> {
|
|
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
|
|
return Promise.all(
|
|
allowed.map(async (d) => ({
|
|
...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)),
|
|
filters: await resolveFilterOptions(d.filters, this.dataSource),
|
|
})),
|
|
);
|
|
}
|
|
|
|
@Get(':key/count')
|
|
@ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' })
|
|
async count(
|
|
@Param('key') key: string,
|
|
@Query() query: RawExportQuery,
|
|
@CurrentUser() user: TCurrentUser,
|
|
): Promise<{ total: number; caps: typeof CAPS }> {
|
|
const dataset = this.resolve(key, user);
|
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
|
const total = await this.runner.count(dataset, query, directions);
|
|
return { total, caps: CAPS };
|
|
}
|
|
|
|
@Get(':key/download')
|
|
@ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' })
|
|
async download(
|
|
@Param('key') key: string,
|
|
@Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string },
|
|
@CurrentUser() user: TCurrentUser,
|
|
@Res() res: Response,
|
|
): Promise<void> {
|
|
const dataset = this.resolve(key, user);
|
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
|
const format = resolveExportFormat(query.format);
|
|
const fields = ExportsController.pickFields(
|
|
await resolveDatasetFields(dataset, this.dataSource),
|
|
query.fields,
|
|
);
|
|
|
|
const rows = await this.runner.run(dataset, fields, query, directions, {
|
|
cap: formatRowCap(format),
|
|
limit: resolveRowLimit(format, query.limit),
|
|
});
|
|
const doc = {
|
|
title: dataset.title,
|
|
description: dataset.description,
|
|
label: `export:${dataset.key}`,
|
|
columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })),
|
|
rows,
|
|
};
|
|
const buffer =
|
|
format === 'pdf'
|
|
? await this.writer.toPdf(doc)
|
|
: format === 'csv'
|
|
? await this.writer.toCsv(doc)
|
|
: await this.writer.toXlsx(doc);
|
|
|
|
const mime = EXPORT_MIME[format];
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`);
|
|
res.setHeader('Content-Type', mime.type);
|
|
res.send(buffer);
|
|
}
|
|
|
|
/**
|
|
* Requested fields, whitelisted against the dataset. No `fields=` means the
|
|
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
|
|
* all of them on an unparameterised call is nobody's intent.
|
|
*/
|
|
private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] {
|
|
if (raw?.trim()) {
|
|
const picked = pickByKey(all, raw);
|
|
// pickByKey falls back to everything when nothing matched; for a dataset
|
|
// the safer read of "all keys unknown" is still the default set.
|
|
if (picked.length !== all.length) return picked;
|
|
}
|
|
const defaults = all.filter((f) => f.default);
|
|
return defaults.length ? defaults : all;
|
|
}
|
|
|
|
private resolve(key: string, user: TCurrentUser): ExportDataset {
|
|
const dataset = getDataset(key);
|
|
if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);
|
|
// Export rides the dataset's own list-page view permission: if you may see
|
|
// these rows, you may export them.
|
|
assertFreightPermission(user, dataset.permission);
|
|
return dataset;
|
|
}
|
|
}
|