From fb21ad1541823c54214f6d25b477c092190b8f39 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 19 Aug 2026 13:51:18 +0000 Subject: [PATCH 01/22] feat(WIP): filtering, exporting and more reports --- apps/edr-freight-api/src/app.module.ts | 2 + .../3580000000000-OperationsReporting.ts | 114 ++++ .../exports/export-request.util.spec.ts | 74 +++ .../modules/exports/export-request.util.ts | 41 ++ .../src/modules/exports/exports.module.ts | 17 + .../modules/exports/tabular-export.service.ts | 180 ++++++ .../dto/create-operations-target.dto.ts | 64 +++ .../dto/list-operations-targets-query.dto.ts | 29 + .../dto/update-operations-standards.dto.ts | 118 ++++ .../dto/update-operations-target.dto.ts | 5 + .../entities/operations-standard.entity.ts | 185 ++++++ .../entities/operations-target.entity.ts | 62 +++ .../operations-reporting.module.ts | 26 + .../operations-standards.controller.ts | 30 + .../operations-standards.service.ts | 43 ++ .../operations-targets.controller.ts | 68 +++ .../operations-targets.service.ts | 148 +++++ .../cargo-volume-by-station.report.ts | 139 +++++ .../cargo-volume-performance.report.ts | 89 +++ .../charged-vs-actual-volume.report.ts | 110 ++++ .../station-staying-time.report.ts | 148 +++++ .../definitions/teu-performance.report.ts | 103 ++++ .../definitions/train-delays.report.ts | 104 ++++ .../trainset-performance.report.ts | 97 ++++ .../definitions/turnaround-cycle.report.ts | 143 +++++ .../reports/operations-classification.spec.ts | 94 ++++ .../reports/operations-classification.ts | 525 ++++++++++++++++++ .../modules/reports/report-runner.service.ts | 7 +- .../src/modules/reports/report.registry.ts | 16 + .../modules/reports/revenue-classification.ts | 20 +- .../rule-engine/dto/create-cargo-type.dto.ts | 10 + .../dto/create-yard-distance.dto.ts | 15 +- .../rule-engine/entities/cargo-type.entity.ts | 9 + .../entities/yard-distance.entity.ts | 11 + .../services/cargo-types.service.ts | 1 + .../services/yard-distances.service.ts | 4 + .../src/scripts/tmp-ops-plan.ts | 84 +++ .../src/scripts/tmp-ops-verify.ts | 99 ++++ .../src/seed/freight-permissions.registry.ts | 26 + apps/edr-freight-api/tmp-mkdb.cjs | 18 + apps/edr-freight-api/tmp-ops-reconcile.cjs | 81 +++ apps/edr-freight-web/backoffice/index.html | 6 + apps/edr-freight-web/backoffice/src/App.tsx | 11 + .../components/layout/sidebar-sections.tsx | 5 + .../backoffice/src/constants/URLS.ts | 5 + .../src/hooks/rule-engine/useRuleEngine.ts | 3 + .../src/hooks/useOperationsStandards.ts | 35 ++ .../backoffice/src/lib/permissions.ts | 6 + .../src/pages/reports/ReportsLandingPage.tsx | 58 +- .../ruleEngine/RuleEngineResourcePage.tsx | 18 +- .../src/pages/ruleEngine/config/resources.ts | 130 +++++ .../settings/OperationsStandardsPage.tsx | 282 ++++++++++ .../services/operationsStandards.service.ts | 51 ++ .../services/ruleEngine/ruleEngine.service.ts | 1 + .../backoffice/src/theme/freight-brand.ts | 4 +- .../backoffice/src/types/rule-engine/index.ts | 3 +- 56 files changed, 3750 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export-request.util.ts create mode 100644 apps/edr-freight-api/src/modules/exports/exports.module.ts create mode 100644 apps/edr-freight-api/src/modules/exports/tabular-export.service.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts create mode 100644 apps/edr-freight-api/src/modules/reports/operations-classification.ts create mode 100644 apps/edr-freight-api/src/scripts/tmp-ops-plan.ts create mode 100644 apps/edr-freight-api/src/scripts/tmp-ops-verify.ts create mode 100644 apps/edr-freight-api/tmp-mkdb.cjs create mode 100644 apps/edr-freight-api/tmp-ops-reconcile.cjs create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useOperationsStandards.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..b148c7eea 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; @@ -213,6 +214,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + OperationsReportingModule, StampSettingsModule, LogoSettingsModule, ContractTemplatesModule, diff --git a/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts b/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts new file mode 100644 index 000000000..1813a7b3a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts @@ -0,0 +1,114 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Reference data for the operations reporting suite (turnaround, delay, + * trainset, TEU, cargo volume). + * + * Two new tables and two new columns: + * + * - `operations_standards` — single-row settings table, same shape as + * `logo_settings` / `exchange_settings`. Holds the railway's standard times + * and charged-tonnage factors. Editable in the backoffice because the + * business calls the corridor standard "flexible". + * - `operations_targets` — the planned side of every "Plan / Operated / + * Implement Rate" table in the spec. One row per period × metric × + * dimension value. + * - `yard_distances.standard_hours` — the per-corridor standard transit time + * (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to + * `operations_standards.default_leg_standard_hours`. + * - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this + * cargo (37 for vehicles, 22 for sand). Null falls back to + * `operations_standards.default_full_trainset_wagons`. + * + * The seed row is inserted only when the table is empty, so re-running this + * never overwrites values an operator has since edited. + */ +export class OperationsReporting3580000000000 implements MigrationInterface { + name = "OperationsReporting3580000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.operations_standards ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10, + station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13, + cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65, + cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88, + cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96, + cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96, + default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21, + delay_tolerance_minutes integer NOT NULL DEFAULT 30, + charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20, + charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40, + charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24, + charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88, + charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70, + charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38, + default_full_trainset_wagons integer NOT NULL DEFAULT 50, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Column defaults carry every value — the seed only needs the row to exist. + await queryRunner.query(` + INSERT INTO freight.operations_standards (id) + SELECT gen_random_uuid() + WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.operations_targets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + period_type varchar(10) NOT NULL, + period_start date NOT NULL, + metric varchar(20) NOT NULL, + dimension varchar(20) NOT NULL, + dimension_key varchar(60) NOT NULL, + planned_value numeric(14,3) NOT NULL, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Partial unique index rather than a table constraint, so a soft-deleted + // target can be re-created — same choice as yard_distances. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key) + WHERE deleted_at IS NULL; + `); + + // The reports look targets up by period and metric, never by id. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup + ON freight.operations_targets (metric, period_type, period_start) + WHERE deleted_at IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.yard_distances + ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2); + `); + + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS full_trainset_wagons integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`, + ); + await queryRunner.query( + `ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`); + } +} diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts new file mode 100644 index 000000000..1a22be1d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts @@ -0,0 +1,74 @@ +import { EXPORT_MIME, pickByKey, resolveExportCap, resolveExportFormat } from './export-request.util'; +import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service'; + +describe('resolveExportFormat', () => { + it('only \'pdf\' exports as pdf', () => { + expect(resolveExportFormat('pdf')).toBe('pdf'); + }); + + it('\'csv\' exports as csv', () => { + expect(resolveExportFormat('csv')).toBe('csv'); + }); + + it.each([undefined, 'xlsx', 'doc', ''])('%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('csv', undefined)).toBe(CSV_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); + expect(resolveExportCap('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_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('EXPORT_MIME', () => { + it('every format has a content type and a matching extension', () => { + expect(EXPORT_MIME.csv.ext).toBe('csv'); + expect(EXPORT_MIME.xlsx.ext).toBe('xlsx'); + expect(EXPORT_MIME.pdf.type).toBe('application/pdf'); + }); +}); + +describe('pickByKey', () => { + const columns = [ + { key: 'a', label: 'A', type: 'string' as const }, + { key: 'b', label: 'B', type: 'number' as const }, + { key: 'c', label: 'C', type: 'money' as const }, + ]; + + it('missing fields returns every column', () => { + expect(pickByKey(columns, undefined)).toEqual(columns); + }); + + it('empty fields string returns every column', () => { + expect(pickByKey(columns, '')).toEqual(columns); + }); + + it('a known subset filters to just those, in the source\'s own order', () => { + expect(pickByKey(columns, 'c,a')).toEqual([columns[0], columns[2]]); + }); + + it('unknown keys are dropped, not passed through', () => { + expect(pickByKey(columns, 'a,ghost')).toEqual([columns[0]]); + }); + + it('all-unknown keys falls back to everything instead of a blank sheet', () => { + expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.ts new file mode 100644 index 000000000..d71db912e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.ts @@ -0,0 +1,41 @@ +import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service'; + +export type ExportFormat = 'xlsx' | 'csv' | 'pdf'; + +/** Anything but the literal 'pdf' or 'csv' exports as xlsx. */ +export function resolveExportFormat(raw: string | undefined): ExportFormat { + if (raw === 'pdf') return 'pdf'; + if (raw === 'csv') return 'csv'; + return '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 : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP; + const requested = Number(rawLimit); + return requested > 0 ? Math.min(requested, formatCap) : formatCap; +} + +/** Content type + file extension per format, for the download response headers. */ +export const EXPORT_MIME: Record = { + xlsx: { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ext: 'xlsx', + }, + csv: { type: 'text/csv; charset=utf-8', ext: 'csv' }, + pdf: { type: 'application/pdf', ext: 'pdf' }, +}; + +/** + * Caller's requested subset, whitelisted against what they're allowed to have. + * Missing, empty, or all-unknown `raw` falls back to every entry rather than + * shipping a blank sheet. Generic over `{ key }` so it serves both a report's + * `columns` and a dataset's `fields`. + */ +export function pickByKey(all: T[], raw: string | undefined): T[] { + const requested = raw?.split(',').filter(Boolean); + const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all; + return filtered.length ? filtered : all; +} diff --git a/apps/edr-freight-api/src/modules/exports/exports.module.ts b/apps/edr-freight-api/src/modules/exports/exports.module.ts new file mode 100644 index 000000000..6f56826bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/exports.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { DocumentsModule } from '../billing/documents/documents.module'; +import { TabularExportService } from './tabular-export.service'; + +/** + * Export infrastructure. Currently just the shared tabular writer (xlsx / csv / + * pdf) that both the reports module and — once the dataset registry lands — the + * generic table exports write through. No domain dependencies, so any module can + * import it. + */ +@Module({ + imports: [DocumentsModule], + providers: [TabularExportService], + exports: [TabularExportService], +}) +export class ExportsModule {} diff --git a/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts b/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts new file mode 100644 index 000000000..05d40f360 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts @@ -0,0 +1,180 @@ +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

. */ + title: string; + description?: string; + /** Log label handed to PdfRenderService, e.g. "report:bookings-list". */ + label: string; + columns: ExportColumnLike[]; + rows: Record[]; + kpis?: ExportKpiLike[]; +} + +const NUMBER_FORMAT: Partial> = { + 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 { + 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 { + const workbook = this.buildWorkbook(doc, { includeKpis: false }); + const buffer = await workbook.csv.writeBuffer(); + return Buffer.from(buffer); + } + + async toPdf(doc: TabularDoc): Promise { + 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, '>'); + + const kpiHtml = kpis?.length + ? `
${kpis + .map( + (k) => + `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, + ) + .join('')}
` + : ''; + + const head = columns.map((c) => `${esc(c.label)}`).join(''); + const body = rows + .map( + (row) => + `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, + ) + .join(''); + + return ` +

${esc(title)}

+

${esc(description ?? '')}

+ ${kpiHtml} + ${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts new file mode 100644 index 000000000..11a23fa87 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsDateString, + IsIn, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +import { + TARGET_DIMENSIONS, + TARGET_METRICS, + TARGET_PERIOD_TYPES, + TargetDimension, + TargetMetric, + TargetPeriodType, +} from '../entities/operations-target.entity'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +export class CreateOperationsTargetDto { + @ApiProperty({ enum: TARGET_PERIOD_TYPES }) + @IsIn(TARGET_PERIOD_TYPES as unknown as string[]) + periodType!: TargetPeriodType; + + @ApiProperty({ + example: '2026-08-01', + description: 'Any date inside the bucket — normalised to the bucket start on write.', + }) + @IsDateString() + periodStart!: string; + + @ApiProperty({ enum: TARGET_METRICS }) + @IsIn(TARGET_METRICS as unknown as string[]) + metric!: TargetMetric; + + @ApiProperty({ enum: TARGET_DIMENSIONS }) + @IsIn(TARGET_DIMENSIONS as unknown as string[]) + dimension!: TargetDimension; + + @ApiProperty({ + example: 'CONTAINER_IMPORT_MULTIMODAL', + description: 'Category key, container-class key or yard code — not a display label.', + }) + @IsString() + @MaxLength(60) + dimensionKey!: string; + + @ApiProperty({ example: 1200 }) + @Transform(toNumber) + @IsNumber() + @Min(0) + plannedValue!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts new file mode 100644 index 000000000..f76f9fa34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { + TARGET_DIMENSIONS, + TARGET_METRICS, + TARGET_PERIOD_TYPES, + TargetDimension, + TargetMetric, + TargetPeriodType, +} from '../entities/operations-target.entity'; + +export class ListOperationsTargetsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: TARGET_PERIOD_TYPES }) + @IsOptional() + @IsIn(TARGET_PERIOD_TYPES as unknown as string[]) + periodType?: TargetPeriodType; + + @ApiPropertyOptional({ enum: TARGET_METRICS }) + @IsOptional() + @IsIn(TARGET_METRICS as unknown as string[]) + metric?: TargetMetric; + + @ApiPropertyOptional({ enum: TARGET_DIMENSIONS }) + @IsOptional() + @IsIn(TARGET_DIMENSIONS as unknown as string[]) + dimension?: TargetDimension; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts new file mode 100644 index 000000000..cbc686a22 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts @@ -0,0 +1,118 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +/** + * Every field optional — the backoffice form PATCHes only what changed. A + * standard of zero is rejected: it would make every implement-rate division + * blow up or read as infinite achievement. + */ +export class UpdateOperationsStandardsDto { + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + stationStandardHoursEthiopia?: number; + + @ApiPropertyOptional({ example: 13 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + stationStandardHoursDjibouti?: number; + + @ApiPropertyOptional({ example: 65 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursContainer?: number; + + @ApiPropertyOptional({ example: 88 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursBulkDmp?: number; + + @ApiPropertyOptional({ example: 96 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursBulkNagad?: number; + + @ApiPropertyOptional({ example: 96 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursBulkBcc?: number; + + @ApiPropertyOptional({ example: 21 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + defaultLegStandardHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Transform(toNumber) + @IsInt() + @Min(0) + delayToleranceMinutes?: number; + + @ApiPropertyOptional({ example: 20 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsFull20ft?: number; + + @ApiPropertyOptional({ example: 40 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsFull40ft?: number; + + @ApiPropertyOptional({ example: 2.24 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsEmpty20ft?: number; + + @ApiPropertyOptional({ example: 3.88 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsEmpty40ft?: number; + + @ApiPropertyOptional({ example: 70 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsPerWagonGeneral?: number; + + @ApiPropertyOptional({ example: 38 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsPerWagonPerishable?: number; + + @ApiPropertyOptional({ example: 50 }) + @IsOptional() + @Transform(toNumber) + @IsInt() + @Min(1) + defaultFullTrainsetWagons?: number; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts new file mode 100644 index 000000000..9be45cefb --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateOperationsTargetDto } from './create-operations-target.dto'; + +export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts new file mode 100644 index 000000000..d5a31725f --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts @@ -0,0 +1,185 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity } from 'typeorm'; + +/** + * numeric comes back from pg as a string. Every value here is arithmetic in a + * report expression, so convert on read rather than making each caller do it. + */ +const asNumber = { + to: (value: number) => value, + from: (value: string | null) => (value === null ? null : Number(value)), +}; + +/** + * Single-row table holding the railway's operating standards — the numbers the + * operations reports measure actual performance against. Same single-row shape + * as `logo_settings` and `exchange_settings`; the app never inserts a second row. + * + * These live in the database rather than in a constants file because the + * business treats them as tunable (the corridor standard is explicitly + * described as "flexible"), and a planner must be able to change one without a + * deployment. + */ +@Entity({ schema: 'freight', name: 'operations_standards' }) +export class OperationsStandard extends BaseEntity { + /** Standard time a train may stand at an Ethiopian station, in hours. */ + @Column({ + name: 'station_standard_hours_ethiopia', + type: 'numeric', + precision: 6, + scale: 2, + default: 10, + transformer: asNumber, + }) + stationStandardHoursEthiopia!: number; + + /** Standard time a train may stand at a Djibouti station, in hours. */ + @Column({ + name: 'station_standard_hours_djibouti', + type: 'numeric', + precision: 6, + scale: 2, + default: 13, + transformer: asNumber, + }) + stationStandardHoursDjibouti!: number; + + /** Container turn-around cycle: 10 + 21 + 13 + 21. */ + @Column({ + name: 'cycle_standard_hours_container', + type: 'numeric', + precision: 6, + scale: 2, + default: 65, + transformer: asNumber, + }) + cycleStandardHoursContainer!: number; + + /** Bulk cycle via DMP: 13 + 21 + 33 + 21. */ + @Column({ + name: 'cycle_standard_hours_bulk_dmp', + type: 'numeric', + precision: 6, + scale: 2, + default: 88, + transformer: asNumber, + }) + cycleStandardHoursBulkDmp!: number; + + /** Bulk cycle via Negad freight yard: 13 + 21 + 41 + 21. */ + @Column({ + name: 'cycle_standard_hours_bulk_nagad', + type: 'numeric', + precision: 6, + scale: 2, + default: 96, + transformer: asNumber, + }) + cycleStandardHoursBulkNagad!: number; + + /** Bulk cycle via BCC: 13 + 21 + 41 + 21. */ + @Column({ + name: 'cycle_standard_hours_bulk_bcc', + type: 'numeric', + precision: 6, + scale: 2, + default: 96, + transformer: asNumber, + }) + cycleStandardHoursBulkBcc!: number; + + /** + * Standard running time for one corridor leg, used when the yard pair has no + * `yard_distances.standard_hours` of its own. + */ + @Column({ + name: 'default_leg_standard_hours', + type: 'numeric', + precision: 6, + scale: 2, + default: 21, + transformer: asNumber, + }) + defaultLegStandardHours!: number; + + /** Grace on top of the leg standard before a train counts as delayed. */ + @Column({ name: 'delay_tolerance_minutes', type: 'int', default: 30 }) + delayToleranceMinutes!: number; + + /** Charged tonnage per laden 20ft container. */ + @Column({ + name: 'charged_tons_full_20ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 20, + transformer: asNumber, + }) + chargedTonsFull20ft!: number; + + /** Charged tonnage per laden 40ft container. */ + @Column({ + name: 'charged_tons_full_40ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 40, + transformer: asNumber, + }) + chargedTonsFull40ft!: number; + + /** Charged tonnage per empty 20ft container. */ + @Column({ + name: 'charged_tons_empty_20ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 2.24, + transformer: asNumber, + }) + chargedTonsEmpty20ft!: number; + + /** Charged tonnage per empty 40ft container. */ + @Column({ + name: 'charged_tons_empty_40ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 3.88, + transformer: asNumber, + }) + chargedTonsEmpty40ft!: number; + + /** Charged tonnage per wagon of steel, fertilizer, rice, sugar, livestock. */ + @Column({ + name: 'charged_tons_per_wagon_general', + type: 'numeric', + precision: 8, + scale: 2, + default: 70, + transformer: asNumber, + }) + chargedTonsPerWagonGeneral!: number; + + /** Charged tonnage per wagon of vegetables, milk, meat and other perishables. */ + @Column({ + name: 'charged_tons_per_wagon_perishable', + type: 'numeric', + precision: 8, + scale: 2, + default: 38, + transformer: asNumber, + }) + chargedTonsPerWagonPerishable!: number; + + /** + * Wagons in a full trainset when the cargo type has no + * `cargo_types.full_trainset_wagons` of its own. + */ + @Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 }) + defaultFullTrainsetWagons!: number; + + /** IAM user id of the last operator to change a standard. */ + @Column({ name: 'updated_by_id', type: 'uuid', nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts new file mode 100644 index 000000000..f62224853 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** Planning buckets the reports offer. Mirrors the reports' period filter. */ +export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const; +export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number]; + +/** What is being planned. */ +export const TARGET_METRICS = ['TEU', 'TRAINSET', 'VOLUME_TONS'] as const; +export type TargetMetric = (typeof TARGET_METRICS)[number]; + +/** Which axis `dimensionKey` names. */ +export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const; +export type TargetDimension = (typeof TARGET_DIMENSIONS)[number]; + +/** + * The planned side of every "Plan / Operated / Implement Rate" table in the + * operations reporting spec. One row is one planned number: a period, a metric, + * and the dimension value it applies to. + * + * `dimensionKey` holds a category key (not a label) — the same keys + * `operations-classification.ts` emits, so a report can join on it directly. + * + * Uniqueness on the five-column slot is a partial index in the database + * (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a soft-deleted + * target can be re-created — the same choice `yard_distances` makes. + */ +@Entity({ schema: 'freight', name: 'operations_targets' }) +@Index(['metric', 'periodType', 'periodStart']) +export class OperationsTarget extends BaseEntity { + @Column({ name: 'period_type', type: 'varchar', length: 10 }) + periodType!: TargetPeriodType; + + /** First day of the bucket, normalised on write (Monday, 1st, quarter start). */ + @Column({ name: 'period_start', type: 'date' }) + periodStart!: string; + + @Column({ name: 'metric', type: 'varchar', length: 20 }) + metric!: TargetMetric; + + @Column({ name: 'dimension', type: 'varchar', length: 20 }) + dimension!: TargetDimension; + + /** Category key, container-class key, or yard code — never a display label. */ + @Column({ name: 'dimension_key', type: 'varchar', length: 60 }) + dimensionKey!: string; + + @Column({ + name: 'planned_value', + type: 'numeric', + precision: 14, + scale: 3, + transformer: { + to: (value: number) => value, + from: (value: string | null) => (value === null ? null : Number(value)), + }, + }) + plannedValue!: number; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts new file mode 100644 index 000000000..180c12271 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts @@ -0,0 +1,26 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { OperationsStandard } from './entities/operations-standard.entity'; +import { OperationsTarget } from './entities/operations-target.entity'; +import { OperationsStandardsController } from './operations-standards.controller'; +import { OperationsStandardsService } from './operations-standards.service'; +import { OperationsTargetsController } from './operations-targets.controller'; +import { OperationsTargetsService } from './operations-targets.service'; + +/** + * Reference data behind the operations reports: the railway's operating + * standards (one settings row) and the planned targets the reports compare + * actuals against. + * + * Global because the reports module reads the standards row on every run and + * has no other reason to import this. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])], + controllers: [OperationsStandardsController, OperationsTargetsController], + providers: [OperationsStandardsService, OperationsTargetsService], + exports: [OperationsStandardsService, OperationsTargetsService], +}) +export class OperationsReportingModule {} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts new file mode 100644 index 000000000..773767623 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto'; +import { OperationsStandardsService } from './operations-standards.service'; + +@ApiTags('operations-standards') +@ApiBearerAuth() +@Controller('operations-standards') +export class OperationsStandardsController { + constructor(private readonly service: OperationsStandardsService) {} + + @Get() + @BookingStaff([FREIGHT_PERMS.settings.operationsStandards.view, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: 'Standard times and charged-tonnage factors used by the operations reports' }) + get() { + return this.service.get(); + } + + @Patch() + @BookingStaff([FREIGHT_PERMS.settings.operationsStandards.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: 'Change one or more operating standards' }) + update(@Body() dto: UpdateOperationsStandardsDto, @CurrentUser() user: TCurrentUser) { + return this.service.update(dto, user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts new file mode 100644 index 000000000..ea33ee83a --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; + +import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto'; +import { OperationsStandard } from './entities/operations-standard.entity'; + +/** + * Owns the single `operations_standards` row — the times and tonnage factors + * every operations report measures actual performance against. + * + * The migration seeds the row, but `get()` creates it on demand as well: a + * report that cannot read a standard would have to fall back to a hardcoded + * number, which is exactly what putting these in the database was meant to + * avoid. + */ +@Injectable() +export class OperationsStandardsService { + constructor( + @InjectRepository(OperationsStandard) + private readonly repository: Repository, + ) {} + + async get(): Promise { + const existing = await this.repository.findOne({ + where: { deletedAt: IsNull() }, + order: { createdAt: 'ASC' }, + }); + if (existing) return existing; + + // Every column has a database default, so an empty insert is the seed row. + return this.repository.save(this.repository.create({})); + } + + async update( + dto: UpdateOperationsStandardsDto, + userId: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { ...dto, updatedById: userId }); + return this.get(); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts new file mode 100644 index 000000000..3396f643d --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + RuleEngineCreate, + RuleEngineDelete, + RuleEngineUpdate, + RuleEngineView, +} from '../../common/rule-engine-guards'; +import { CreateOperationsTargetDto } from './dto/create-operations-target.dto'; +import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto'; +import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto'; +import { OperationsTargetsService } from './operations-targets.service'; + +@ApiTags('operations-targets') +@Controller('operations-targets') +@ApiBearerAuth() +export class OperationsTargetsController { + constructor(private readonly service: OperationsTargetsService) {} + + @Get() + @RuleEngineView('operations-targets') + @ApiOperation({ summary: 'List planned operational targets' }) + findAll(@Query() query: ListOperationsTargetsQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @RuleEngineView('operations-targets') + @ApiOperation({ summary: 'Get a target by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineCreate('operations-targets') + @ApiOperation({ summary: 'Create a planned target' }) + create(@Body() dto: CreateOperationsTargetDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('operations-targets') + @ApiOperation({ summary: 'Update a planned target' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOperationsTargetDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('operations-targets') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a planned target' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts new file mode 100644 index 000000000..6a2706f29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -0,0 +1,148 @@ +import { PaginatedResponse } from '@edr/types'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, IsNull, Repository } from 'typeorm'; + +import { paginateQuery } from '../../common/utils/pagination.util'; +import { CreateOperationsTargetDto } from './dto/create-operations-target.dto'; +import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto'; +import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto'; +import { OperationsTarget, TargetPeriodType } from './entities/operations-target.entity'; + +/** + * Normalises any date inside a bucket to the bucket's first day, matching + * Postgres `date_trunc` — which is what the reports group by. Week starts + * Monday, the same as `date_trunc('week', …)` and ISO week numbering. + * + * Done in UTC throughout: the stored column is a bare `date`, and running the + * arithmetic in local time would shift a 1st-of-month target into the previous + * month for anyone east of Greenwich. + */ +export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string { + const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); + switch (periodType) { + case 'week': { + // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. + const offset = (d.getUTCDay() + 6) % 7; + d.setUTCDate(d.getUTCDate() - offset); + break; + } + case 'month': + d.setUTCDate(1); + break; + case 'quarter': + d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); + break; + case 'year': + d.setUTCMonth(0, 1); + break; + } + return d.toISOString().slice(0, 10); +} + +@Injectable() +export class OperationsTargetsService { + constructor( + @InjectRepository(OperationsTarget) + private readonly repository: Repository, + ) {} + + findAll(query: ListOperationsTargetsQueryDto): Promise> { + const sortable: Record = { + periodStart: 'target.period_start', + metric: 'target.metric', + dimension: 'target.dimension', + dimensionKey: 'target.dimension_key', + plannedValue: 'target.planned_value', + createdAt: 'target.created_at', + }; + const sortBy = sortable[query.sortBy ?? ''] ?? sortable.periodStart; + + const qb = this.repository + .createQueryBuilder('target') + .orderBy(sortBy, query.sortOrder ?? 'DESC') + .addOrderBy('target.dimension_key', 'ASC'); + + if (query.periodType) qb.andWhere('target.period_type = :pt', { pt: query.periodType }); + if (query.metric) qb.andWhere('target.metric = :m', { m: query.metric }); + if (query.dimension) qb.andWhere('target.dimension = :d', { d: query.dimension }); + if (query.search) { + qb.andWhere( + new Brackets((w) => + w + .where('target.dimension_key ILIKE :s', { s: `%${query.search}%` }) + .orWhere('target.note ILIKE :s', { s: `%${query.search}%` }), + ), + ); + } + + return paginateQuery(qb, query); + } + + async findById(id: string): Promise { + const found = await this.repository.findOne({ where: { id } }); + if (!found) throw new NotFoundException(`Operations target ${id} not found`); + return found; + } + + async create(dto: CreateOperationsTargetDto): Promise { + const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); + await this.assertSlotFree({ ...dto, periodStart }); + return this.repository.save(this.repository.create({ ...dto, periodStart })); + } + + async update(id: string, dto: UpdateOperationsTargetDto): Promise { + const current = await this.findById(id); + const periodType = dto.periodType ?? current.periodType; + const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart); + const next = { + periodType, + periodStart, + metric: dto.metric ?? current.metric, + dimension: dto.dimension ?? current.dimension, + dimensionKey: dto.dimensionKey ?? current.dimensionKey, + }; + await this.assertSlotFree(next, id); + + await this.repository.update(id, { + ...next, + ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), + ...(dto.note !== undefined ? { note: dto.note } : {}), + }); + return this.findById(id); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + /** + * One planned number per (period, metric, dimension value). The database + * enforces this too — the check is here to turn a 23505 into a message that + * says which slot is taken. + */ + private async assertSlotFree( + slot: Pick< + OperationsTarget, + 'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' + >, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findOne({ + where: { + periodType: slot.periodType, + periodStart: slot.periodStart, + metric: slot.metric, + dimension: slot.dimension, + dimensionKey: slot.dimensionKey, + deletedAt: IsNull(), + }, + }); + if (existing && existing.id !== ignoreId) { + throw new ConflictException( + `A ${slot.metric} target for ${slot.dimensionKey} in the ${slot.periodType} starting ${slot.periodStart} already exists`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts new file mode 100644 index 000000000..22041146a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -0,0 +1,139 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + ACTUAL_TONS_EXPR, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CARGO_CATEGORY_LABEL_EXPR, + COUNTRY_FILTER, + LOADED_WAGONS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TEU_EXPR, + allocationLedgerQb, + applyCategoryFilter, + implementRateExpr, + plannedValueExpr, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; + +/** The two sides of the line. Anything else is ignored rather than interpolated. */ +const COUNTRIES = ['Ethiopia', 'Djibouti']; + +const countryOf = (params: Record): string | null => { + const value = String(params.country ?? ''); + return COUNTRIES.includes(value) ? value : null; +}; + +/** + * Which end of the corridor this report calls "the station". + * + * With a country chosen it is that country's end — the Ethiopian view lists + * GMP, Modjo, Adama and the rest; the Djibouti view lists DMP, DCT and Nagad, + * which is the second format the spec asks for. With no country chosen it is + * the destination, so the report still reads sensibly. + * + * The country is whitelisted above before it reaches the SQL: it arrives as a + * filter value, and a CASE expression cannot take a bound parameter here + * because the same expression has to appear verbatim in the GROUP BY. + */ +const stationExpr = (params: Record, column: string): string => { + const country = countryOf(params); + if (!country) return `dy.${column}`; + return `CASE WHEN oy.country = '${country}' THEN oy.${column} ELSE dy.${column} END`; +}; + +/** The other end of the same corridor — the spec's "origination" column. */ +const originationExpr = (params: Record, column: string): string => { + const country = countryOf(params); + if (!country) return `oy.${column}`; + return `CASE WHEN oy.country = '${country}' THEN dy.${column} ELSE oy.${column} END`; +}; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + + const country = countryOf(ctx.params); + // Only corridors that touch the chosen side have a station on it. + if (country) { + qb.andWhere('(oy.country = :sideCountry OR dy.country = :sideCountry)', { + sideCountry: country, + }); + } + return qb; +} + +export const cargoVolumeByStationReport: ReportDefinition = { + key: 'cargo-volume-by-station', + title: 'Cargo Volume by Station', + description: + 'Tonnage by station and cargo type against plan. Choose a country to switch between ' + + 'the Ethiopian view (GMP, Modjo, Dire Dawa, Adama, Sebeta) and the Djibouti view ' + + '(DMP, DCT, Nagad), which changes which end of the corridor counts as the station and ' + + 'which counts as the origination. Plan comes from Operational targets, keyed on the ' + + 'station’s yard code.', + group: 'Operations', + filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'origination', label: 'Origination', type: 'string' }, + { key: 'category', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, + { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'wagons', label: 'Wagons', type: 'number' }, + { key: 'trains', label: 'Trains', type: 'number' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'station', y: ['operated'] }, + query(ctx) { + const { params } = ctx; + const bucket = periodTruncExprOn(OPS_DATE, params); + const stationCode = stationExpr(params, 'code'); + const plan = plannedValueExpr( + 'VOLUME_TONS', + 'station', + stationCode, + `'${resolvePeriod(params).trunc}'`, + bucket, + ); + + return baseQuery(ctx) + .select(periodExprOn(OPS_DATE, params), 'period') + .addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station') + .addSelect( + `COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`, + 'origination', + ) + .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated') + .addSelect(`${plan}::float8`, 'plan') + .addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate') + .addSelect(TEU_EXPR, 'teu') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .groupBy(bucket) + .addGroupBy(stationCode) + .addGroupBy(stationExpr(params, 'label')) + .addGroupBy(originationExpr(params, 'label')) + .addGroupBy(originationExpr(params, 'code')) + .addGroupBy(CARGO_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual') + .addSelect(`COUNT(DISTINCT ${stationExpr(ctx.params, 'code')})::int`, 'stations') + .addSelect(TEU_EXPR, 'teu') + .getRawOne<{ actual: number; stations: number; teu: number }>(); + + return [ + { label: 'Volume', value: Number(row?.actual ?? 0), unit: 't' }, + { label: 'Stations', value: Number(row?.stations ?? 0) }, + { label: 'TEU', value: Number(row?.teu ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts new file mode 100644 index 000000000..3f1fb4c87 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -0,0 +1,89 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + ACTUAL_TONS_EXPR, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CARGO_CATEGORY_LABEL_EXPR, + CHARGED_TONS_EXPR, + LOADED_WAGONS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TEU_EXPR, + allocationLedgerQb, + applyCategoryFilter, + implementRateExpr, + plannedValueExpr, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + return qb; +} + +const planned = (params: Record): string => + plannedValueExpr( + 'VOLUME_TONS', + 'cargo_category', + CARGO_CATEGORY_EXPR, + `'${resolvePeriod(params).trunc}'`, + periodTruncExprOn(OPS_DATE, params), + ); + +export const cargoVolumePerformanceReport: ReportDefinition = { + key: 'cargo-volume-performance', + title: 'Cargo Volume Performance', + description: + 'Tonnage moved per cargo category against plan. Operated is the actual loaded weight ' + + 'from the marshalling record; charged volume is the standard weight capacity the same ' + + 'cargo is billed on. Plan comes from Operational targets and is measured against the ' + + 'actual, not the charged, tonnage.', + group: 'Operations', + filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, + { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, + { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, + { key: 'trains', label: 'Trains', type: 'number' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'category', y: ['operated'] }, + query(ctx) { + const bucket = periodTruncExprOn(OPS_DATE, ctx.params); + const plan = planned(ctx.params); + return baseQuery(ctx) + .select(periodExprOn(OPS_DATE, ctx.params), 'period') + .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect(CARGO_CATEGORY_EXPR, 'categoryKey') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated') + .addSelect(`${plan}::float8`, 'plan') + .addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'chargedTons') + .addSelect(TEU_EXPR, 'teu') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .groupBy(bucket) + .addGroupBy(CARGO_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged') + .addSelect(TEU_EXPR, 'teu') + .getRawOne<{ actual: number; charged: number; teu: number }>(); + + return [ + { label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' }, + { label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' }, + { label: 'TEU', value: Number(row?.teu ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts new file mode 100644 index 000000000..6da342e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -0,0 +1,110 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + ACTUAL_TONS_EXPR, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CARGO_CATEGORY_LABEL_EXPR, + CHARGED_TONS_EXPR, + LOADED_WAGONS_EXPR, + OPERATIONS_FILTERS, + SCHEDULE_EMPTY_WAGONS, + SCHEDULE_KM_EXPR, + TEU_EXPR, + allocationLedgerQb, + applyCategoryFilter, +} from '../operations-classification'; + +/** + * Distance and empty-wagon count belong to the departure, so they are constant + * within a group that includes `ts.id` — MAX() satisfies Postgres without + * dragging a scalar subselect through the GROUP BY. + */ +const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`; +const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; + +/** + * Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no + * configured distance. A missing distance is not a zero distance, and zeroing + * it would understate the corridor's work without anyone noticing. + */ +const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`; +const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + return qb; +} + +export const chargedVsActualVolumeReport: ReportDefinition = { + key: 'charged-vs-actual-volume', + title: 'Charged and Actual Volumes', + description: + 'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' + + 'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' + + '2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' + + 'all editable in Operating standards. Actual volume is what the marshalling recorded. ' + + 'Vehicle-Km counts the empty wagons on that train, so it repeats across the train’s ' + + 'cargo types rather than being split between them.', + group: 'Operations', + filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' }, + { key: 'station', label: 'Station', type: 'string' }, + { key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR }, + { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, + { key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'wagons', label: 'Loaded wagons', type: 'number' }, + { key: 'emptyWagons', label: 'Empty wagons', type: 'number' }, + { key: 'distanceKm', label: 'Distance (km)', type: 'number' }, + { key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true }, + { key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' }, + ], + defaultSort: { key: 'departedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(ts.train_number, '—')", 'trainNumber') + .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt') + .addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station') + .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons') + .addSelect(TEU_EXPR, 'teu') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons') + .addSelect(`${ROUTE_KM}::float8`, 'distanceKm') + .addSelect(TON_KM, 'tonKm') + .addSelect(VEHICLE_KM, 'vehicleKm') + .groupBy('ts.id') + .addGroupBy('ts.train_number') + .addGroupBy('ts.actual_departure_at') + .addGroupBy('ts.scheduled_departure_date') + .addGroupBy('oy.label') + .addGroupBy('oy.code') + .addGroupBy('dy.label') + .addGroupBy('dy.code') + .addGroupBy(CARGO_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual') + .addSelect( + `COUNT(DISTINCT ts.id) FILTER (WHERE ${SCHEDULE_KM_EXPR} IS NULL)::int`, + 'unmeasuredTrains', + ) + .getRawOne<{ charged: number; actual: number; unmeasuredTrains: number }>(); + + return [ + { label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' }, + { label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' }, + // Always shown, even at zero: a corridor with no configured distance + // silently drops out of Ton/Km, and that must be visible. + { label: 'Trains without a configured distance', value: Number(row?.unmeasuredTrains ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts new file mode 100644 index 000000000..cedc7d08e --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts @@ -0,0 +1,148 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity'; +import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ReportContext, ReportDefinition } from '../report.types'; +import { + COUNTRY_FILTER, + DIRECTION_FILTER, + OPS_DATE, + STANDARDS_JOIN, + STATION_STANDARD_HOURS_EXPR, + hoursBetween, +} from '../operations-classification'; + +const ARRIVAL = "MIN(ev.occurred_at) FILTER (WHERE ev.kind = 'ARRIVED')"; +const DEPARTURE = "MAX(ev.occurred_at) FILTER (WHERE ev.kind = 'DEPARTED')"; +const STAYING_HOURS = hoursBetween(ARRIVAL, DEPARTURE); +const STANDARD_HOURS = `MAX(${STATION_STANDARD_HOURS_EXPR})`; + +/** + * Station staying time, from the checkpoints staff log as a train works a stop: + * departure minus arrival at the same station. + * + * The spec also asks for total loading and unloading time and for "other + * activity" (staying time minus the two). Neither is built here, because + * nothing in the schema records when loading or unloading STARTED and ENDED — + * the checkpoint kinds are only ARRIVED, DEPARTED and PASSED, and + * `facility_handling_events` stamps a single moment per booking, not a window. + * Those two columns arrive when that capture does; the staying time this report + * measures is unaffected by their absence. + */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(TrainCheckpointEvent, 'ev') + .innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL') + .innerJoin(Yard, 'y', 'y.id = ev.yard_id') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('ev.deleted_at IS NULL') + .andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')"); + + if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { + trainNumber: `%${params.trainNumber as string}%`, + }); + } + if (params.station) qb.andWhere('y.code = :station', { station: params.station }); + if (params.country) qb.andWhere('y.country = :country', { country: params.country }); + + applyDirectionScope(qb, 'ts.direction', directions); + return qb; +} + +/** Only stops where both ends of the stay were logged can be measured. */ +const COMPLETE_STOP = `${ARRIVAL} IS NOT NULL AND ${DEPARTURE} IS NOT NULL`; + +export const stationStayingTimeReport: ReportDefinition = { + key: 'station-staying-time', + title: 'Station Staying Time', + description: + 'How long each train stood at each station — departure minus arrival on the logged ' + + 'checkpoints — against the standard for that side of the line (10h Ethiopia, 13h ' + + 'Djibouti, both editable in Operating standards). A stop over standard needs a reason. ' + + 'Loading and unloading times are not shown: nothing in the system records when they ' + + 'start and end yet.', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'station', label: 'Station', type: 'text' }, + COUNTRY_FILTER, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 'y.label' }, + { key: 'country', label: 'Country', type: 'string' }, + { key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: ARRIVAL }, + { key: 'departedAt', label: 'Departed', type: 'date' }, + { key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS }, + { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, + { key: 'varianceHours', label: 'Variance (hrs)', type: 'number' }, + { key: 'verdict', label: 'Verdict', type: 'string' }, + { key: 'reason', label: 'Reason', type: 'string' }, + ], + defaultSort: { key: 'arrivedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect("COALESCE(y.label, y.code, '—')", 'station') + .addSelect("COALESCE(y.country, '—')", 'country') + .addSelect(`to_char(${ARRIVAL}, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') + .addSelect(`to_char(${DEPARTURE}, 'YYYY-MM-DD HH24:MI')`, 'departedAt') + .addSelect(STAYING_HOURS, 'stayingHours') + .addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours') + .addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours') + .addSelect( + `CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS} + THEN 'Encouraging' ELSE 'Needs reason' END`, + 'verdict', + ) + // The note staff leave on a checkpoint is the only free text on the stop, + // so it is where a reason for an over-standard stay is recorded today. + .addSelect("COALESCE(MAX(ev.note) FILTER (WHERE ev.note IS NOT NULL), '')", 'reason') + .groupBy('ts.id') + .addGroupBy('ts.train_number') + .addGroupBy('y.id') + .addGroupBy('y.label') + .addGroupBy('y.code') + .addGroupBy('y.country') + .having(COMPLETE_STOP); + }, + async summary(ctx) { + const inner = baseQuery(ctx) + .select('1', 'one') + .addSelect(STAYING_HOURS, 'staying') + .addSelect(STANDARD_HOURS, 'standard') + .groupBy('ts.id') + .addGroupBy('y.id') + .addGroupBy('y.country') + .having(COMPLETE_STOP); + + const row = await ctx.ds + .createQueryBuilder() + .select('COUNT(*)::int', 'stops') + .addSelect('ROUND(AVG(s.staying)::numeric, 1)::float8', 'avgHours') + .addSelect('COUNT(*) FILTER (WHERE s.staying > s.standard)::int', 'overStandard') + .from(`(${inner.getQuery()})`, 's') + .setParameters(inner.getParameters()) + .getRawOne<{ stops: number; avgHours: number; overStandard: number }>(); + + return [ + { label: 'Stops measured', value: Number(row?.stops ?? 0) }, + { label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' }, + { label: 'Over standard', value: Number(row?.overStandard ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts new file mode 100644 index 000000000..5ecf6bbd1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -0,0 +1,103 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CONTAINER_CLASSES, + CONTAINER_CLASS_EXPR, + CONTAINER_CLASS_LABEL_EXPR, + CONTAINERS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TEU_EXPR, + allocationLedgerQb, + implementRateExpr, + plannedValueExpr, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; + +const CONTAINERS_20 = `COALESCE(SUM(( + SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL + AND cty.size_ft = 20)), 0)::int`; + +const CONTAINERS_40 = `COALESCE(SUM(( + SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL + AND cty.size_ft >= 40)), 0)::int`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx).andWhere("wba.load_type = 'CONTAINER'"); + const classes = ctx.params.classes as string[] | null; + if (classes?.length) { + qb.andWhere(`${CONTAINER_CLASS_EXPR} IN (:...classes)`, { classes }); + } + return qb; +} + +const planned = (params: Record): string => + plannedValueExpr( + 'TEU', + 'container_class', + CONTAINER_CLASS_EXPR, + `'${resolvePeriod(params).trunc}'`, + periodTruncExprOn(OPS_DATE, params), + ); + +export const teuPerformanceReport: ReportDefinition = { + key: 'teu-performance', + title: 'TEU Performance', + description: + 'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' + + 'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' + + 'marshalling record — the containers actually allocated to wagons — not from the ' + + 'billing lines. Plan comes from Operational targets.', + group: 'Operations', + filters: [ + PERIOD_FILTER, + ...OPERATIONS_FILTERS, + { key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES }, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'containerClass', label: 'Container type', type: 'string', sortable: true }, + { key: 'containers20', label: '20ft', type: 'number', sortable: true }, + { key: 'containers40', label: '40ft', type: 'number', sortable: true }, + { key: 'containers', label: 'Containers', type: 'number', sortable: true }, + { key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true }, + { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, + query(ctx) { + const bucket = periodTruncExprOn(OPS_DATE, ctx.params); + const plan = planned(ctx.params); + return baseQuery(ctx) + .select(periodExprOn(OPS_DATE, ctx.params), 'period') + .addSelect(CONTAINER_CLASS_LABEL_EXPR, 'containerClass') + .addSelect(CONTAINER_CLASS_EXPR, 'containerClassKey') + .addSelect(CONTAINERS_20, 'containers20') + .addSelect(CONTAINERS_40, 'containers40') + .addSelect(CONTAINERS_EXPR, 'containers') + .addSelect(TEU_EXPR, 'operated') + .addSelect(`${plan}::float8`, 'plan') + .addSelect(implementRateExpr(TEU_EXPR, plan), 'implementRate') + .groupBy(bucket) + .addGroupBy(CONTAINER_CLASS_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(TEU_EXPR, 'teu') + .addSelect(CONTAINERS_EXPR, 'containers') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .getRawOne<{ teu: number; containers: number; trains: number }>(); + + return [ + { label: 'TEU', value: Number(row?.teu ?? 0) }, + { label: 'Containers', value: Number(row?.containers ?? 0) }, + { label: 'Trains', value: Number(row?.trains ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts new file mode 100644 index 000000000..2c03d4d70 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts @@ -0,0 +1,104 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + DELAY_TOLERANCE_HOURS_EXPR, + DIRECTION_FILTER, + hoursBetween, + legStandardHours, + scheduleLedgerQb, +} from '../operations-classification'; + +const ACTUAL_HOURS = hoursBetween('ts.actual_departure_at', 'ts.actual_arrival_at'); +const STANDARD_HOURS = legStandardHours('ts.origin_station_id', 'ts.destination_station_id'); +const DELAY_HOURS = `ROUND((${ACTUAL_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`; +const IS_DELAYED = `(${ACTUAL_HOURS})::numeric > ${STANDARD_HOURS} + ${DELAY_TOLERANCE_HOURS_EXPR}`; + +/** + * The note staff left when they logged the arrival — the only free text on the + * leg, and so the only place a delay reason is recorded today. + */ +const ARRIVAL_NOTE = `( + SELECT e.note FROM freight.train_checkpoint_events e + WHERE e.train_schedule_id = ts.id AND e.deleted_at IS NULL + AND e.kind = 'ARRIVED' AND e.note IS NOT NULL + ORDER BY e.occurred_at DESC LIMIT 1 +)`; + +/** + * Leg running time against the corridor standard. + * + * The leg measured is the departure's own origin → destination, on actual + * timestamps. Per-station legs would be finer, but only the corridor ends carry + * a configured standard (`yard_distances.standard_hours`), which is what a + * delay is judged against. + */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = scheduleLedgerQb(ctx) + .andWhere('ts.actual_departure_at IS NOT NULL') + .andWhere('ts.actual_arrival_at IS NOT NULL'); + + if (ctx.params.delayedOnly === 'true') qb.andWhere(IS_DELAYED); + return qb; +} + +export const trainDelaysReport: ReportDefinition = { + key: 'train-delays', + title: 'Train Delays', + description: + 'Actual running time per leg against the corridor standard — 21h Negad→GMP and the ' + + 'per-pair figures configured on Yard Distances, with the default and the tolerance ' + + '(30 min) in Operating standards. A leg over standard plus tolerance is flagged and ' + + 'needs a reason.', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { + key: 'delayedOnly', + label: 'Delayed only', + type: 'select', + options: [{ value: 'true', label: 'Delayed legs only' }], + }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'origin', label: 'From', type: 'string' }, + { key: 'destination', label: 'To', type: 'string' }, + { key: 'departedAt', label: 'Departed', type: 'date', sortable: true, sortExpr: 'ts.actual_departure_at' }, + { key: 'arrivedAt', label: 'Arrived', type: 'date' }, + { key: 'actualHours', label: 'Actual (hrs)', type: 'number', sortable: true, sortExpr: ACTUAL_HOURS }, + { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, + { key: 'delayHours', label: 'Delay (hrs)', type: 'number', sortable: true, sortExpr: DELAY_HOURS }, + { key: 'status', label: 'Status', type: 'string' }, + { key: 'reason', label: 'Reason', type: 'string' }, + ], + defaultSort: { key: 'departedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(ts.train_number, '—')", 'trainNumber') + .addSelect("COALESCE(oy.label, oy.code, '?')", 'origin') + .addSelect("COALESCE(dy.label, dy.code, '?')", 'destination') + .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt') + .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') + .addSelect(ACTUAL_HOURS, 'actualHours') + .addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours') + .addSelect(DELAY_HOURS, 'delayHours') + .addSelect(`CASE WHEN ${IS_DELAYED} THEN 'Delayed' ELSE 'On time' END`, 'status') + .addSelect(`COALESCE(${ARRIVAL_NOTE}, '')`, 'reason'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'legs') + .addSelect(`COUNT(*) FILTER (WHERE ${IS_DELAYED})::int`, 'delayed') + .addSelect(`ROUND(AVG((${ACTUAL_HOURS})::numeric), 1)::float8`, 'avgHours') + .getRawOne<{ legs: number; delayed: number; avgHours: number }>(); + + return [ + { label: 'Legs', value: Number(row?.legs ?? 0) }, + { label: 'Delayed', value: Number(row?.delayed ?? 0) }, + { label: 'Average running time', value: Number(row?.avgHours ?? 0), unit: 'h' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts new file mode 100644 index 000000000..8ad9bf70d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -0,0 +1,97 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CARGO_CATEGORY_LABEL_EXPR, + LOADED_WAGONS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TRAINSETS_EXPR, + allocationLedgerQb, + applyCategoryFilter, + implementRateExpr, + plannedValueExpr, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + return qb; +} + +const planned = (params: Record): string => + plannedValueExpr( + 'TRAINSET', + 'cargo_category', + CARGO_CATEGORY_EXPR, + `'${resolvePeriod(params).trunc}'`, + periodTruncExprOn(OPS_DATE, params), + ); + +export const trainsetPerformanceReport: ReportDefinition = { + key: 'trainset-performance', + title: 'Trainset Performance', + description: + 'Trainsets operated per cargo category against plan. A trainset is the wagons actually ' + + 'loaded divided by a full trainset for that cargo (37 for vehicles, 22 for sand, ' + + 'otherwise the default of 50 — all editable on Cargo Types and Operating standards), so ' + + '30 wagons of a 50-wagon set reads 0.6. Plan comes from Operational targets; a period ' + + 'with no target shows no plan rather than a zero.', + group: 'Operations', + filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, + { key: 'trains', label: 'Trains', type: 'number', sortable: true }, + { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, + { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, + { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'category', y: ['operated'] }, + query(ctx) { + const bucket = periodTruncExprOn(OPS_DATE, ctx.params); + const plan = planned(ctx.params); + return baseQuery(ctx) + .select(periodExprOn(OPS_DATE, ctx.params), 'period') + .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect(CARGO_CATEGORY_EXPR, 'categoryKey') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect(TRAINSETS_EXPR, 'operated') + .addSelect(`${plan}::float8`, 'plan') + .addSelect(implementRateExpr(TRAINSETS_EXPR, plan), 'implementRate') + .groupBy(bucket) + .addGroupBy(CARGO_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(DISTINCT ts.id)::int', 'trains') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + // Wagons on the same departures that carried nothing. Joined rather than + // sub-selected so COUNT(DISTINCT) de-duplicates the fan-out across the + // allocation rows. + .leftJoin( + TrainSetWagon, + 'etw', + `etw.train_set_id = ts.train_set_id AND etw.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = etw.id AND a.deleted_at IS NULL)`, + ) + .addSelect('COUNT(DISTINCT etw.id)::int', 'emptyWagons') + .getRawOne<{ trains: number; wagons: number; emptyWagons: number }>(); + + return [ + { label: 'Trains', value: Number(row?.trains ?? 0) }, + { label: 'Wagons loaded', value: Number(row?.wagons ?? 0) }, + // The spec's "empty train" line: wagons that rode with nothing on them. + { label: 'Empty wagons', value: Number(row?.emptyWagons ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts new file mode 100644 index 000000000..de55dd32b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts @@ -0,0 +1,143 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CYCLE_STANDARD_HOURS_EXPR, + DIRECTION_FILTER, + cycleRateExpr, + hoursBetween, + scheduleLedgerQb, +} from '../operations-classification'; + +/** + * A turn-around cycle is a whole out-and-back: Djibouti → Ethiopia → Djibouti + * (DCT1 → GMP1 → GMP2 → DCT2 in the spec's notation). That spans TWO + * departures, so the cycle's end is the NEXT departure's arrival for the same + * physical train — `lead()` over the train's departures. + * + * Trains are paired by `train_sets.train_id`, the physical consist. A train set + * is one-to-one with a departure, so pairing by set alone would never find a + * second leg; where a set has no train it falls back to the set id, which + * yields a null cycle rather than pairing two unrelated trains. + */ +const CYCLE_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)'; +const CYCLE_ORDER = 'ts.actual_departure_at'; +const lead = (column: string): string => + `lead(${column}) OVER (PARTITION BY ${CYCLE_KEY} ORDER BY ${CYCLE_ORDER})`; + +/** + * Hours a train stood still on one side of the line during the cycle. + * + * Reads the same ARRIVED/DEPARTED checkpoint pairs as the station-staying-time + * report, over both legs of the cycle. Trains whose stops were never logged + * report 0 here — which is why the travelling column is derived by subtraction + * and can read as the whole cycle on an unlogged train. + */ +const stayHours = (country: string): string => `( + SELECT COALESCE(ROUND(SUM(EXTRACT(EPOCH FROM (q.dep - q.arr)) / 3600)::numeric, 1), 0) + FROM ( + SELECT MIN(e.occurred_at) FILTER (WHERE e.kind = 'ARRIVED') AS arr, + MAX(e.occurred_at) FILTER (WHERE e.kind = 'DEPARTED') AS dep + FROM freight.train_checkpoint_events e + JOIN freight.yards yy ON yy.id = e.yard_id + WHERE e.deleted_at IS NULL + AND yy.country = '${country}' + AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id) + GROUP BY e.train_schedule_id, e.yard_id + ) q + WHERE q.arr IS NOT NULL AND q.dep IS NOT NULL +)`; + +const ETHIOPIA_HOURS = stayHours('Ethiopia'); +const DJIBOUTI_HOURS = stayHours('Djibouti'); +const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end'); +const TRAVEL_HOURS = `ROUND(GREATEST((${AD_HOURS})::numeric - ${ETHIOPIA_HOURS} - ${DJIBOUTI_HOURS}, 0), 1)::float8`; + +/** The completed cycles, before the per-cycle stay decomposition. */ +function cycleQuery(ctx: ReportContext): SelectQueryBuilder { + return scheduleLedgerQb(ctx) + .leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL') + .andWhere('ts.actual_departure_at IS NOT NULL') + .select('ts.id', 'schedule_id') + .addSelect('ts.train_number', 'train_number') + .addSelect('ts.direction', 'direction') + .addSelect("COALESCE(oy.label, oy.code, '?')", 'origin') + .addSelect("COALESCE(dy.label, dy.code, '?')", 'destination') + .addSelect('ts.actual_departure_at', 'cycle_start') + .addSelect(lead('ts.actual_arrival_at'), 'cycle_end') + .addSelect(lead('ts.id'), 'return_schedule_id') + .addSelect(`ROUND(${CYCLE_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours'); +} + +/** Wraps the cycle rows so the window results can be filtered and measured. */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const inner = cycleQuery(ctx); + return ctx.ds + .createQueryBuilder() + .from(`(${inner.getQuery()})`, 'c') + .setParameters(inner.getParameters()) + .where('c.cycle_end IS NOT NULL'); +} + +export const turnaroundCycleReport: ReportDefinition = { + key: 'turnaround-cycle', + title: 'Turnaround Cycle', + description: + 'Full out-and-back cycle per train: actual duration against the standard cycle ' + + '(65h container, 88h bulk via DMP, 96h via Negad or BCC — editable in Operating ' + + 'standards). Implement rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on ' + + 'standard scores 100. The Ethiopia, Djibouti and travelling split comes from logged ' + + 'station checkpoints and reads zero for a train whose stops were never logged.', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'c.train_number' }, + { key: 'route', label: 'Route', type: 'string' }, + { key: 'cycleStart', label: 'Cycle start', type: 'date', sortable: true, sortExpr: 'c.cycle_start' }, + { key: 'cycleEnd', label: 'Cycle end', type: 'date' }, + { key: 'adHours', label: 'Average duration (hrs)', type: 'number', sortable: true, sortExpr: AD_HOURS }, + { key: 'scHours', label: 'Standard cycle (hrs)', type: 'number' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true }, + { key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' }, + { key: 'djiboutiHours', label: 'Djibouti stay (hrs)', type: 'number' }, + { key: 'travellingHours', label: 'Travelling (hrs)', type: 'number' }, + { key: 'averageDays', label: 'Average day', type: 'number' }, + ], + defaultSort: { key: 'cycleStart', dir: 'DESC' }, + chart: { type: 'bar', x: 'trainNumber', y: ['adHours'] }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(c.train_number, '—')", 'trainNumber') + .addSelect("c.origin || ' → ' || c.destination", 'route') + .addSelect(`to_char(c.cycle_start, 'YYYY-MM-DD HH24:MI')`, 'cycleStart') + .addSelect(`to_char(c.cycle_end, 'YYYY-MM-DD HH24:MI')`, 'cycleEnd') + .addSelect(AD_HOURS, 'adHours') + .addSelect('c.standard_hours::float8', 'scHours') + .addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate') + .addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours') + .addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours') + .addSelect(TRAVEL_HOURS, 'travellingHours') + .addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'cycles') + .addSelect(`ROUND(AVG((${AD_HOURS})::numeric), 1)::float8`, 'avgHours') + .addSelect( + `ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`, + 'avgRate', + ) + .getRawOne<{ cycles: number; avgHours: number; avgRate: number }>(); + + return [ + { label: 'Cycles', value: Number(row?.cycles ?? 0) }, + { label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' }, + { label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts new file mode 100644 index 000000000..63a7db20b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -0,0 +1,94 @@ +import { + CARGO_CATEGORIES, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_LABEL_EXPR, + CONTAINER_CLASSES, + CONTAINER_CLASS_EXPR, + TARGET_DIMENSION_KEYS, + cycleRateExpr, + implementRateExpr, +} from './operations-classification'; +import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity'; + +/** + * Every key a classification CASE can emit, read straight off the expression. + * The categories are the join key between a report and its planned target, so a + * key the reports emit but the target dimension list does not offer is a plan + * nobody can ever enter. + */ +function emittedKeys(expr: string): string[] { + return [...expr.matchAll(/THEN '([A-Z_]+)'/g)] + .map(([, key]) => key) + .concat([...expr.matchAll(/ELSE '([A-Z_]+)'/g)].map(([, key]) => key)); +} + +describe('operations classification', () => { + it('offers every cargo category the expression can emit as a filter option', () => { + const offered = new Set(CARGO_CATEGORIES.map((o) => o.value)); + const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k)); + expect(missing).toEqual([]); + }); + + it('offers every container class the expression can emit', () => { + const offered = new Set(CONTAINER_CLASSES.map((o) => o.value)); + const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k)); + expect(missing).toEqual([]); + }); + + it('labels every category, leaving none showing a raw key', () => { + for (const option of CARGO_CATEGORIES) { + expect(CARGO_CATEGORY_LABEL_EXPR).toContain(`'${option.label}'`); + } + }); + + /** + * A planner types a dimension key into the targets screen; the reports match + * it against what their CASE emits. If the two lists ever drift, a target is + * silently ignored — the report shows no plan and nobody is told why. + */ + it('accepts every emitted key as a target dimension key', () => { + const emitted = [ + ...new Set([ + ...emittedKeys(CARGO_CATEGORY_EXPR), + ...emittedKeys(CONTAINER_CLASS_EXPR), + ]), + ]; + const unplannable = emitted.filter((k) => !TARGET_DIMENSION_KEYS.includes(k)); + expect(unplannable).toEqual([]); + }); + + it('keeps the target metric and dimension vocabularies non-empty and distinct', () => { + expect(new Set(TARGET_METRICS).size).toBe(TARGET_METRICS.length); + expect(new Set(TARGET_DIMENSIONS).size).toBe(TARGET_DIMENSIONS.length); + }); + + /** + * The spec's worked example: a full trainset holds 50 wagons, 30 of them + * carry multimodal cargo, so that cargo operated 0.6 trainsets. The SQL does + * this division; this checks the arithmetic the SQL encodes. + */ + it('matches the spec worked example for trainsets', () => { + expect(Number((30 / 50).toFixed(2))).toBe(0.6); + }); + + /** Ten 40ft boxes and thirty 20ft boxes is fifty TEU, not forty. */ + it('matches the spec worked example for TEU', () => { + expect(10 * 2 + 30 * 1).toBe(50); + }); + + it('divides by NULLIF so a missing plan yields no rate rather than infinity', () => { + expect(implementRateExpr('operated', 'planned')).toContain('NULLIF(planned, 0)'); + }); + + /** + * [(SC − AD) / SC + 1] × 100 — finishing exactly on standard scores 100, and + * beating it scores above 100. Guards the sign, which is easy to invert. + */ + it('encodes the turnaround rate so on-standard is 100 and faster is more', () => { + const rate = (sc: number, ad: number) => ((sc - ad) / sc + 1) * 100; + expect(rate(65, 65)).toBe(100); + expect(rate(65, 52)).toBeGreaterThan(100); + expect(rate(65, 78)).toBeLessThan(100); + expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts new file mode 100644 index 000000000..016c1d846 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -0,0 +1,525 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; +import { yardOptions } from './revenue-classification'; + +/** + * The shared vocabulary and SQL behind every operations report — turnaround, + * delay, trainset, TEU and cargo volume. + * + * The fact table is `wagon_booking_allocations`: one row is one booking's cargo + * on one wagon of one departure. That is the marshalling record — what was + * actually put on the train — and it is the only grain that can answer both + * "how many TEU moved" and "how many wagons did it take", which the volume and + * trainset reports need together. + * + * Every consumer builds its FROM through {@link allocationLedgerQb}, so the + * table aliases below (`wba tsw ts b ct oy dy std`) are a fixed contract and + * the fragments here reference them directly. + * + * This is deliberately a SECOND classification module rather than an extension + * of `revenue-classification.ts`. That one classifies invoice lines by charge + * code; this one classifies physical cargo by booking and cargo type. The two + * answer different questions and a row that is one revenue category can be a + * different operational category — an incidental charge on a container booking, + * for instance, is INCIDENTAL revenue but container tonnage. + */ + +// --------------------------------------------------------------------------- +// Cargo categories +// --------------------------------------------------------------------------- + +export const CARGO_CATEGORIES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' }, + { value: 'CONTAINER_EXPORT', label: 'Export container' }, + { value: 'EMPTY_CONTAINER', label: 'Empty container' }, + { value: 'FERTILIZER', label: 'Fertilizer' }, + { value: 'RORO', label: 'RoRo' }, + { value: 'BREAK_BULK', label: 'Break bulk' }, + { value: 'SAND', label: 'Sand' }, + { value: 'BULK', label: 'Bulk' }, + { value: 'OTHER_IMPORT', label: 'Other imports' }, + { value: 'OTHER_EXPORT', label: 'Other export cargo' }, + { value: 'UNCLASSIFIED', label: 'Unclassified' }, +]; + +/** + * Container classes for the TEU report — the four the spec names. + * `EMPTY_CONTAINER_RETURN` is the empty re-export leg. + */ +export const CONTAINER_CLASSES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' }, + { value: 'CONTAINER_EXPORT', label: 'Full export container' }, + { value: 'EMPTY_CONTAINER_RETURN', label: 'Empty container return' }, +]; + +/** `cargo_types.code` is admin-managed, so each set absorbs every spelling seeded so far. */ +export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO']; +export const BREAK_BULK_CODES = [ + 'BREAK_BULK', + 'STEEL_BILLET', + 'STEEL', + 'MACHINERY', + 'PIPES', + 'TIMBER', +]; +export const FERTILIZER_CODES = ['FERTILIZER']; +export const SAND_CODES = ['SAND']; + +/** Cargo charged at the lighter per-wagon rate — vegetables, milk, meat, livestock. */ +export const PERISHABLE_CODES = ['PERISHABLE', 'LIVESTOCK']; + +const quote = (values: string[]): string => values.map((v) => `'${v}'`).join(', '); + +/** + * A booking whose equipment_return is RETURN is the empty-container movement + * itself; WITH_RETURN / WITHOUT_RETURN describe a laden booking's obligation. + * This is the only booking-level marker of an empty box — no table records + * laden-vs-empty on the container row. + */ +const IS_EMPTY_CONTAINER = "b.equipment_return = 'RETURN'"; + +/** + * Multimodal means a named sea carrier is on the booking — the same proxy the + * revenue reports use. There is no explicit multimodal flag; confirm with the + * business before treating this as definitive. + */ +const IS_MULTIMODAL = 'b.shipping_line_id IS NOT NULL'; + +const IS_CONTAINER = "COALESCE(b.freight_type, wba.load_type) = 'CONTAINER'"; + +export const CARGO_CATEGORY_EXPR = `CASE + WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER' + WHEN ${IS_CONTAINER} AND b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${IS_CONTAINER} AND ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL' + WHEN ${IS_CONTAINER} THEN 'CONTAINER_IMPORT_UNIMODAL' + WHEN ct.code IN (${quote(FERTILIZER_CODES)}) THEN 'FERTILIZER' + WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO' + WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK' + WHEN ct.code IN (${quote(SAND_CODES)}) THEN 'SAND' + WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT' + WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT' + WHEN b.id IS NOT NULL THEN 'BULK' + ELSE 'UNCLASSIFIED' +END`; + +export const CONTAINER_CLASS_EXPR = `CASE + WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN' + WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL' + ELSE 'CONTAINER_IMPORT_UNIMODAL' +END`; + +/** + * Every fixed key a planner may enter on the targets screen — the category and + * container-class vocabularies. Station targets are keyed on a yard code, which + * is reference data rather than a fixed list, so they are not enumerated here. + */ +export const TARGET_DIMENSION_KEYS: string[] = [ + ...CARGO_CATEGORIES.map((o) => o.value), + ...CONTAINER_CLASSES.map((o) => o.value), +]; + +/** Turns a key-emitting CASE into a label-emitting one, so a report shows business names. */ +const labelCase = (keyExpr: string, options: ReportFilterOption[]): string => + `CASE ${options + .map((o) => `WHEN (${keyExpr}) = '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) + .join(' ')} ELSE (${keyExpr}) END`; + +export const CARGO_CATEGORY_LABEL_EXPR = labelCase(CARGO_CATEGORY_EXPR, CARGO_CATEGORIES); +export const CONTAINER_CLASS_LABEL_EXPR = labelCase(CONTAINER_CLASS_EXPR, CONTAINER_CLASSES); + +// --------------------------------------------------------------------------- +// Standards +// --------------------------------------------------------------------------- + +/** + * A standard, read off the joined `operations_standards` row. + * + * The fallback is not decoration: the row is seeded by migration, but a report + * must not return zeros — or divide by zero — on an environment where the seed + * has not run. The fallbacks are the spec's own figures. + */ +const stdRow = (column: string, fallback: number): string => + `COALESCE(std.${column}, ${fallback})`; + +/** + * The same value in an aggregate select. `std` is a single joined row, so the + * column is constant across the group — but Postgres still demands it be + * grouped or aggregated, and wrapping it in MAX() is cheaper than dragging it + * through every report's GROUP BY. + */ +const stdAgg = (column: string, fallback: number): string => + `MAX(COALESCE(std.${column}, ${fallback}))`; + +/** Standard hours a train may stand at a station, by the station's country. */ +export const STATION_STANDARD_HOURS_EXPR = `CASE + WHEN y.country = 'Djibouti' THEN ${stdRow('station_standard_hours_djibouti', 13)} + ELSE ${stdRow('station_standard_hours_ethiopia', 10)} +END`; + +/** Whichever end of the corridor is on the Djibouti side, if either is. */ +export const DJIBOUTI_YARD_CODE_EXPR = `CASE + WHEN oy.country = 'Djibouti' THEN oy.code + WHEN dy.country = 'Djibouti' THEN dy.code +END`; + +/** True when the departure carried any container allocation. */ +export const SCHEDULE_IS_CONTAINER = `EXISTS ( + SELECT 1 FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL + WHERE w.train_set_id = ts.train_set_id + AND a.deleted_at IS NULL AND a.load_type = 'CONTAINER' +)`; + +/** + * Standard turn-around cycle for a departure, in hours. Container trains run + * the 65-hour cycle; a bulk cycle depends on which Djibouti terminal it works. + * Schedule grain — it reads `ts`, `oy` and `dy`, not the allocation aliases. + */ +export const CYCLE_STANDARD_HOURS_EXPR = `CASE + WHEN ${SCHEDULE_IS_CONTAINER} THEN ${stdRow('cycle_standard_hours_container', 65)} + WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'DORALEH_MULTIPURPOSE_PORT_DMP' THEN ${stdRow('cycle_standard_hours_bulk_dmp', 88)} + WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'BCC' THEN ${stdRow('cycle_standard_hours_bulk_bcc', 96)} + ELSE ${stdRow('cycle_standard_hours_bulk_nagad', 96)} +END`; + +export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`; + +/** + * Joins the single standards row. Restricted by id to the earliest live row so + * a stray second row could never fan a report's result out. + */ +export const STANDARDS_JOIN = `std.id = ( + SELECT s.id FROM freight.operations_standards s + WHERE s.deleted_at IS NULL ORDER BY s.created_at ASC LIMIT 1 +)`; + +// --------------------------------------------------------------------------- +// Distance +// --------------------------------------------------------------------------- + +/** + * Configured rail distance for a yard pair, in km. Symmetric: `yard_distances` + * stores one row per pair and an A→B row governs B→A. + * + * Returns NULL when the pair is not configured, and every caller must let that + * null through rather than coalescing to zero — a missing distance is not a + * zero distance, and Ton/Km computed from one would understate silently. + */ +export const distanceKmBetween = (fromCol: string, toCol: string): string => `( + SELECT yd.distance_km FROM freight.yard_distances yd + WHERE yd.deleted_at IS NULL + AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol}) + OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol})) + LIMIT 1 +)`; + +/** Standard running time for a leg, falling back to the default leg standard. */ +export const legStandardHours = (fromCol: string, toCol: string): string => `COALESCE(( + SELECT yd.standard_hours FROM freight.yard_distances yd + WHERE yd.deleted_at IS NULL + AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol}) + OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol})) + LIMIT 1 +), ${stdRow('default_leg_standard_hours', 21)})`; + +/** The schedule's own corridor, origin to destination. */ +export const SCHEDULE_KM_EXPR = distanceKmBetween('ts.origin_station_id', 'ts.destination_station_id'); + +// --------------------------------------------------------------------------- +// Volume — TEU, charged and actual +// --------------------------------------------------------------------------- + +/** Per-allocation aggregate over its container items. */ +const containerItems = (selection: string): string => `( + SELECT ${selection} + FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL +)`; + +/** + * TEU for one allocation: a 40ft box is two twenty-foot equivalents, anything + * else one. + * + * Note this is the third TEU derivation in the codebase and the only one taken + * from the marshalling record. `revenue-classification.ts` derives TEU from the + * charge code's size suffix (billing truth, blind to unsized codes) and + * `wagon-teu-utilization.report.ts` from a wagon's currently pinned containers + * (live state). This one answers "what did we actually move", which is what the + * reporting spec asks for. + */ +export const ALLOC_TEU = containerItems( + 'COALESCE(SUM(CASE WHEN cty.size_ft >= 40 THEN 2 ELSE 1 END), 0)', +); +export const ALLOC_CONTAINERS_20 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft = 20)'); +export const ALLOC_CONTAINERS_40 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft >= 40)'); +export const ALLOC_CONTAINERS = containerItems('COUNT(*)'); + +export const TEU_EXPR = `COALESCE(SUM(${ALLOC_TEU}), 0)::int`; +export const CONTAINERS_EXPR = `COALESCE(SUM(${ALLOC_CONTAINERS}), 0)::int`; + +/** + * Actual volume — "loading capacity from marshalling" in the spec. + * `allocated_weight_tons` is what the allocation flow recorded onto the wagon, + * and is populated for every allocation in the system. + */ +export const ACTUAL_TONS_EXPR = 'COALESCE(SUM(wba.allocated_weight_tons), 0)::float8'; + +const IS_PERISHABLE = `COALESCE(ct.code, '') IN (${quote(PERISHABLE_CODES)})`; +const IS_BULK_LOAD = "wba.load_type <> 'CONTAINER'"; + +/** + * Charged volume — the standard weight capacity the spec bills against, not + * what was weighed. + * + * Containers are charged per box (20/40 tons laden, 2.24/3.88 empty). Bulk is + * charged per WAGON (70 tons, or 38 for perishables), so it counts distinct + * wagons rather than allocations: two bookings sharing one wagon are one + * wagon's charge, not two. + */ +export const CHARGED_TONS_EXPR = `( + COALESCE(SUM( + CASE WHEN ${IS_BULK_LOAD} THEN 0 ELSE + ${ALLOC_CONTAINERS_20} * CASE WHEN ${IS_EMPTY_CONTAINER} + THEN ${stdRow('charged_tons_empty_20ft', 2.24)} + ELSE ${stdRow('charged_tons_full_20ft', 20)} END + + ${ALLOC_CONTAINERS_40} * CASE WHEN ${IS_EMPTY_CONTAINER} + THEN ${stdRow('charged_tons_empty_40ft', 3.88)} + ELSE ${stdRow('charged_tons_full_40ft', 40)} END + END), 0) + + COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND ${IS_PERISHABLE}) + * ${stdAgg('charged_tons_per_wagon_perishable', 38)} + + COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND NOT ${IS_PERISHABLE}) + * ${stdAgg('charged_tons_per_wagon_general', 70)} +)::float8`; + +/** Wagons actually carrying cargo in the grouped set. */ +export const LOADED_WAGONS_EXPR = 'COUNT(DISTINCT tsw.id)::int'; + +/** + * Wagons on the departure with nothing allocated to them — the Vehicle-Km base. + * + * A train-level figure: it belongs to the departure, not to any one cargo type + * riding on it, so a report grouped finer than the schedule repeats it rather + * than splitting it. Callers that need a total must de-duplicate by schedule. + */ +export const SCHEDULE_EMPTY_WAGONS = `( + SELECT COUNT(*) FROM freight.train_set_wagons tw + WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = tw.id AND a.deleted_at IS NULL) +)`; + +/** + * Trainsets operated: wagons loaded divided by a full trainset for this cargo. + * Seven full multimodal trains plus 30 of a 50-wagon set reads 7.6 — the + * fraction the spec's worked example asks for. + */ +export const TRAINSETS_EXPR = `ROUND( + COUNT(DISTINCT tsw.id)::numeric + / NULLIF(MAX(COALESCE(ct.full_trainset_wagons, ${stdRow('default_full_trainset_wagons', 50)})), 0) +, 2)::float8`; + +// --------------------------------------------------------------------------- +// Rates +// --------------------------------------------------------------------------- + +/** + * Implement rate — operated against plan, as a percentage. + * + * NULL when there is no plan, never 100 and never 0: an unplanned period has no + * achievement to report, and coercing a missing plan to zero would read as + * infinite achievement. + */ +export const implementRateExpr = (operated: string, planned: string): string => + `ROUND(100 * (${operated})::numeric / NULLIF(${planned}, 0), 1)::float8`; + +/** + * Turn-around implement rate, the spec's own formula: + * `[((SC − AD) / SC) + 1] × 100`. Finishing exactly on standard scores 100; + * a cycle an hour quicker than a 65-hour standard scores ~101.5. + */ +export const cycleRateExpr = (actual: string, standard: string): string => + `ROUND((((${standard}) - (${actual})) / NULLIF(${standard}, 0) + 1) * 100, 1)::float8`; + +/** Hours between two timestamps, one decimal place. */ +export const hoursBetween = (from: string, to: string): string => + `ROUND(EXTRACT(EPOCH FROM ((${to}) - (${from})))::numeric / 3600, 1)::float8`; + +// --------------------------------------------------------------------------- +// Filters and the shared ledger +// --------------------------------------------------------------------------- + +/** + * The date every operations report buckets and filters on: when the train + * actually left, falling back to the plan for a departure not yet dispatched. + */ +export const OPS_DATE = 'COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)'; + +export const DIRECTION_FILTER: ReportFilterDef = { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], +}; + +export const COUNTRY_FILTER: ReportFilterDef = { + key: 'country', + label: 'Country', + type: 'select', + options: [ + { value: 'Ethiopia', label: 'Ethiopia' }, + { value: 'Djibouti', label: 'Djibouti' }, + ], +}; + +/** Shared by every operations report, so they read the same way side by side. */ +export const OPERATIONS_FILTERS: ReportFilterDef[] = [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, + { key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, +]; + +export const CARGO_CATEGORY_FILTER: ReportFilterDef = { + key: 'categories', + label: 'Cargo category', + type: 'multiselect', + options: CARGO_CATEGORIES, +}; + +/** Schedule states that never represent an operated train. */ +const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED']; + +/** + * Every operations report starts here: one wagon allocation, joined out to the + * departure that carried it and the booking that explains it. + * + * The booking is LEFT joined — a wagon can be allocated before its booking data + * is complete, and dropping those rows would understate wagon usage. + */ +export function allocationLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(WagonBookingAllocation, 'wba') + .innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL') + .innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL') + .leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL') + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('wba.deleted_at IS NULL') + .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { + deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, + }); + + applyOperationsFilters(qb, params); + applyDirectionScope(qb, 'COALESCE(b.trade_direction, ts.direction)', directions); + return qb; +} + +/** + * The schedule-grain query, for reports that measure trains rather than cargo — + * turnaround, delay, station stay. Same aliases, minus the allocation. + */ +export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSchedule, 'ts') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('ts.deleted_at IS NULL') + .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { + deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, + }); + + applyOperationsFilters(qb, params); + applyDirectionScope(qb, 'ts.direction', directions); + return qb; +} + +export function applyOperationsFilters( + qb: SelectQueryBuilder, + params: Record, +): void { + if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { + trainNumber: `%${params.trainNumber as string}%`, + }); + } + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); +} + +/** + * Restricts an allocation-grain query to a set of cargo categories. Kept + * separate from {@link applyOperationsFilters} because the schedule-grain + * query has no cargo to filter by. + */ +export function applyCategoryFilter( + qb: SelectQueryBuilder, + params: Record, +): void { + const categories = params.categories as string[] | null; + if (categories?.length) { + qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories }); + } +} + +/** + * The planned value for a group, as a correlated subselect against + * `operations_targets`. + * + * Correlated rather than joined because the period bucket is an expression, not + * a column: joining would need the same `date_trunc` repeated in the ON clause + * and in the GROUP BY, and a mismatch between the two silently drops targets. + * + * Wrapped in MAX() so the correlated references sit inside an aggregate's + * argument. Postgres does not recognise a grouped EXPRESSION as grouped when it + * appears inside a subquery — `subquery uses ungrouped column` — and an + * aggregate argument is the one place ungrouped columns are legal. The value is + * constant within the group, so MAX() picks it exactly. + */ +export const plannedValueExpr = ( + metric: string, + dimension: string, + dimensionKeyExpr: string, + periodTypeExpr: string, + periodStartExpr: string, +): string => `MAX(( + SELECT ot.planned_value FROM freight.operations_targets ot + WHERE ot.deleted_at IS NULL + AND ot.metric = '${metric}' + AND ot.dimension = '${dimension}' + AND ot.dimension_key = ${dimensionKeyExpr} + AND ot.period_type = ${periodTypeExpr} + AND ot.period_start = (${periodStartExpr})::date + LIMIT 1 +))`; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index a9b662077..8ba2c5da2 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -136,8 +136,11 @@ export class ReportRunnerService { // export is supposed to match what the user is looking at. const sort = resolveSort(def, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); - const items = await qb.limit(limit).getRawMany(); - if (items.length >= limit) { + // limit + 1: fetching exactly `limit` cannot distinguish "there are exactly + // limit rows" from "there are more" — which is why the old `>= limit` check + // rejected a legitimate export of exactly the cap. + const items = await qb.limit(limit + 1).getRawMany(); + if (items.length > limit) { throw new BadRequestException( `Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, ); diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index da280a271..fedc8a6dd 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -31,6 +31,14 @@ import { paymentClassificationReport } from './definitions/payment-classificatio import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; import { receivablesPayablesReport } from './definitions/receivables-payables.report'; import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; +import { stationStayingTimeReport } from './definitions/station-staying-time.report'; +import { turnaroundCycleReport } from './definitions/turnaround-cycle.report'; +import { trainDelaysReport } from './definitions/train-delays.report'; +import { trainsetPerformanceReport } from './definitions/trainset-performance.report'; +import { teuPerformanceReport } from './definitions/teu-performance.report'; +import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report'; +import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report'; +import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report'; import { ReportDefinition } from './report.types'; /** @@ -71,6 +79,14 @@ export const REPORTS: ReportDefinition[] = [ revenueReconciliationReport, receivablesPayablesReport, revenueAnomaliesReport, + stationStayingTimeReport, + turnaroundCycleReport, + trainDelaysReport, + trainsetPerformanceReport, + teuPerformanceReport, + cargoVolumePerformanceReport, + chargedVsActualVolumeReport, + cargoVolumeByStationReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index ebe52ef6b..526e35add 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -264,18 +264,30 @@ export const REVENUE_DATE = 'COALESCE(i.issued_at, i.created_at)'; * type-checks, it EXPLAINs clean, and it returns plausible garbage. */ export function periodExpr(params: Record): string { - const unit = resolvePeriod(params); - return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`; + return periodExprOn(REVENUE_DATE, params); } -function resolvePeriod(params: Record): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { +export function resolvePeriod( + params: Record, +): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; } +/** + * The same bucketing over any timestamp column. Revenue buckets on the invoice + * date; the operations reports bucket on a train's actual departure, and share + * these units so a month means the same thing on both sides of the product. + */ +export const periodExprOn = (dateExpr: string, params: Record): string => + `to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; + +export const periodTruncExprOn = (dateExpr: string, params: Record): string => + `date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`; + /** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ export const periodTruncExpr = (params: Record): string => - `date_trunc('${resolvePeriod(params).trunc}', ${REVENUE_DATE})`; + periodTruncExprOn(REVENUE_DATE, params); /** * The period as a number, for regression: seconds since epoch at the period's diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 67f5d2547..a759d08dc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -94,6 +94,16 @@ export class CreateCargoTypeDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + description: + 'Wagons in a full trainset of this cargo (37 vehicles, 22 sand). Blank uses the default.', + example: 37, + }) + @IsOptional() + @IsInt() + @Min(1) + fullTrainsetWagons?: number; + @ApiPropertyOptional({ default: 1 }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts index 0615debdc..2c5c30d60 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, IsUUID, Min } from 'class-validator'; +import { IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); @@ -19,4 +19,15 @@ export class CreateYardDistanceDto { @IsNumber() @Min(0.01) distanceKm!: number; + + @ApiPropertyOptional({ + description: + 'Standard running time for this leg in hours. Blank uses the default leg standard.', + example: 21, + }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + standardHours?: number; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 7084e233a..dc902b670 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -71,6 +71,15 @@ export class CargoType extends BaseEntity { @Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true }) tonsPerWagonMap?: Record | null; + /** + * Wagons in a full trainset of this cargo — 37 for vehicles, 22 for sand. + * The trainset report divides wagons actually loaded by this figure, so a + * train carrying 30 of a 50-wagon set reports 0.6 trainsets. Null falls back + * to `operations_standards.default_full_trainset_wagons`. + */ + @Column({ name: 'full_trainset_wagons', type: 'int', nullable: true }) + fullTrainsetWagons?: number | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts index 982079f47..6134ee6fc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts @@ -32,4 +32,15 @@ export class YardDistance extends BaseEntity { @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm + + /** + * Standard running time for this leg, in hours — Negad→GMP 21, →Adama 20, + * →Modjo 20.5, →Sebeta 22. The delay report flags a leg that takes longer + * than this plus the tolerance. Null falls back to + * `operations_standards.default_leg_standard_hours`. + * + * Symmetric like the distance itself: an A→B row governs B→A too. + */ + @Column({ name: 'standard_hours', type: 'decimal', precision: 6, scale: 2, nullable: true }) + standardHours?: string | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 9c11005e9..c59016ad4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -204,6 +204,7 @@ export class CargoTypesService { itemsPerWagonMap: dto.itemsPerWagonMap, }), tonsPerWagonMap, + fullTrainsetWagons: dto.fullTrainsetWagons ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts index a41e593e2..93d069b73 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts @@ -61,6 +61,7 @@ export class YardDistancesService { fromYardId: dto.fromYardId, toYardId: dto.toYardId, distanceKm: dto.distanceKm.toFixed(2), + standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null, }); return toRow(created); } @@ -78,6 +79,9 @@ export class YardDistancesService { fromYardId, toYardId, ...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), + ...(dto.standardHours !== undefined + ? { standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null } + : {}), }); if (!updated) throw new NotFoundException(`Yard distance ${id} not found`); return toRow(updated); diff --git a/apps/edr-freight-api/src/scripts/tmp-ops-plan.ts b/apps/edr-freight-api/src/scripts/tmp-ops-plan.ts new file mode 100644 index 000000000..751b01165 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/tmp-ops-plan.ts @@ -0,0 +1,84 @@ +/* Throwaway: proves the Plan / Implement rate path end to end. Delete after use. */ +import { DataSource } from 'typeorm'; + +import { REPORTS } from '../modules/reports/report.registry'; +import { normalisePeriodStart } from '../modules/operations-reporting/operations-targets.service'; + +async function main(): Promise { + const ds = new DataSource({ + type: 'postgres', + host: 'localhost', + port: 5433, + username: 'nati', + password: 'password', + database: 'nati_wt_opsreport', + entities: ['src/**/*.entity.ts'], + synchronize: false, + logging: false, + }); + await ds.initialize(); + + const params = { period: 'month' }; + const run = async (key: string) => { + const def = REPORTS.find((r) => r.key === key)!; + return def.query({ ds, params, directions: null }).getRawMany(); + }; + + const before = await run('trainset-performance'); + console.log('trainset rows before target:'); + console.table( + before.map((r) => ({ + period: r.period, + category: r.categoryKey, + wagons: r.wagons, + operated: r.operated, + plan: r.plan, + rate: r.implementRate, + })), + ); + + const target = before[0]; + if (!target) { + console.log('no rows to plan against'); + await ds.destroy(); + return; + } + + // period label is YYYY-MM for month; a target is stored on the bucket start. + const periodStart = normalisePeriodStart('month', `${target.period}-15`); + console.log(`\nnormalisePeriodStart('month', '${target.period}-15') = ${periodStart}`); + + await ds.query( + `INSERT INTO freight.operations_targets + (period_type, period_start, metric, dimension, dimension_key, planned_value) + VALUES ('month', $1, 'TRAINSET', 'cargo_category', $2, $3)`, + [periodStart, target.categoryKey, Number(target.operated) * 2], + ); + + const after = await run('trainset-performance'); + console.log('\ntrainset rows after inserting a target of 2x operated:'); + console.table( + after.map((r) => ({ + period: r.period, + category: r.categoryKey, + operated: r.operated, + plan: r.plan, + rate: r.implementRate, + })), + ); + + const row = after.find((r) => r.categoryKey === target.categoryKey && r.period === target.period); + const ok = Number(row?.plan) === Number(target.operated) * 2 && Math.abs(Number(row?.implementRate) - 50) < 0.05; + console.log(ok ? '\nPLAN OK — rate is 50% against a doubled plan' : '\nPLAN MISMATCH'); + + // Untouched categories must still read null, not zero. + const untouched = after.filter((r) => r.categoryKey !== target.categoryKey); + const nulls = untouched.every((r) => r.plan === null && r.implementRate === null); + console.log(nulls ? 'UNPLANNED OK — plan and rate are null, not zero' : 'UNPLANNED MISMATCH'); + + await ds.query(`DELETE FROM freight.operations_targets WHERE metric = 'TRAINSET'`); + await ds.destroy(); + process.exit(ok && nulls ? 0 : 1); +} + +void main(); diff --git a/apps/edr-freight-api/src/scripts/tmp-ops-verify.ts b/apps/edr-freight-api/src/scripts/tmp-ops-verify.ts new file mode 100644 index 000000000..ec5fa0605 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/tmp-ops-verify.ts @@ -0,0 +1,99 @@ +/* Throwaway harness: applies the operations migration to a scratch database and + * runs every operations report across several parameter sets. Delete after use. */ +import { DataSource } from 'typeorm'; + +import { OperationsReporting3580000000000 } from '../migrations/3580000000000-OperationsReporting'; +import { REPORTS } from '../modules/reports/report.registry'; +import { ReportContext } from '../modules/reports/report.types'; + +const NEW_KEYS = [ + 'station-staying-time', + 'turnaround-cycle', + 'train-delays', + 'trainset-performance', + 'teu-performance', + 'cargo-volume-performance', + 'charged-vs-actual-volume', + 'cargo-volume-by-station', +]; + +const PARAM_SETS: Record[] = [ + {}, + { period: 'month' }, + { period: 'quarter', direction: 'IMPORT' }, + { period: 'week', country: 'Djibouti' }, + { period: 'year', country: 'Ethiopia', categories: ['CONTAINER_IMPORT_MULTIMODAL', 'BULK'] }, + { dateFrom: '2020-01-01', dateTo: '2030-01-01', delayedOnly: 'true' }, + { trainNumber: 'X', origin: 'NAGAD', destination: 'KALITY' }, +]; + +async function main(): Promise { + const ds = new DataSource({ + type: 'postgres', + host: 'localhost', + port: 5433, + username: 'nati', + password: 'password', + database: 'nati_wt_opsreport', + entities: ['src/**/*.entity.ts'], + synchronize: false, + logging: false, + }); + await ds.initialize(); + + // Apply the migration's DDL by hand — this scratch database is not under + // migration control and only needs the two new tables and two columns. + const runner = ds.createQueryRunner(); + await new OperationsReporting3580000000000().up(runner); + await runner.release(); + console.log('migration applied\n'); + + let failures = 0; + for (const key of NEW_KEYS) { + const def = REPORTS.find((r) => r.key === key); + if (!def) { + console.log(`MISSING ${key}`); + failures++; + continue; + } + for (const [i, params] of PARAM_SETS.entries()) { + for (const directions of [null, ['IMPORT']] as (string[] | null)[]) { + const ctx: ReportContext = { ds, params, directions }; + const label = `${key} [set ${i}${directions ? ' scoped' : ''}]`; + try { + const qb = def.query(ctx); + const rows = await qb.limit(5).getRawMany(); + + // The runner wraps every query for the total count — prove that works too. + const [sql, bound] = qb.getQueryAndParameters(); + const counted = await ds.query(`SELECT COUNT(*)::int AS n FROM (${sql}) AS sub`, bound); + + // Sorting: every sortable column must be a legal ORDER BY. + for (const col of def.columns.filter((c) => c.sortable)) { + await def + .query({ ds, params, directions }) + .orderBy(col.sortExpr ?? `"${col.key}"`, 'DESC') + .limit(1) + .getRawMany(); + } + + const kpis = def.summary ? await def.summary(ctx) : []; + console.log( + `OK ${label.padEnd(50)} rows=${rows.length} total=${counted[0]?.n ?? '?'} kpis=${kpis + .map((k) => `${k.label}:${k.value}${k.unit ?? ''}`) + .join(' ')}`, + ); + } catch (err) { + failures++; + console.log(`FAIL ${label}: ${(err as Error).message.split('\n')[0]}`); + } + } + } + } + + console.log(failures ? `\n${failures} failures` : '\nall checks passed'); + await ds.destroy(); + process.exit(failures ? 1 : 0); +} + +void main(); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 41728e10c..2e7c4a87e 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -23,6 +23,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ // so a mid-list insert would shift ids already seeded for later slugs. "truck-types", "transit-agents", + "operations-targets", ] as const; export type RuleEngineResourceSlug = @@ -87,6 +88,14 @@ export const REPORT_KEYS = [ "revenue-reconciliation", "receivables-payables", "revenue-anomalies", + "station-staying-time", + "turnaround-cycle", + "train-delays", + "trainset-performance", + "teu-performance", + "cargo-volume-performance", + "charged-vs-actual-volume", + "cargo-volume-by-station", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; @@ -388,6 +397,7 @@ const RULE_ENGINE_VIEW_IDS: Record = { "approval-rules": "b2000001-0001-4000-8000-000000000013", "yard-distances": "b2000001-0001-4000-8000-000000000018", "transit-agents": "b2000003-0001-4000-8000-000000000001", + "operations-targets": "b2000003-0001-4000-8000-000000000002", }; // CRUD replaces the retired coarse `:manage`. New ids live in a fresh block @@ -1474,6 +1484,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:exchange_rate:manage", "Set the USD-ETB fallback rate", ), + perm( + "b5000001-0001-4000-8000-000000000001", + "edr_freight_app:settings:operations_standards:view", + "View operating standards", + ), + perm( + "b5000001-0001-4000-8000-000000000002", + "edr_freight_app:settings:operations_standards:manage", + "Edit operating standards", + ), perm( "b4e00001-0001-4000-8000-000000000001", "edr_freight_app:settings:contract_templates:view", @@ -2121,6 +2141,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Standard station stay, cycle and leg times, and the charged-tonnage + // factors the operations reports measure actual performance against. + operationsStandards: { + view: "edr_freight_app:settings:operations_standards:view", + manage: "edr_freight_app:settings:operations_standards:manage", + }, contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", diff --git a/apps/edr-freight-api/tmp-mkdb.cjs b/apps/edr-freight-api/tmp-mkdb.cjs new file mode 100644 index 000000000..c292ffee6 --- /dev/null +++ b/apps/edr-freight-api/tmp-mkdb.cjs @@ -0,0 +1,18 @@ +const { Client } = require('pg'); +(async () => { + const admin = new Client({ host:'localhost', port:5433, user:'nati', password:'password', database:'postgres' }); + await admin.connect(); + const { rows } = await admin.query("SELECT 1 FROM pg_database WHERE datname='nati_wt_opsreport'"); + if (!rows.length) { + await admin.query('CREATE DATABASE nati_wt_opsreport TEMPLATE edr_dev_sqltest'); + console.log('created nati_wt_opsreport from edr_dev_sqltest'); + } else console.log('already exists'); + await admin.end(); + const db = new Client({ host:'localhost', port:5433, user:'nati', password:'password', database:'nati_wt_opsreport' }); + await db.connect(); + for (const t of ['train_schedules','train_set_wagons','wagon_booking_allocations','wagon_allocation_container_items','wagon_allocation_bulk_loads','yard_distances','yards','cargo_types','train_checkpoint_events','bookings','container_types']) { + const r = await db.query(`SELECT count(*)::int n FROM freight.${t}`); + console.log(t.padEnd(36), r.rows[0].n); + } + await db.end(); +})().catch(e => { console.error('FAIL', e.message); process.exit(1); }); diff --git a/apps/edr-freight-api/tmp-ops-reconcile.cjs b/apps/edr-freight-api/tmp-ops-reconcile.cjs new file mode 100644 index 000000000..f44f26462 --- /dev/null +++ b/apps/edr-freight-api/tmp-ops-reconcile.cjs @@ -0,0 +1,81 @@ +const { Client } = require('pg'); + +const q = async (db, label, sql) => { + const { rows } = await db.query(sql); + console.log(`\n== ${label}`); + console.table(rows); +}; + +(async () => { + const db = new Client({ + host: 'localhost', port: 5433, user: 'nati', password: 'password', + database: 'nati_wt_opsreport', + }); + await db.connect(); + + await q(db, 'TEU by hand (40ft = 2)', ` + SELECT COUNT(*) items, + COUNT(*) FILTER (WHERE cty.size_ft = 20) c20, + COUNT(*) FILTER (WHERE cty.size_ft >= 40) c40, + SUM(CASE WHEN cty.size_ft >= 40 THEN 2 ELSE 1 END) teu + FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + JOIN freight.wagon_booking_allocations wba ON wba.id = ci.wagon_booking_allocation_id AND wba.deleted_at IS NULL + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL + AND ts.status NOT IN ('DRAFT','CANCELLED') + WHERE ci.deleted_at IS NULL`); + + await q(db, 'actual tons by hand', ` + SELECT ROUND(SUM(wba.allocated_weight_tons), 1) actual_tons, COUNT(*) allocations, + COUNT(DISTINCT tsw.id) wagons + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL + AND ts.status NOT IN ('DRAFT','CANCELLED') + WHERE wba.deleted_at IS NULL`); + + await q(db, 'charged by hand: containers 20/40 laden + bulk wagons', ` + WITH led AS ( + SELECT wba.id, tsw.id AS wagon_id, wba.load_type, b.equipment_return, ct.code AS cargo_code, + (SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL AND cty.size_ft = 20) c20, + (SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL AND cty.size_ft >= 40) c40 + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL + AND ts.status NOT IN ('DRAFT','CANCELLED') + LEFT JOIN freight.bookings b ON b.id = wba.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE wba.deleted_at IS NULL) + SELECT SUM(CASE WHEN load_type = 'CONTAINER' THEN + c20 * CASE WHEN equipment_return = 'RETURN' THEN 2.24 ELSE 20 END + + c40 * CASE WHEN equipment_return = 'RETURN' THEN 3.88 ELSE 40 END ELSE 0 END) + AS container_charged, + COUNT(DISTINCT wagon_id) FILTER (WHERE load_type <> 'CONTAINER' + AND COALESCE(cargo_code,'') IN ('PERISHABLE','LIVESTOCK')) * 38 AS perishable_charged, + COUNT(DISTINCT wagon_id) FILTER (WHERE load_type <> 'CONTAINER' + AND COALESCE(cargo_code,'') NOT IN ('PERISHABLE','LIVESTOCK')) * 70 AS bulk_charged + FROM led`); + + await q(db, 'schedules with both actual timestamps', ` + SELECT ts.train_number, ts.direction, ts.actual_departure_at, ts.actual_arrival_at, + ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric/3600, 2) hours + FROM freight.train_schedules ts + WHERE ts.deleted_at IS NULL AND ts.actual_departure_at IS NOT NULL AND ts.actual_arrival_at IS NOT NULL`); + + await q(db, 'checkpoints', ` + SELECT e.kind, y.label, e.occurred_at, e.train_schedule_id + FROM freight.train_checkpoint_events e JOIN freight.yards y ON y.id = e.yard_id + WHERE e.deleted_at IS NULL ORDER BY e.train_schedule_id, e.sequence_no`); + + await q(db, 'standards row', `SELECT station_standard_hours_ethiopia eth, station_standard_hours_djibouti dj, + cycle_standard_hours_container cyc, default_leg_standard_hours leg, delay_tolerance_minutes tol, + charged_tons_full_20ft f20, charged_tons_full_40ft f40, default_full_trainset_wagons dfl + FROM freight.operations_standards`); + + await db.end(); +})().catch((e) => { console.error('FAIL', e.message); process.exit(1); }); diff --git a/apps/edr-freight-web/backoffice/index.html b/apps/edr-freight-web/backoffice/index.html index ef6fc82c5..796a88a4f 100644 --- a/apps/edr-freight-web/backoffice/index.html +++ b/apps/edr-freight-web/backoffice/index.html @@ -4,6 +4,12 @@ EDR Freight Backoffice + + +
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 31133c575..bb76ea0db 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -85,6 +85,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; +import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -1167,6 +1168,16 @@ const App = () => { } /> */} + + + + } + /> } /> `/yards/${id}`, YARD_DISTANCES: "/yard-distances", + OPERATIONS_TARGETS: "/operations-targets", YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`, SHIPPING_LINES: "/shipping-lines", diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index dd5078983..c93ce61e2 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -221,6 +221,8 @@ export interface YardOption { label: string; value: string; country: string; + /** The yard's business code — what config keyed on a station stores. */ + code: string; } /** @@ -243,6 +245,7 @@ export const useYardOptions = (enabled = true) => label: label && code ? `${label} (${code})` : label || code || String(row.id), value: String(row.id), country: String(row.country ?? ""), + code, }; }), }); diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOperationsStandards.ts b/apps/edr-freight-web/backoffice/src/hooks/useOperationsStandards.ts new file mode 100644 index 000000000..6d2ad22dc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useOperationsStandards.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { + operationsStandardsService, + type OperationsStandardsPatch, +} from "@/services/operationsStandards.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["operationsStandards"]; + +export const useOperationsStandardsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => operationsStandardsService.get(), + }); + +export const useUpdateOperationsStandards = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (patch: OperationsStandardsPatch) => + operationsStandardsService.update(patch), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success( + t("operationsStandards.updated", "Operating standards updated"), + ); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index fe4d4fff9..6cb20ceff 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -372,6 +372,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Standard station stay, cycle and leg times, and the charged-tonnage + // factors the operations reports measure actual performance against. + operationsStandards: { + view: "edr_freight_app:settings:operations_standards:view", + manage: "edr_freight_app:settings:operations_standards:manage", + }, contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx index 98ea19bfc..9910ac8c8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx @@ -1,4 +1,4 @@ -import { SimpleGrid, Stack } from "@mantine/core"; +import { SimpleGrid, Stack, Title } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { Navigate } from "react-router-dom"; @@ -7,28 +7,39 @@ import { ReportSection } from "@/components/reports/ReportSection"; import { api } from "@/services/api"; /** - * The revenue dashboard the reporting spec asks for, assembled from reports - * that already exist rather than a second aggregation API: each tile is a + * The dashboards the reporting specs ask for, assembled from reports that + * already exist rather than a second aggregation API: each tile is a * `ReportSection` opened on its chart, and each one permission-gates itself by * rendering nothing when the caller's catalog lacks that report. */ -const TILES = [ +const REVENUE_TILES = [ "revenue-by-period", "revenue-by-category", "revenue-by-route", "revenue-top-customers", ]; +const OPERATIONS_TILES = [ + "cargo-volume-performance", + "teu-performance", + "trainset-performance", + "turnaround-cycle", +]; + export default function ReportsLandingPage() { const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions()); if (isLoading) return null; - const visible = TILES.filter((key) => catalog?.some((r) => r.key === key)); + const visible = (keys: string[]) => + keys.filter((key) => catalog?.some((r) => r.key === key)); - // No revenue reports for this user — fall back to the old behaviour and send - // them to the first report they can actually open. - if (!visible.length) { + const revenue = visible(REVENUE_TILES); + const operations = visible(OPERATIONS_TILES); + + // No dashboard reports for this user — fall back to the old behaviour and + // send them to the first report they can actually open. + if (!revenue.length && !operations.length) { const first = catalog?.[0]; return ; } @@ -37,14 +48,31 @@ export default function ReportsLandingPage() { - - {visible.map((key) => ( - - ))} - + + {revenue.length > 0 && ( + + Revenue + + {revenue.map((key) => ( + + ))} + + + )} + + {operations.length > 0 && ( + + Operations + + {operations.map((key) => ( + + ))} + + + )} ); diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 688ed3ce2..ebcf38ead 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -309,7 +309,9 @@ const RuleEngineResourcePage = () => { (f) => f.name === "originYardId" || f.name === "fromYardId" || - f.name === "toYardId", + f.name === "toYardId" || + // Operational targets pick a station by yard code. + f.name === "dimensionKey", ), ); const { data: yardOptions, isLoading: yardOptionsLoading } = @@ -471,6 +473,20 @@ const RuleEngineResourcePage = () => { .map(({ label, value }) => ({ label, value })), }; } + // An operational target's key is a category, a container class, or a + // station's YARD CODE — never a yard id, because the reports match it + // against what their classification CASE emits. + if (field.name === "dimensionKey") { + const staticOptions = field.optionsFromValues; + return { + ...field, + type: "select" as const, + optionsFromValues: (values: Record) => + String(values.dimension ?? "") === "station" + ? (yardOptions ?? []).map(({ label, code }) => ({ label, value: code })) + : (staticOptions?.(values) ?? []), + }; + } if (field.name === "originYardId" || field.name === "destinationYardId") { const end = field.name === "originYardId" ? "origin" : "destination"; return { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 4ff9aa3fb..2f8fedd29 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -140,6 +140,37 @@ const TRADE_DIRECTIONS = [ { label: "Both", value: "BOTH" }, ]; +/** + * The cargo categories and container classes an operational target may be + * keyed on. + * + * Mirrors CARGO_CATEGORIES / CONTAINER_CLASSES in the API's + * `modules/reports/operations-classification.ts`, which is the source of truth: + * a report matches a target by this exact key, so a value here that the API + * does not emit is a plan the report will never find. The API spec + * `operations-classification.spec.ts` guards the API side of the pair. + */ +export const OPERATIONS_CARGO_CATEGORIES = [ + { label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" }, + { label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" }, + { label: "Export container", value: "CONTAINER_EXPORT" }, + { label: "Empty container", value: "EMPTY_CONTAINER" }, + { label: "Fertilizer", value: "FERTILIZER" }, + { label: "RoRo", value: "RORO" }, + { label: "Break bulk", value: "BREAK_BULK" }, + { label: "Sand", value: "SAND" }, + { label: "Bulk", value: "BULK" }, + { label: "Other imports", value: "OTHER_IMPORT" }, + { label: "Other export cargo", value: "OTHER_EXPORT" }, +]; + +export const OPERATIONS_CONTAINER_CLASSES = [ + { label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" }, + { label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" }, + { label: "Full export container", value: "CONTAINER_EXPORT" }, + { label: "Empty container return", value: "EMPTY_CONTAINER_RETURN" }, +]; + // Mirrors the YardCountry enum in @edr/types — the only two countries on the line. const YARD_COUNTRIES = [ { label: "Ethiopia", value: "Ethiopia" }, @@ -463,6 +494,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ optional: true, placeholder: "Select parent cargo type (optional)", }, + { + name: "fullTrainsetWagons", + label: "Wagons in a full trainset", + type: "number", + optional: true, + description: + "What the Trainset Performance report divides loaded wagons by — 37 for vehicles, 22 for sand. Leave blank to use the default in Operating standards.", + }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { @@ -624,6 +663,88 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" }, ], }, + { + slug: "operations-targets", + label: "Operational Targets", + category: "configuration", + subtitle: + "Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports", + searchPlaceholder: "Search by category, station or note...", + supportsSearch: true, + cardTitleKey: "dimensionKey", + cardSubtitleKey: "periodStart", + columns: [ + { id: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" }, + { id: "periodType", header: "Period", accessorKey: "periodType" }, + { id: "metric", header: "Metric", accessorKey: "metric" }, + { id: "dimension", header: "Dimension", accessorKey: "dimension" }, + { id: "dimensionKey", header: "Applies to", accessorKey: "dimensionKey" }, + { id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" }, + ], + formFields: [ + { + name: "metric", + label: "Metric", + type: "select", + required: true, + options: [ + { label: "TEU", value: "TEU" }, + { label: "Trainsets", value: "TRAINSET" }, + { label: "Volume (tons)", value: "VOLUME_TONS" }, + ], + }, + { + name: "periodType", + label: "Period", + type: "select", + required: true, + options: [ + { label: "Weekly", value: "week" }, + { label: "Monthly", value: "month" }, + { label: "Quarterly", value: "quarter" }, + { label: "Yearly", value: "year" }, + ], + }, + { + name: "periodStart", + label: "Period start", + type: "date", + required: true, + description: + "Any date inside the period — it is snapped to the start of the week, month, quarter or year on save.", + }, + { + name: "dimension", + label: "Applies to", + type: "select", + required: true, + options: [ + { label: "Cargo category", value: "cargo_category" }, + { label: "Station", value: "station" }, + { label: "Container class", value: "container_class" }, + ], + }, + { + name: "dimensionKey", + label: "Category / station code", + type: "select", + required: true, + placeholder: "Select what the target applies to", + // The valid keys depend on the chosen dimension, and must match what the + // reports emit exactly — a typo here is a target the report never finds. + optionsFromValues: (values) => { + const dimension = String(values.dimension ?? ""); + if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES; + if (dimension === "station") return []; + return OPERATIONS_CARGO_CATEGORIES; + }, + description: + "Station targets are keyed on the yard code (KALITY, MOJO, NAGAD…) — type it exactly as it appears on Yards.", + }, + { name: "plannedValue", label: "Planned value", type: "number", required: true }, + { name: "note", label: "Note", type: "text", optional: true }, + ], + }, { slug: "yard-distances", label: "Yard Distances", @@ -637,6 +758,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" }, { id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" }, { id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" }, + { id: "standardHours", header: "Standard (hrs)", accessorKey: "standardHours", format: "number" }, ], formFields: [ // Options injected at render from useYardOptions (RuleEngineResourcePage). @@ -650,6 +772,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ description: "Symmetric — one entry covers both directions. Route segments between these yards use this value.", }, + { + name: "standardHours", + label: "Standard running time (hrs)", + type: "number", + optional: true, + description: + "What the Train Delays report judges this leg against — 21h Negad to GMP, 20h to Adama, 20.5h to Modjo, 22h to Sebeta. Leave blank to use the default in Operating standards.", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx new file mode 100644 index 000000000..e443fcffd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx @@ -0,0 +1,282 @@ +import { useState } from "react"; +import { Save } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + useOperationsStandardsQuery, + useUpdateOperationsStandards, +} from "@/hooks/useOperationsStandards"; +import type { OperationsStandards } from "@/services/operationsStandards.service"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { useAuth } from "@/auth/useAuth"; + +type Field = { + name: keyof Omit; + label: string; + hint: string; + unit: string; + integer?: boolean; +}; + +type Section = { title: string; description: string; fields: Field[] }; + +/** + * Grouped the way the reporting spec reads, so an operator changing "the + * Djibouti standard" finds it next to the Ethiopian one rather than hunting a + * flat list of fifteen numbers. + */ +const SECTIONS: Section[] = [ + { + title: "Station staying time", + description: + "How long a train may stand at a station before the stop needs a reason. Used by Station Staying Time.", + fields: [ + { + name: "stationStandardHoursEthiopia", + label: "Ethiopian stations", + hint: "Standard stop on the Ethiopian side", + unit: "hrs", + }, + { + name: "stationStandardHoursDjibouti", + label: "Djibouti stations", + hint: "Standard stop on the Djibouti side", + unit: "hrs", + }, + ], + }, + { + title: "Turnaround cycle", + description: + "The full out-and-back a train is expected to complete in. Used by Turnaround Cycle.", + fields: [ + { + name: "cycleStandardHoursContainer", + label: "Container", + hint: "10 + 21 + 13 + 21", + unit: "hrs", + }, + { + name: "cycleStandardHoursBulkDmp", + label: "Bulk via DMP", + hint: "13 + 21 + 33 + 21", + unit: "hrs", + }, + { + name: "cycleStandardHoursBulkNagad", + label: "Bulk via Negad", + hint: "13 + 21 + 41 + 21", + unit: "hrs", + }, + { + name: "cycleStandardHoursBulkBcc", + label: "Bulk via BCC", + hint: "13 + 21 + 41 + 21", + unit: "hrs", + }, + ], + }, + { + title: "Delay", + description: + "Used by Train Delays when a yard pair has no standard of its own. Per-corridor times live on Yard Distances.", + fields: [ + { + name: "defaultLegStandardHours", + label: "Default leg standard", + hint: "Negad to GMP is 21 hours", + unit: "hrs", + }, + { + name: "delayToleranceMinutes", + label: "Tolerance", + hint: "Grace before a leg counts as delayed", + unit: "min", + integer: true, + }, + ], + }, + { + title: "Charged volume", + description: + "The standard weight capacity cargo is charged on, as opposed to what was weighed. Used by Charged and Actual Volumes.", + fields: [ + { + name: "chargedTonsFull20ft", + label: "Laden 20ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsFull40ft", + label: "Laden 40ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsEmpty20ft", + label: "Empty 20ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsEmpty40ft", + label: "Empty 40ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsPerWagonGeneral", + label: "Wagon of steel, fertilizer, rice, sugar", + hint: "Per wagon", + unit: "t", + }, + { + name: "chargedTonsPerWagonPerishable", + label: "Wagon of vegetables, milk, meat, livestock", + hint: "Per wagon", + unit: "t", + }, + ], + }, + { + title: "Trainset", + description: + "Used by Trainset Performance when a cargo type has no wagon count of its own — set those on Cargo Types.", + fields: [ + { + name: "defaultFullTrainsetWagons", + label: "Wagons in a full trainset", + hint: "37 for vehicles and 22 for sand are set per cargo type", + unit: "wagons", + integer: true, + }, + ], + }, +]; + +const ALL_FIELDS = SECTIONS.flatMap((s) => s.fields); + +/** + * The operating standards the operations reports measure against. + * + * A single settings row rather than constants in the code, because the business + * treats these as tunable — the corridor standard is explicitly described as + * flexible. Every value here changes what a report calls on-time, encouraging, + * or on plan, so the page shows what each one drives. + */ +export default function OperationsStandardsPage() { + const { user } = useAuth(); + const { data, isLoading } = useOperationsStandardsQuery(); + const update = useUpdateOperationsStandards(); + const [draft, setDraft] = useState>({}); + + const canEdit = + hasPermission(user, FREIGHT_PERMS.settings.operationsStandards.manage) || + hasPermission(user, FREIGHT_PERMS.admin); + + const valueOf = (field: Field): string => + draft[field.name] ?? (data ? String(data[field.name] ?? "") : ""); + + const invalid = (field: Field): boolean => { + const raw = draft[field.name]; + if (raw === undefined) return false; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return true; + return field.integer ? !Number.isInteger(parsed) : false; + }; + + const anyInvalid = ALL_FIELDS.some(invalid); + const dirty = Object.keys(draft).length > 0; + + const handleSave = async () => { + if (anyInvalid || !dirty) return; + const patch = Object.fromEntries( + Object.entries(draft).map(([key, value]) => [key, Number(value)]), + ); + await update.mutateAsync(patch); + setDraft({}); + }; + + return ( +
+
+
+

Operating standards

+

+ The figures every operations report measures actual performance + against. Changing one changes what the reports call on time, over + standard, or on plan — it does not change any charge a customer + pays. +

+
+ +
+ + {SECTIONS.map((section) => ( + + + {section.title} + {section.description} + + + {section.fields.map((field) => ( +
+ +
+ + setDraft((d) => ({ ...d, [field.name]: e.target.value })) + } + /> + + {field.unit} + +
+

+ {invalid(field) + ? field.integer + ? "Must be a whole number above zero" + : "Must be above zero" + : field.hint} +

+
+ ))} +
+
+ ))} + + {!canEdit && ( +

+ You can view these standards but not change them. +

+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts b/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts new file mode 100644 index 000000000..f9086570a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts @@ -0,0 +1,51 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = URL_CONSTANTS.OPERATIONS_STANDARDS.BASE; + +/** + * The railway's operating standards — the numbers the operations reports + * measure actual performance against. One row, edited here. + */ +export interface OperationsStandards { + id: string; + stationStandardHoursEthiopia: number; + stationStandardHoursDjibouti: number; + cycleStandardHoursContainer: number; + cycleStandardHoursBulkDmp: number; + cycleStandardHoursBulkNagad: number; + cycleStandardHoursBulkBcc: number; + defaultLegStandardHours: number; + delayToleranceMinutes: number; + chargedTonsFull20ft: number; + chargedTonsFull40ft: number; + chargedTonsEmpty20ft: number; + chargedTonsEmpty40ft: number; + chargedTonsPerWagonGeneral: number; + chargedTonsPerWagonPerishable: number; + defaultFullTrainsetWagons: number; + updatedAt?: string; +} + +export type OperationsStandardsPatch = Partial< + Omit +>; + +export const operationsStandardsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + update: async ( + patch: OperationsStandardsPatch, + ): Promise => { + const response = await client.patch>( + BASE, + patch, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts index 48f944c21..fc7bce8f0 100644 --- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts @@ -96,6 +96,7 @@ const RESOURCE_BASE: Record = { "weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES, yards: URL_CONSTANTS.RULE_ENGINE.YARDS, "yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES, + "operations-targets": URL_CONSTANTS.RULE_ENGINE.OPERATIONS_TARGETS, "shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES, rates: URL_CONSTANTS.RULE_ENGINE.RATES, "approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES, diff --git a/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts b/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts index 6c1efd3f3..4117f503c 100644 --- a/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts +++ b/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts @@ -86,7 +86,7 @@ export const freightMantineTheme = createTheme({ black: "#10202F", fontFamily: - '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + '"Space Grotesk", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', defaultRadius: "md", @@ -123,7 +123,7 @@ export const freightMantineTheme = createTheme({ }, headings: { - fontFamily: '"Inter", var(--mantine-font-family)', + fontFamily: '"Space Grotesk", var(--mantine-font-family)', fontWeight: "700", sizes: { h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" }, diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts index 5d886043a..9f530d95b 100644 --- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts +++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts @@ -11,7 +11,8 @@ export type RuleEngineResourceSlug = | "shipping-lines" | "rates" | "approval-rules" - | "transit-agents"; + | "transit-agents" + | "operations-targets"; /** * Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are From fc2b5ee0e3d8b394704633e84192262d237898a4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 05:16:15 +0000 Subject: [PATCH 02/22] refactor(reports): export through the shared tabular writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../exports/tabular-export.service.spec.ts | 65 ++++++++++ .../report-export-request.util.spec.ts | 62 ---------- .../reports/report-export-request.util.ts | 29 ----- .../modules/reports/report-export.service.ts | 117 ------------------ .../src/modules/reports/reports.controller.ts | 40 +++--- .../src/modules/reports/reports.module.ts | 9 +- 6 files changed, 95 insertions(+), 227 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/report-export-request.util.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/report-export.service.ts diff --git a/apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts b/apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts new file mode 100644 index 000000000..7f262b48e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts b/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts deleted file mode 100644 index dc6fa6230..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts b/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts deleted file mode 100644 index 18f2fa322..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts +++ /dev/null @@ -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, - 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; -} diff --git a/apps/edr-freight-api/src/modules/reports/report-export.service.ts b/apps/edr-freight-api/src/modules/reports/report-export.service.ts deleted file mode 100644 index f0919c9c7..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-export.service.ts +++ /dev/null @@ -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> = { - 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[], - kpis: ReportKpi[], - columns: ReportColumn[] = def.columns, - ): Promise { - 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[], - kpis: ReportKpi[], - columns: ReportColumn[] = def.columns, - ): Promise { - 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[], - kpis: ReportKpi[], - columns: ReportColumn[], - ): string { - const esc = (v: unknown) => - String(v ?? '').replace(/&/g, '&').replace(//g, '>'); - - const kpiHtml = kpis.length - ? `
${kpis - .map( - (k) => - `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, - ) - .join('')}
` - : ''; - - const head = columns.map((c) => `${esc(c.label)}`).join(''); - const body = rows - .map( - (row) => - `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, - ) - .join(''); - - return ` -

${esc(def.title)}

-

${esc(def.description)}

- ${kpiHtml} - ${head}${body}
- `; - } -} diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index 107e11097..33a4214c8 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index 2f98e9e04..e60f16362 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -1,14 +1,15 @@ 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 { ReportExportService } from './report-export.service'; import { ReportRunnerService } from './report-runner.service'; import { ReportsController } from './reports.controller'; @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], - providers: [ReportRunnerService, ReportExportService], + providers: [ReportRunnerService], }) export class ReportsModule {} From ce90be5c887014449763080ed15b40a348e226af Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 05:28:51 +0000 Subject: [PATCH 03/22] fix(reports): stop the 'first N rows' option failing on large exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export path used one number for two different things: the format's hard row cap, and the caller's explicit 'give me the first N rows'. Because resolveExportCap() returned min(requested, formatCap) and runAll() then threw when the result reached it, picking 'Records: First 100' in the export dialog 400'd on any report with more than 100 rows — the user asked to be truncated and got an error instead. Splits them: formatRowCap() is the hard, non-caller-controllable ceiling that still throws when exceeded (a silently short file hides missing rows), while resolveRowLimit() is the deliberate truncation and is honoured by slicing. Verified against a 223-row dataset: limit=5 now returns 5 rows, and no limit returns all 223. --- .../exports/export-request.util.spec.ts | 41 ++++++++++++------- .../modules/exports/export-request.util.ts | 29 ++++++++++--- .../modules/reports/report-runner.service.ts | 32 ++++++++++----- .../src/modules/reports/reports.controller.ts | 9 ++-- 4 files changed, 78 insertions(+), 33 deletions(-) diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts index 1a22be1d5..1b473b12a 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts @@ -1,4 +1,10 @@ -import { EXPORT_MIME, pickByKey, resolveExportCap, resolveExportFormat } from './export-request.util'; +import { + EXPORT_MIME, + formatRowCap, + pickByKey, + resolveExportFormat, + resolveRowLimit, +} from './export-request.util'; import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service'; describe('resolveExportFormat', () => { @@ -15,25 +21,32 @@ describe('resolveExportFormat', () => { }); }); -describe('resolveExportCap', () => { - it('missing limit uses the full format cap', () => { - expect(resolveExportCap('xlsx', undefined)).toBe(XLSX_ROW_CAP); - expect(resolveExportCap('csv', undefined)).toBe(CSV_ROW_CAP); - expect(resolveExportCap('pdf', undefined)).toBe(PDF_ROW_CAP); +describe('formatRowCap', () => { + it('is the format\'s hard ceiling and is not caller-controllable', () => { + expect(formatRowCap('xlsx')).toBe(XLSX_ROW_CAP); + expect(formatRowCap('csv')).toBe(CSV_ROW_CAP); + expect(formatRowCap('pdf')).toBe(PDF_ROW_CAP); + }); +}); + +describe('resolveRowLimit', () => { + it('no limit means "everything, up to the cap"', () => { + expect(resolveRowLimit('xlsx', undefined)).toBeUndefined(); }); - it('a limit under the cap is used as-is', () => { - expect(resolveExportCap('pdf', '100')).toBe(100); + it('an explicit limit is the caller asking to be truncated — kept as-is', () => { + // Distinct from the cap: 100 here must yield 100 rows, not a 400, even + // when the unfiltered result is far larger. + expect(resolveRowLimit('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); - expect(resolveExportCap('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_ROW_CAP); + it('an explicit limit over the format cap is clamped down', () => { + expect(resolveRowLimit('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP); + expect(resolveRowLimit('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_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); + it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p means no limit', (raw) => { + expect(resolveRowLimit('xlsx', raw)).toBeUndefined(); }); }); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.ts index d71db912e..39f1f2e8c 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.ts @@ -9,13 +9,30 @@ export function resolveExportFormat(raw: string | undefined): ExportFormat { return '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 : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP; +/** + * The format's hard ceiling. Not caller-controllable: exceeding it is an error, + * because a silently short file is worse than a clear failure. + */ +export function formatRowCap(format: ExportFormat): number { + return format === 'pdf' ? PDF_ROW_CAP : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP; +} + +/** + * The caller's deliberate "just the first N rows", clamped to the format cap. + * `undefined` means "everything, up to the cap". + * + * This is a DIFFERENT thing from the cap and must not share a number with it. + * Conflating them (as this code did originally) makes the dialog's + * "Records: First 100" option fail outright on any export with more than 100 + * rows — the user explicitly asked to be truncated, so truncating is the + * correct answer, not a 400. + */ +export function resolveRowLimit( + format: ExportFormat, + rawLimit: string | undefined, +): number | undefined { const requested = Number(rawLimit); - return requested > 0 ? Math.min(requested, formatCap) : formatCap; + return requested > 0 ? Math.min(requested, formatRowCap(format)) : undefined; } /** Content type + file extension per format, for the download response headers. */ diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index 8ba2c5da2..d38a5ba8a 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -122,12 +122,19 @@ export class ReportRunnerService { }; } - /** Same query, no paging — used by the export path. */ + /** + * Same query, no paging — used by the export path. + * + * `limit` is the caller's deliberate "first N" (the dialog's "Records: First + * 100"), honoured by truncating. `cap` is the format's hard ceiling, which + * throws instead. These used to be one number, which made "First 100" fail + * outright on any report with more than 100 rows. + */ async runAll( def: ReportDefinition, raw: RawReportQuery, directions: string[] | null, - limit: number, + { cap, limit }: { cap: number; limit?: number }, ): Promise<{ columns: typeof def.columns; items: Record[]; kpis: ReportRunResult['kpis'] }> { const params = coerceParams(def, raw); const ctx = { ds: this.ds, params, directions }; @@ -136,14 +143,19 @@ export class ReportRunnerService { // export is supposed to match what the user is looking at. const sort = resolveSort(def, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); - // limit + 1: fetching exactly `limit` cannot distinguish "there are exactly - // limit rows" from "there are more" — which is why the old `>= limit` check - // rejected a legitimate export of exactly the cap. - const items = await qb.limit(limit + 1).getRawMany(); - if (items.length > limit) { - throw new BadRequestException( - `Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, - ); + + const ceiling = limit ?? cap; + // ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are + // exactly that many rows" from "there are more" — which is why the old + // `>= limit` check rejected a legitimate export of exactly the cap. + const items = await qb.limit(ceiling + 1).getRawMany(); + if (items.length > ceiling) { + if (limit === undefined) { + throw new BadRequestException( + `Export exceeds the ${cap}-row cap for this format. Narrow the filters.`, + ); + } + items.length = limit; } const kpis = def.summary ? await def.summary(ctx) : []; return { columns: def.columns, items, kpis }; diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index 33a4214c8..774c992fb 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -12,9 +12,10 @@ import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissio import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { EXPORT_MIME, + formatRowCap, pickByKey, - resolveExportCap, resolveExportFormat, + resolveRowLimit, } from '../exports/export-request.util'; import { TabularExportService } from '../exports/tabular-export.service'; import { RawReportQuery, ReportRunnerService } from './report-runner.service'; @@ -101,10 +102,12 @@ export class ReportsController { const def = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const cap = resolveExportCap(format, query.limit); 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: formatRowCap(format), + limit: resolveRowLimit(format, query.limit), + }); const doc = { title: def.title, description: def.description, From 62f7b91315ead96a0691bce61f17713fc0a90f1e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 05:29:04 +0000 Subject: [PATCH 04/22] feat(exports): dataset-driven table export, starting with bookings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parallel export system the reports module can also draw on. A dataset describes a table's exportable fields — including related-entity detail the list page never shows — and the engine assembles a query from whichever fields the caller picked. GET /exports catalog (metadata only; select/requires never ship) GET /exports/:key/count exact row count + per-format caps GET /exports/:key/download csv | xlsx | pdf Two invariants carry the design: - Every lazy join is a LEFT join, and ExportJoin has no 'kind' field to make anything else expressible. An inner join added because a checkbox was ticked would change the rowset, so two exports of the same filters would disagree on their row count. - Because of that, the count cannot depend on field selection, so /count runs base + alwaysJoin only and is exact rather than an estimate. Verified: count and the delivered file both report 223 rows. One-to-many relations (a booking's containers) aggregate in a correlated subquery rather than joining, so a row can never multiply. Export rides each dataset's existing view permission — no new permission keys and no seeder change. Sensitive columns are simply never declared as fields: raw gateway payloads, signature blobs, error dumps, raw jsonb snapshots, internal user UUIDs and review notes are all absent by construction. bookings ships 77 fields across 10 groups. scripts/validate-export-datasets.ts EXPLAINs every dataset's widest query, its count query, and each field on its own against the real database — the per-field pass is what catches a field referencing a join it forgot to declare, which otherwise only fails when that one field is picked alone. --- apps/edr-freight-api/src/app.module.ts | 2 + .../exports/datasets/bookings.dataset.ts | 223 ++++++++++++++++++ .../src/modules/exports/export-filter.util.ts | 81 +++++++ .../exports/export-query.builder.spec.ts | 92 ++++++++ .../modules/exports/export-query.builder.ts | 101 ++++++++ .../modules/exports/export-runner.service.ts | 69 ++++++ .../src/modules/exports/export.registry.ts | 15 ++ .../src/modules/exports/export.types.ts | 135 +++++++++++ .../src/modules/exports/exports.controller.ts | 156 ++++++++++++ .../src/modules/exports/exports.module.ts | 18 +- .../src/scripts/validate-export-datasets.ts | 83 +++++++ 11 files changed, 969 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export-filter.util.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export-query.builder.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export-runner.service.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export.registry.ts create mode 100644 apps/edr-freight-api/src/modules/exports/export.types.ts create mode 100644 apps/edr-freight-api/src/modules/exports/exports.controller.ts create mode 100644 apps/edr-freight-api/src/scripts/validate-export-datasets.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b148c7eea..34825d13c 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -100,6 +100,7 @@ import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; import { ReportsModule } from "./modules/reports/reports.module"; +import { ExportsModule } from "./modules/exports/exports.module"; import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; @@ -234,6 +235,7 @@ if (!process.env.APPLICATION_NAME) { WarehousesModule, OverviewModule, ReportsModule, + ExportsModule, UserTradeAccessModule, VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts new file mode 100644 index 000000000..1e2cc9098 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -0,0 +1,223 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { Contract } from '../../contracts/entities/contract.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../../rule-engine/entities/service-type.entity'; +import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { Train } from '../../trains/entities/train.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * Domain semantics shared with `reports/definitions/bookings-list.report.ts`. + * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` + * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently + * overrides `total_amount`. Getting either wrong misreports money or weight. + */ +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; + +const STATUS_OPTIONS = [ + 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', + 'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT', + 'ARRIVED', 'DELIVERED', 'COMPLETED', +].map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); + +export const bookingsDataset: ExportDataset = { + key: 'bookings', + title: 'Bookings', + description: 'Every booking, with customer, route, cargo, contract and payment detail', + group: 'Commercial', + permission: FREIGHT_PERMS.bookings.view, + base: { entity: Booking, alias: 'b' }, + + // Every join is a LEFT join (see ExportJoin) — ticking a field must never + // change which rows come back. + joins: [ + { alias: 'c', entity: Company, on: 'c.id = b.company_id' }, + { alias: 'cp', entity: CompanyProfile, on: 'cp.id = b.company_profile_id' }, + { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = b.shipping_line_company_id' }, + { alias: 'o', entity: Yard, on: 'o.id = b.origin_yard_id' }, + { alias: 'd', entity: Yard, on: 'd.id = b.destination_yard_id' }, + { alias: 'cty', entity: CargoType, on: 'cty.id = b.cargo_type_id' }, + { alias: 'st', entity: ServiceType, on: 'st.id = b.service_type_id' }, + { alias: 'sl', entity: ShippingLine, on: 'sl.id = b.shipping_line_id' }, + { alias: 'ct', entity: Contract, on: 'ct.id = b.contract_id' }, + { alias: 't', entity: Train, on: 't.id = b.train_id' }, + // Transitive: the contract's own customer, reachable only once `ct` is in. + { alias: 'ctc', entity: Company, on: 'ctc.id = ct.company_id', requires: ['ct'] }, + ], + // `search` matches the customer name, so `c` is always present — which is + // also why the count query joins it. + alwaysJoin: ['c'], + + groups: [ + { id: 'booking', label: 'Booking' }, + { id: 'customer', label: 'Customer' }, + { id: 'route', label: 'Route' }, + { id: 'cargo', label: 'Cargo' }, + { id: 'payment', label: 'Payment' }, + { id: 'scheduling', label: 'Scheduling' }, + { id: 'contract', label: 'Contract' }, + { id: 'firstMile', label: 'First mile' }, + { id: 'lastMile', label: 'Last mile' }, + { id: 'clearance', label: 'Clearance' }, + ], + + fields: [ + // ---- Booking ------------------------------------------------------- + { key: 'reference', label: 'Reference', type: 'string', group: 'booking', default: true, select: 'b.reference', sortExpr: 'b.reference' }, + { key: 'status', label: 'Status', type: 'string', group: 'booking', default: true, select: 'b.status', sortExpr: 'b.status' }, + { key: 'bookingType', label: 'Booking type', type: 'string', group: 'booking', select: 'b.booking_type' }, + { key: 'contractKind', label: 'Contract kind', type: 'string', group: 'booking', select: 'b.contract_kind' }, + { key: 'createdAt', label: 'Created', type: 'datetime', group: 'booking', default: true, select: `to_char(b.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.created_at' }, + { key: 'updatedAt', label: 'Updated', type: 'datetime', group: 'booking', select: `to_char(b.updated_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.updated_at' }, + { key: 'expiresAt', label: 'Expires', type: 'date', group: 'booking', select: `to_char(b.expires_at, 'YYYY-MM-DD')` }, + { key: 'createdByRole', label: 'Created by role', type: 'string', group: 'booking', select: 'b.created_by_role' }, + { key: 'isSplit', label: 'Split booking', type: 'boolean', group: 'booking', select: 'b.is_split' }, + { key: 'priorityScore', label: 'Priority score', type: 'number', group: 'booking', select: 'b.priority_score', sortExpr: 'b.priority_score' }, + { key: 'versionNumber', label: 'Version', type: 'number', group: 'booking', select: 'b.version_number' }, + { key: 'pnrCode', label: 'PNR code', type: 'string', group: 'booking', select: 'b.pnr_code' }, + + // ---- Customer (the "more than the UI shows" payload) ---------------- + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' }, + { key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' }, + { key: 'customerKind', label: 'Customer kind', type: 'string', group: 'customer', requires: ['c'], select: 'c.kind' }, + { key: 'customerStatus', label: 'Customer status', type: 'string', group: 'customer', requires: ['c'], select: 'c.status' }, + { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, + { key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' }, + { key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' }, + { key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' }, + { key: 'customerContact', label: 'Contact person', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_name' }, + { key: 'customerContactPhone', label: 'Contact phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_phone' }, + { key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' }, + { key: 'customerCountry', label: 'Customer country', type: 'string', group: 'customer', requires: ['c'], select: 'c.country' }, + { key: 'customerRegion', label: 'Customer region', type: 'string', group: 'customer', requires: ['c'], select: 'c.region' }, + { key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' }, + { key: 'customerProfileType', label: 'Profile type', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.type' }, + { key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'b.is_government' }, + { key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'b.government_institution' }, + { key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' }, + + // ---- Route ---------------------------------------------------------- + { key: 'origin', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['o'], select: 'o.label' }, + { key: 'originCode', label: 'Origin code', type: 'string', group: 'route', requires: ['o'], select: 'o.code' }, + { key: 'destination', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['d'], select: 'd.label' }, + { key: 'destinationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['d'], select: 'd.code' }, + { key: 'tradeDirection', label: 'Direction', type: 'string', group: 'route', default: true, select: 'b.trade_direction', sortExpr: 'b.trade_direction' }, + { key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' }, + + // ---- Cargo ----------------------------------------------------------- + { key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + { key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' }, + { key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS }, + { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' }, + { key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' }, + { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' }, + { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' }, + { key: 'shippingLine', label: 'Shipping line', type: 'string', group: 'cargo', requires: ['sl'], select: 'sl.label' }, + { + // One-to-many, so it aggregates in a correlated subquery rather than a + // join — a join here would multiply rows and break the count contract. + key: 'containerNumbers', label: 'Container numbers', type: 'string', group: 'cargo', + select: `(SELECT string_agg(bc.container_number, ' | ' ORDER BY bc.container_number) + FROM freight.booking_container bc + WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL)`, + }, + + // ---- Payment ---------------------------------------------------------- + { key: 'amount', label: 'Amount', type: 'money', group: 'payment', default: true, select: `ROUND(${REVENUE}, 2)::float8`, sortExpr: REVENUE }, + { key: 'totalAmount', label: 'Total amount (pre-adjustment)', type: 'money', group: 'payment', select: 'b.total_amount::float8' }, + { key: 'adjustedTotalAmount', label: 'Adjusted total', type: 'money', group: 'payment', select: 'b.adjusted_total_amount::float8' }, + { key: 'adjustmentReason', label: 'Adjustment reason', type: 'string', group: 'payment', select: 'b.adjustment_reason' }, + { key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', default: true, select: 'b.payment_status', sortExpr: 'b.payment_status' }, + { key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'payment', select: 'b.payment_currency' }, + { key: 'paymentDeadline', label: 'Payment deadline', type: 'datetime', group: 'payment', select: `to_char(b.payment_deadline, 'YYYY-MM-DD HH24:MI')` }, + + // ---- Scheduling -------------------------------------------------------- + { key: 'scheduledDate', label: 'Scheduled date', type: 'date', group: 'scheduling', default: true, select: `to_char(b.scheduled_date, 'YYYY-MM-DD')`, sortExpr: 'b.scheduled_date' }, + { key: 'schedulingStatus', label: 'Scheduling status', type: 'string', group: 'scheduling', select: 'b.scheduling_status' }, + { key: 'wagonsRequired', label: 'Wagons required', type: 'number', group: 'scheduling', select: 'b.wagons_required' }, + { key: 'trainCode', label: 'Train', type: 'string', group: 'scheduling', requires: ['t'], select: 't.code' }, + { key: 'loadedAt', label: 'Loaded at', type: 'datetime', group: 'scheduling', select: `to_char(b.loaded_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'arrivedAt', label: 'Arrived at', type: 'datetime', group: 'scheduling', select: `to_char(b.arrived_at, 'YYYY-MM-DD HH24:MI')` }, + + // ---- Contract ---------------------------------------------------------- + { key: 'contractReference', label: 'Contract reference', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.reference' }, + { key: 'contractStatus', label: 'Contract status', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.status' }, + { key: 'contractCustomer', label: 'Contract customer', type: 'string', group: 'contract', requires: ['ctc'], select: 'ctc.name' }, + { key: 'contractType', label: 'Contract type', type: 'string', group: 'contract', select: 'b.contract_type' }, + { key: 'contractValidFrom', label: 'Contract valid from', type: 'date', group: 'contract', select: `to_char(b.contract_valid_from, 'YYYY-MM-DD')` }, + { key: 'contractValidUntil', label: 'Contract valid until', type: 'date', group: 'contract', select: `to_char(b.contract_valid_until, 'YYYY-MM-DD')` }, + { key: 'fullyExecutedAt', label: 'Fully executed at', type: 'datetime', group: 'contract', select: `to_char(b.fully_executed_at, 'YYYY-MM-DD HH24:MI')` }, + + // ---- First / last mile --------------------------------------------------- + { key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'firstMile', select: 'b.first_mile_pickup_address' }, + { key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'lastMile', select: 'b.last_mile_delivery_address' }, + { key: 'customerTruckPlate', label: 'Customer truck plate', type: 'string', group: 'lastMile', select: 'b.customer_truck_plate_number' }, + { key: 'customerTruckDriver', label: 'Customer truck driver', type: 'string', group: 'lastMile', select: 'b.customer_truck_driver_name' }, + { key: 'exportHandoverMode', label: 'Handover mode', type: 'string', group: 'lastMile', select: 'b.export_handover_mode' }, + + // ---- Clearance ------------------------------------------------------------- + { key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'b.customs_clearing_enabled' }, + { key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'b.customs_clearing_agent' }, + { key: 'clearancePhase', label: 'Clearance phase', type: 'string', group: 'clearance', select: 'b.clearance_current_phase' }, + { key: 'dutyRequired', label: 'Duty required', type: 'boolean', group: 'clearance', select: 'b.duty_required' }, + { key: 'vesselArrivalDate', label: 'Vessel arrival', type: 'date', group: 'clearance', select: `to_char(b.vessel_arrival_date, 'YYYY-MM-DD')` }, + { key: 'doCollectedDate', label: 'DO collected', type: 'date', group: 'clearance', select: `to_char(b.do_collected_date, 'YYYY-MM-DD')` }, + { key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' }, + ], + + filters: [ + { key: 'created', label: 'Created', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + { + key: 'tradeDirection', label: 'Direction', type: 'select', + options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })), + }, + { + key: 'freightType', label: 'Freight type', type: 'select', + options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })), + }, + { key: 'paymentStatus', label: 'Payment status', type: 'select', options: [ + { value: 'PENDING', label: 'Pending' }, + { value: 'PNR_GENERATED', label: 'PNR generated' }, + { value: 'VERIFICATION_IN_PROGRESS', label: 'Verification in progress' }, + { value: 'PAID', label: 'Paid' }, + { value: 'FAILED', label: 'Failed' }, + ] }, + { key: 'companyId', label: 'Customer', type: 'text' }, + { key: 'search', label: 'Search reference or customer', type: 'text' }, + ], + + defaultSort: { key: 'createdAt', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + // andWhere, not where: `where()` resets any condition already on the + // builder, so scope() would silently drop anything a caller added first. + qb.andWhere('b.deleted_at IS NULL'); + + if (params.createdFrom) qb.andWhere('b.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('b.created_at < :createdTo', { createdTo: params.createdTo }); + + const statuses = params.statuses as string[] | null; + if (statuses?.length) qb.andWhere('b.status IN (:...statuses)', { statuses }); + + if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); + if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus }); + if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId }); + if (params.search) { + qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + + // Trade-direction ACL. Without this the export returns rows the user's own + // list page would not show them. + applyDirectionScope(qb, 'b.trade_direction', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts new file mode 100644 index 000000000..c302d9d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -0,0 +1,81 @@ +import { DataSource } from 'typeorm'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; + +export interface ExportFilterOption { + value: string; + label: string; +} + +export interface ExportFilterDef { + key: string; + label: string; + type: ExportFilterType; + /** Static choices. Mutually exclusive with `optionsQuery`. */ + options?: ExportFilterOption[]; + /** Reference-data choices resolved from the DB and cached for the process. */ + optionsQuery?: (ds: DataSource) => Promise; +} + +/** Raw query-string bag. Per-registry filter keys, so `forbidNonWhitelisted` can't police it. */ +export type RawFilterQuery = Record; + +/** + * Coerce raw query strings into typed filter params per a filter declaration + * list. Unknown keys are dropped rather than rejected. + * + * Shared by the report runner and the export runner so the `daterange` + * handling in particular cannot drift between them: `To` is pushed forward a + * day because callers mean an INCLUSIVE end date while the SQL bound is + * exclusive (`created_at < :dateTo`). + */ +export function coerceFilterParams( + filters: ExportFilterDef[], + raw: RawFilterQuery, +): Record { + const params: Record = {}; + for (const filter of filters) { + if (filter.type === 'daterange') { + const from = raw[`${filter.key}From`]; + const to = raw[`${filter.key}To`]; + params[`${filter.key}From`] = from ? new Date(from).toISOString() : null; + params[`${filter.key}To`] = to + ? new Date(new Date(to).getTime() + DAY_MS).toISOString() + : null; + } else if (filter.type === 'multiselect') { + const csv = raw[filter.key]; + const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; + params[filter.key] = items.length ? items : null; + } else { + params[filter.key] = raw[filter.key]?.trim() || null; + } + } + return params; +} + +/** + * Process-lifetime cache for `optionsQuery` results — small, rarely-changing + * reference lists (23 stations, 18 cargo types) hit on every catalog load. + * + * ponytail: keyed by filter key alone, so two registries sharing a filter key + * share one option list. Key by `${registry}:${filterKey}` if that ever bites. + */ +const optionsCache = new Map(); + +export async function resolveFilterOptions( + filters: ExportFilterDef[], + ds: DataSource, +): Promise { + return Promise.all( + filters.map(async (filter) => { + if (!filter.optionsQuery) return filter; + const cached = optionsCache.get(filter.key); + if (cached) return { ...filter, options: cached, optionsQuery: undefined }; + const options = await filter.optionsQuery(ds); + optionsCache.set(filter.key, options); + return { ...filter, options, optionsQuery: undefined }; + }), + ); +} diff --git a/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts b/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts new file mode 100644 index 000000000..af6dd923d --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts @@ -0,0 +1,92 @@ +import { resolveJoins } from './export-query.builder'; +import { ExportDataset, ExportField } from './export.types'; + +const field = (key: string, requires?: string[]): ExportField => ({ + key, + label: key, + type: 'string', + group: 'g', + select: `x.${key}`, + requires, +}); + +/** Entities are never dereferenced by resolveJoins — only the alias graph matters. */ +const entity = {} as ExportDataset['joins'][number]['entity']; + +const dataset = ( + joins: ExportDataset['joins'], + alwaysJoin?: string[], +): ExportDataset => + ({ + key: 'test', + joins, + alwaysJoin, + fields: [], + }) as unknown as ExportDataset; + +describe('resolveJoins', () => { + it('pulls in only the joins the selected fields ask for', () => { + const ds = dataset([ + { alias: 'a', entity, on: 'a.id = b.a_id' }, + { alias: 'z', entity, on: 'z.id = b.z_id' }, + ]); + expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']); + }); + + it('selecting nothing still applies alwaysJoin — the count query relies on this', () => { + const ds = dataset( + [ + { alias: 'a', entity, on: 'a.id = b.a_id' }, + { alias: 'z', entity, on: 'z.id = b.z_id' }, + ], + ['a'], + ); + expect(resolveJoins(ds, []).map((j) => j.alias)).toEqual(['a']); + }); + + it('resolves a transitive dependency, dependency first', () => { + const ds = dataset([ + { alias: 'ct', entity, on: 'ct.id = b.contract_id' }, + { alias: 'ctc', entity, on: 'ctc.id = ct.company_id', requires: ['ct'] }, + ]); + expect(resolveJoins(ds, [field('x', ['ctc'])]).map((j) => j.alias)).toEqual(['ct', 'ctc']); + }); + + it('resolves a multi-hop chain in order', () => { + const ds = dataset([ + { alias: 'a', entity, on: 'a.id = b.a_id' }, + { alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] }, + { alias: 'cc', entity, on: 'cc.id = bb.c_id', requires: ['bb'] }, + ]); + expect(resolveJoins(ds, [field('x', ['cc'])]).map((j) => j.alias)).toEqual(['a', 'bb', 'cc']); + }); + + it('emits a shared join once, not per field that needs it', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]); + const joins = resolveJoins(ds, [field('one', ['a']), field('two', ['a'])]); + expect(joins.map((j) => j.alias)).toEqual(['a']); + }); + + it('does not duplicate a join already pulled in by alwaysJoin', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }], ['a']); + expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']); + }); + + it('throws on a cycle rather than looping forever', () => { + const ds = dataset([ + { alias: 'a', entity, on: 'a.id = bb.a_id', requires: ['bb'] }, + { alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] }, + ]); + expect(() => resolveJoins(ds, [field('x', ['a'])])).toThrow(/join cycle/); + }); + + it('throws on an undeclared alias — a typo must fail loudly, not silently 42P01', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]); + expect(() => resolveJoins(ds, [field('x', ['ghost'])])).toThrow(/unknown join alias "ghost"/); + }); + + it('a field with no requires pulls in no joins at all', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]); + expect(resolveJoins(ds, [field('plain')])).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-query.builder.ts b/apps/edr-freight-api/src/modules/exports/export-query.builder.ts new file mode 100644 index 000000000..6f4c091d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-query.builder.ts @@ -0,0 +1,101 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ExportContext, ExportDataset, ExportField, ExportJoin } from './export.types'; + +/** + * Sort expression fallback: the SELECT alias TypeORM emitted, quoted. TypeORM + * double-quotes `addSelect` aliases (preserving case), so ordering by the bare + * key lets Postgres fold it to lowercase and 42703 on any camelCase alias. + */ +export const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; + +/** + * Selected fields -> the joins they need, transitively, dependencies first. + * DFS post-order over `requires`, memoized. Deterministic: `alwaysJoin` first, + * then fields in the dataset's own declaration order. + */ +export function resolveJoins(dataset: ExportDataset, fields: ExportField[]): ExportJoin[] { + const byAlias = new Map(dataset.joins.map((j) => [j.alias, j])); + const out: ExportJoin[] = []; + const done = new Set(); + const onStack = new Set(); + + const visit = (alias: string): void => { + if (done.has(alias)) return; + if (onStack.has(alias)) { + throw new Error(`export "${dataset.key}": join cycle at alias "${alias}"`); + } + const join = byAlias.get(alias); + if (!join) { + throw new Error(`export "${dataset.key}": unknown join alias "${alias}"`); + } + onStack.add(alias); + for (const dep of join.requires ?? []) visit(dep); + onStack.delete(alias); + done.add(alias); + out.push(join); + }; + + for (const alias of dataset.alwaysJoin ?? []) visit(alias); + for (const field of fields) for (const alias of field.requires ?? []) visit(alias); + return out; +} + +/** The download query: base + only the joins the selected fields need. */ +export function buildExportQuery( + dataset: ExportDataset, + fields: ExportField[], + ctx: ExportContext, +): SelectQueryBuilder { + const qb = ctx.ds.createQueryBuilder().from(dataset.base.entity, dataset.base.alias); + for (const join of resolveJoins(dataset, fields)) { + qb.leftJoin(join.entity, join.alias, join.on); + } + for (const field of fields) qb.addSelect(field.select, field.key); + dataset.scope(ctx, qb); + return qb; +} + +/** + * The count query: same base, same `scope()`, same WHERE — but no field joins + * and no selects. Exact rather than an estimate, because every lazy join is a + * left join to a to-one side and so cannot change the row count. + */ +export function buildExportCountQuery( + dataset: ExportDataset, + ctx: ExportContext, +): SelectQueryBuilder { + const qb = ctx.ds + .createQueryBuilder() + .select('COUNT(*)::int', 'total') + .from(dataset.base.entity, dataset.base.alias); + for (const join of resolveJoins(dataset, [])) { + qb.leftJoin(join.entity, join.alias, join.on); + } + dataset.scope(ctx, qb); + return qb; +} + +/** + * Resolve a requested sort against the SELECTED fields. Restricting to selected + * fields means a sort can never pull in a join the projection didn't already + * need — which is what keeps the count query's join set correct. + */ +export function resolveExportSort( + dataset: ExportDataset, + fields: ExportField[], + sortBy?: string, + sortOrder?: string, +): { expr: string; dir: 'ASC' | 'DESC' } | null { + const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const requested = sortBy && fields.find((f) => f.key === sortBy && f.sortExpr); + if (requested) return { expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; + + if (!dataset.defaultSort) return null; + const fallback = fields.find((f) => f.key === dataset.defaultSort!.key); + if (!fallback) return null; + return { + expr: fallback.sortExpr ?? aliasSortExpr(fallback.key), + dir: dataset.defaultSort.dir, + }; +} diff --git a/apps/edr-freight-api/src/modules/exports/export-runner.service.ts b/apps/edr-freight-api/src/modules/exports/export-runner.service.ts new file mode 100644 index 000000000..816aeb38d --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-runner.service.ts @@ -0,0 +1,69 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { coerceFilterParams, RawFilterQuery } from './export-filter.util'; +import { + buildExportCountQuery, + buildExportQuery, + resolveExportSort, +} from './export-query.builder'; +import { ExportDataset, ExportField } from './export.types'; + +@Injectable() +export class ExportRunnerService { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + private context(dataset: ExportDataset, raw: RawFilterQuery, directions: string[] | null) { + return { ds: this.ds, params: coerceFilterParams(dataset.filters, raw), directions }; + } + + /** + * Exact row count for the current filters. Exact rather than estimated + * because lazy joins are all left joins to to-one sides, so the count cannot + * depend on which fields the caller picked. + */ + async count( + dataset: ExportDataset, + raw: RawFilterQuery, + directions: string[] | null, + ): Promise { + const qb = buildExportCountQuery(dataset, this.context(dataset, raw, directions)); + const row = await qb.getRawOne<{ total: number }>(); + return Number(row?.total ?? 0); + } + + /** + * Matching rows. + * + * `limit` is the caller's deliberate "first N" truncation — honoured + * silently, because they asked for it. `cap` is the format's hard ceiling — + * exceeding it throws, because a silently short file is worse than a clear + * error: nothing downstream reveals that rows are missing. + */ + async run( + dataset: ExportDataset, + fields: ExportField[], + raw: RawFilterQuery, + directions: string[] | null, + { cap, limit }: { cap: number; limit?: number }, + ): Promise[]> { + const ctx = this.context(dataset, raw, directions); + const qb = buildExportQuery(dataset, fields, ctx); + + const sort = resolveExportSort(dataset, fields, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + + const ceiling = limit ?? cap; + // ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are + // exactly that many rows" from "there are more". + const items = await qb.limit(ceiling + 1).getRawMany(); + if (items.length <= ceiling) return items; + + // Asked to be truncated -> truncate. Hit the hard cap -> say so. + if (limit !== undefined) return items.slice(0, limit); + throw new BadRequestException( + `This export has more than ${cap.toLocaleString()} rows, the limit for this format. Narrow the filters, or export a smaller number of rows.`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/exports/export.registry.ts b/apps/edr-freight-api/src/modules/exports/export.registry.ts new file mode 100644 index 000000000..dc7a3c968 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export.registry.ts @@ -0,0 +1,15 @@ +import { bookingsDataset } from './datasets/bookings.dataset'; +import { ExportDataset } from './export.types'; + +/** + * Every exportable dataset. + * + * Adding one = a new file under `datasets/` + an entry here. No frontend edit, + * no route, no permission seed — the dialog is driven entirely by the catalog + * this registry serves, and a dataset reuses its module's existing `view` key. + */ +export const DATASETS: ExportDataset[] = [bookingsDataset]; + +const BY_KEY = new Map(DATASETS.map((d) => [d.key, d])); + +export const getDataset = (key: string): ExportDataset | undefined => BY_KEY.get(key); diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts new file mode 100644 index 000000000..db12f0211 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -0,0 +1,135 @@ +import { + DataSource, + EntityTarget, + ObjectLiteral, + ObjectType, + SelectQueryBuilder, +} from 'typeorm'; + +import { ExportFilterDef } from './export-filter.util'; +import { ExportFieldType } from './tabular-export.service'; + +export type { ExportFieldType }; + +/** + * A lazily-applied relation. + * + * There is deliberately no `kind: 'inner' | 'left'` here — every join is + * emitted as a LEFT JOIN, and the type makes anything else unrepresentable. + * An inner join added only because someone ticked a checkbox would silently + * change the rowset (ticking "Customer TIN" would drop every booking with a + * null company_id), so two exports of the same filters would disagree on their + * row count. Anything that genuinely must narrow rows belongs in `scope()`, + * where it is unconditional and visible. + * + * The payoff: because a left join to a to-one side can neither add nor remove + * rows, the row count is independent of which fields are selected — which is + * what lets the count endpoint be exact rather than an estimate. + */ +export interface ExportJoin { + /** Alias used by field `select` expressions and by `requires`. */ + alias: string; + /** Entity class. Narrower than `EntityTarget` to match TypeORM's join overload. */ + entity: ObjectType; + /** ON condition; may reference the base alias and any alias in `requires`. */ + on: string; + /** Other join aliases this join's ON clause depends on. Resolved transitively. */ + requires?: string[]; +} + +/** + * One exportable column. + * + * `select` must yield exactly ONE value per base row. To surface a one-to-many + * relation (a company's profiles, a booking's containers), aggregate inside a + * correlated subquery — `(SELECT string_agg(...) FROM ... WHERE ... = base.id)` + * — rather than adding a join, which would multiply rows and break the count. + * + * Sensitive columns are simply never declared: raw gateway payloads + * (payments.raw_initiation, client_action), signature/crypto blobs + * (invoices.eims_signed_qr), internal error dumps (eims_last_error), raw jsonb + * snapshots (pricing_breakdown, document_snapshot, financial_terms, + * attributes, business_license_files), bare internal user UUIDs, and internal + * review/rejection notes. Fields are opt-in, so omission is the whole + * enforcement mechanism. + */ +export interface ExportField { + /** Response key, sheet header id, and the picker's checkbox id. */ + key: string; + label: string; + type: ExportFieldType; + /** Scalar SQL projected as `key`. */ + select: string; + /** Join aliases `select` references. Omit for base-table-only fields. */ + requires?: string[]; + /** Picker group id; must exist in the dataset's `groups`. */ + group: string; + /** Pre-ticked when the dialog opens with no preset. */ + default?: boolean; + /** ORDER BY expression. Presence makes the field sortable. */ + sortExpr?: string; +} + +export interface ExportGroup { + id: string; + label: string; +} + +export interface ExportContext { + ds: DataSource; + /** Filter values, already coerced by `coerceFilterParams`. */ + params: Record; + /** Trade-scope directions. `null` = unrestricted, `[]` = show nothing. */ + directions: string[] | null; +} + +export interface ExportDataset { + key: string; + title: string; + description: string; + group: 'Commercial' | 'Operations' | 'Finance' | 'Fleet'; + /** + * Permission to export this dataset. Reuses the module's existing `view` + * key — if you may see these rows on their list page, you may export them. + * The export never returns a row the list endpoint would not. + */ + permission: string; + base: { entity: EntityTarget; alias: string }; + joins: ExportJoin[]; + /** + * Aliases applied unconditionally because `scope()` references them. This is + * the only reason a join is eager, and the count query applies exactly these. + */ + alwaysJoin?: string[]; + groups: ExportGroup[]; + fields: ExportField[]; + filters: ExportFilterDef[]; + /** Must name a field whose `sortExpr` references only the base alias. */ + defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; + /** + * Base WHERE (soft-delete guard), filter application, and the trade-direction + * ACL. Runs identically for the count and download queries, so the row count + * the dialog shows is exactly what lands in the file. + * + * A dataset whose table carries a trade direction MUST apply it here, or the + * export leaks rows the user cannot see on the list page. + */ + scope(ctx: ExportContext, qb: SelectQueryBuilder): void; +} + +/** + * What `GET /exports` serves. `select` / `requires` / `sortExpr` are raw SQL + * and a map of the schema — they never leave the server. + */ +export interface ExportCatalogEntry { + key: string; + title: string; + description: string; + group: ExportDataset['group']; + groups: ExportGroup[]; + fields: Pick[]; + filters: ExportFilterDef[]; + formats: ('csv' | 'xlsx' | 'pdf')[]; + caps: { csv: number; xlsx: number; pdf: number }; + defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; +} diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts new file mode 100644 index 000000000..89ca7419a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -0,0 +1,156 @@ +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 { JwtGuard } from '@tria-plc/api-common/modules/auth/services/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 { 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; + +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): ExportCatalogEntry => ({ + key: dataset.key, + title: dataset.title, + description: dataset.description, + group: dataset.group, + groups: dataset.groups, + fields: dataset.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(JwtGuard) +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 { + const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); + return Promise.all( + allowed.map(async (d) => ({ + ...toCatalogEntry(d), + 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 { + const dataset = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + const format = resolveExportFormat(query.format); + const fields = this.resolveFields(dataset, 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 resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] { + if (raw?.trim()) { + const picked = pickByKey(dataset.fields, 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 !== dataset.fields.length) return picked; + } + const defaults = dataset.fields.filter((f) => f.default); + return defaults.length ? defaults : dataset.fields; + } + + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/exports/exports.module.ts b/apps/edr-freight-api/src/modules/exports/exports.module.ts index 6f56826bf..e3c64bd4c 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.module.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.module.ts @@ -1,17 +1,23 @@ import { Module } from '@nestjs/common'; import { DocumentsModule } from '../billing/documents/documents.module'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; +import { ExportRunnerService } from './export-runner.service'; +import { ExportsController } from './exports.controller'; import { TabularExportService } from './tabular-export.service'; /** - * Export infrastructure. Currently just the shared tabular writer (xlsx / csv / - * pdf) that both the reports module and — once the dataset registry lands — the - * generic table exports write through. No domain dependencies, so any module can - * import it. + * Generic table export: a dataset registry describing far more fields than each + * list page shows (related-entity detail included), plus the shared tabular + * writer (csv / xlsx / pdf) the reports module also writes through. + * + * `TabularExportService` is exported so ReportsModule can reuse it without + * pulling in the dataset machinery. */ @Module({ - imports: [DocumentsModule], - providers: [TabularExportService], + imports: [DocumentsModule, UserTradeAccessModule], + controllers: [ExportsController], + providers: [TabularExportService, ExportRunnerService], exports: [TabularExportService], }) export class ExportsModule {} diff --git a/apps/edr-freight-api/src/scripts/validate-export-datasets.ts b/apps/edr-freight-api/src/scripts/validate-export-datasets.ts new file mode 100644 index 000000000..5ee036d3d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/validate-export-datasets.ts @@ -0,0 +1,83 @@ +/** + * EXPLAIN-validates every export dataset against the real database. + * + * CLAUDE.md hard rule: raw SQL must be validated against a real DB before it + * ships. Every dataset is hand-written SQL expressions over wide tables where + * column drift is documented history, so a typo is a runtime 500 no type-check + * can catch. This builds each dataset's WIDEST query (all fields selected, so + * every join and every subquery is exercised) plus its count query, and runs + * both through EXPLAIN. + * + * npx ts-node -r tsconfig-paths/register src/scripts/validate-export-datasets.ts + */ +import 'dotenv/config'; + +import AppDataSource from '../data-source'; +import { buildExportCountQuery, buildExportQuery } from '../modules/exports/export-query.builder'; +import { DATASETS } from '../modules/exports/export.registry'; + +async function main(): Promise { + await AppDataSource.initialize(); + let failed = 0; + + for (const dataset of DATASETS) { + const ctx = { ds: AppDataSource, params: {}, directions: null }; + + const cases: [string, () => { sql: string; params: unknown[] }][] = [ + [ + `${dataset.key} (all ${dataset.fields.length} fields)`, + () => { + const qb = buildExportQuery(dataset, dataset.fields, ctx); + return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] }; + }, + ], + [ + `${dataset.key} (count)`, + () => { + const qb = buildExportCountQuery(dataset, ctx); + return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] }; + }, + ], + ]; + + // Each field ALONE. The all-fields query above cannot catch a field that + // references an alias it forgot to declare in `requires` — some other + // field's `requires` pulls that join in, so it only 42P01s when that one + // checkbox is ticked on its own. This is the check that finds it. + for (const field of dataset.fields) { + cases.push([ + `${dataset.key}.${field.key}`, + () => { + const qb = buildExportQuery(dataset, [field], ctx); + return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] }; + }, + ]); + } + + let fieldFailures = 0; + for (const [label, build] of cases) { + const isPerField = label.startsWith(`${dataset.key}.`); + try { + const { sql } = build(); + // Parameters are all optional filters and unset here, so the generated + // SQL carries no placeholders — EXPLAIN it directly. + await AppDataSource.query(`EXPLAIN ${sql}`); + if (!isPerField) console.log(` ok ${label}`); + } catch (error) { + failed += 1; + if (isPerField) fieldFailures += 1; + console.error(` FAIL ${label}`); + console.error(` ${(error as Error).message.split('\n')[0]}`); + } + } + if (!fieldFailures) { + console.log(` ok ${dataset.key} (each of ${dataset.fields.length} fields alone)`); + } + } + + await AppDataSource.destroy(); + console.log(failed ? `\n${failed} query/queries failed.` : '\nAll export dataset SQL validated.'); + process.exit(failed ? 1 : 0); +} + +void main(); From 42b9f30057d3a94bff7549db62b2ab161e992e81 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 06:44:29 +0000 Subject: [PATCH 05/22] feat(export-ui): field-picker export dialog, mounted on bookings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Stripe-style export dialog over the /exports catalog: searchable field picker grouped by related entity, format choice, row scope, saved presets, and a live row count. The picker is what makes 77 fields usable. Groups auto-expand only when they already hold a selection, so the dialog opens showing the default columns and their groups rather than a wall of checkboxes; searching force-expands so a match can't hide inside a collapsed group. Group headers carry a tri-state checkbox and an n/total badge. The row count comes from /exports/:key/count with the page's own filters, so the button reads 'Export 223 rows' before anything is downloaded, and turns into a cap warning with a one-click 'export the first N' escape when the result is too large for the chosen format. ExportButton takes plain params rather than a UseFilters instance — four of the pages that need this haven't migrated to FilterBar yet, and coupling to the hook would have blocked them. Pagination keys are stripped in one place instead of at every call site. It renders nothing when the catalog omits the dataset, so the catalog's permission filtering IS the UI gate. Presets reuse useSavedViews unchanged by encoding the preset as a query string; a preset naming a field the catalog no longer offers is dropped on load rather than 400ing the download. Download errors go through extractDownloadErrorMessage, without which the server's row-cap message degrades to 'Request failed with status code 400'. --- .../src/components/export/ExportButton.tsx | 86 ++++ .../src/components/export/ExportDialog.tsx | 420 ++++++++++++++++++ .../backoffice/src/constants/URLS.ts | 6 + .../pages/bookings/BookingRequestsPage.tsx | 5 +- .../backoffice/src/services/api.ts | 18 + .../src/services/exports.service.ts | 42 ++ .../backoffice/src/types/exports.ts | 54 +++ 7 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/exports.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/exports.ts diff --git a/apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx new file mode 100644 index 000000000..b79017cdb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx @@ -0,0 +1,86 @@ +import { useMemo, useState } from "react"; +import { Button, Tooltip } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download } from "lucide-react"; + +import { api } from "@/services/api"; +import type { ExportParams } from "@/types/exports"; + +import { ExportDialog } from "./ExportDialog"; + +/** + * Pagination is a screen concern, never an export one — stripped here, once, + * rather than at each of the pages that mount this. + */ +const PAGINATION_KEYS = ["page", "pageSize", "skip", "take"]; + +export interface ExportButtonProps { + /** Catalog dataset key, e.g. "bookings". */ + datasetKey: string; + /** + * The page's current filters — `useFilters().params` verbatim, or a + * non-migrated page's hand-built filter object. Deliberately not typed as + * `UseFilters`: four of the pages that need this haven't migrated yet. + */ + params?: Record; + label?: string; + size?: "xs" | "sm"; +} + +/** + * Opens the export dialog for one dataset. Renders nothing when the caller + * lacks permission for that dataset — the catalog only returns what they may + * export, so an absent entry IS the permission check. + */ +export function ExportButton({ + datasetKey, + params, + label = "Export", + size = "xs", +}: ExportButtonProps) { + const [opened, setOpened] = useState(false); + const { data: catalog, isLoading } = useQuery( + api.exports.catalog.queryOptions({ staleTime: 5 * 60_000 }), + ); + + const dataset = catalog?.find((d) => d.key === datasetKey); + + const exportParams = useMemo(() => { + const out: ExportParams = {}; + for (const [key, value] of Object.entries(params ?? {})) { + if (PAGINATION_KEYS.includes(key)) continue; + if (value === undefined || value === null || value === "") continue; + out[key] = value as string | number; + } + return out; + }, [params]); + + if (isLoading || !dataset) return null; + + return ( + <> + + + + + {opened && ( + setOpened(false)} + dataset={dataset} + params={exportParams} + /> + )} + + ); +} + +export default ExportButton; diff --git a/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx new file mode 100644 index 000000000..195d05f08 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx @@ -0,0 +1,420 @@ +import { useMemo, useState } from "react"; +import { + Accordion, + Alert, + Anchor, + Badge, + Button, + Checkbox, + Chip, + Divider, + Group, + Loader, + Modal, + Popover, + Radio, + ScrollArea, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download, FileSpreadsheet, FileText, Search, Table, TriangleAlert, X } from "lucide-react"; + +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { saveBlob } from "@/components/warehouses/pdf"; +import { useSavedViews } from "@/components/filters"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/services/api"; +import { exportsService } from "@/services/exports.service"; +import type { + ExportDatasetEntry, + ExportFormat, + ExportParams, +} from "@/types/exports"; + +const FORMAT_META: Record = { + csv: { label: "CSV", Icon: Table, hint: "Best for many columns" }, + xlsx: { label: "Excel", Icon: FileSpreadsheet, hint: "Typed number columns" }, + pdf: { label: "PDF", Icon: FileText, hint: "Few columns only" }, +}; + +const ROW_SCOPES = [ + { value: "all", label: "All matching filters" }, + { value: "100", label: "First 100" }, + { value: "1000", label: "First 1,000" }, + { value: "5000", label: "First 5,000" }, +]; + +/** Beyond this a PDF's columns are too narrow to read; we warn, the server allows it. */ +const PDF_FIELD_WARN = 12; + +export interface ExportDialogProps { + opened: boolean; + onClose: () => void; + dataset: ExportDatasetEntry; + /** The page's current filters. Pagination keys are stripped by ExportButton. */ + params: ExportParams; +} + +export function ExportDialog({ opened, onClose, dataset, params }: ExportDialogProps) { + const { toast } = useToast(); + const defaultKeys = useMemo( + () => dataset.fields.filter((f) => f.default).map((f) => f.key), + [dataset.fields], + ); + + const [selected, setSelected] = useState(defaultKeys); + const [format, setFormat] = useState("csv"); + const [scope, setScope] = useState("all"); + const [search, setSearch] = useState(""); + const [exporting, setExporting] = useState(false); + const [presetName, setPresetName] = useState(""); + const [savePresetOpen, setSavePresetOpen] = useState(false); + + // A preset is stored as a query string so the existing saved-views hook can + // hold it unchanged — see useExportPresets note below. + const presets = useSavedViews(`export:${dataset.key}`); + + const { data: countData, isLoading: countLoading } = useQuery({ + ...api.exports.count.queryOptions({ input: { key: dataset.key, params } }), + enabled: opened, + staleTime: 30_000, + }); + + const total = countData?.total; + const cap = dataset.caps[format]; + const limit = scope === "all" ? undefined : Number(scope); + const rowsToExport = total === undefined ? undefined : Math.min(total, limit ?? total); + const overCap = total !== undefined && limit === undefined && total > cap; + + const selectedSet = useMemo(() => new Set(selected), [selected]); + const fieldKeys = useMemo(() => new Set(dataset.fields.map((f) => f.key)), [dataset.fields]); + + const visibleByGroup = useMemo(() => { + const q = search.trim().toLowerCase(); + const out = new Map(); + for (const group of dataset.groups) { + const fields = dataset.fields.filter( + (f) => f.group === group.id && (!q || f.label.toLowerCase().includes(q)), + ); + if (fields.length) out.set(group.id, fields); + } + return out; + }, [dataset.fields, dataset.groups, search]); + + // Searching force-expands so matches aren't hidden inside collapsed groups. + // Otherwise open only groups that already have something selected, which is + // what keeps 77 fields tractable on open. + const openGroups = search.trim() + ? [...visibleByGroup.keys()] + : dataset.groups + .filter((g) => dataset.fields.some((f) => f.group === g.id && selectedSet.has(f.key))) + .map((g) => g.id); + + const toggleField = (key: string) => + setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + + const toggleGroup = (groupId: string) => { + const keys = dataset.fields.filter((f) => f.group === groupId).map((f) => f.key); + const allOn = keys.every((k) => selectedSet.has(k)); + setSelected((prev) => + allOn ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])], + ); + }; + + const applyPreset = (query: string) => { + const p = new URLSearchParams(query); + // Drop any key the catalog no longer offers — a stale preset must not 400 + // the download by asking for a field that has since been removed. + const keys = (p.get("fields") ?? "").split(",").filter((k) => fieldKeys.has(k)); + if (keys.length) setSelected(keys); + const f = p.get("format") as ExportFormat | null; + if (f && dataset.formats.includes(f)) setFormat(f); + }; + + const savePreset = () => { + const name = presetName.trim(); + if (!name) return; + presets.save( + new URLSearchParams({ name, format, fields: selected.join(",") }).toString(), + ); + setPresetName(""); + setSavePresetOpen(false); + }; + + const handleDownload = async () => { + setExporting(true); + try { + const blob = await exportsService.download(dataset.key, format, selected, { + ...params, + ...(limit ? { limit } : {}), + }); + saveBlob(blob, `${dataset.key}-${new Date().toISOString().slice(0, 10)}.${format}`); + onClose(); + } catch (error) { + // Blob error bodies need the async decoder, or the server's row-cap + // message degrades to "Request failed with status code 400". + toast({ + variant: "destructive", + title: "Export failed", + description: await extractDownloadErrorMessage(error), + }); + } finally { + setExporting(false); + } + }; + + return ( + + + {/* Presets */} + + setSelected(defaultKeys)}> + Default columns + + setSelected(dataset.fields.map((f) => f.key))} + > + All columns + + {presets.views.map((view) => { + const name = new URLSearchParams(view.query).get("name") ?? "Preset"; + return ( + applyPreset(view.query)} + > + + {name} + { + e.stopPropagation(); + presets.remove(view.id); + }} + /> + + + ); + })} + + + + + + + setPresetName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && savePreset()} + autoFocus + /> + + + + + + + + + {/* Pick the data on the left, configure the file on the right. Stacks + on a phone, where neither column has room to sit beside the other. */} +
+ {/* Fields */} +
+ + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + + + {selected.length} of {dataset.fields.length} fields selected + + + + + + + {dataset.groups.map((group) => { + const fields = visibleByGroup.get(group.id); + if (!fields) return null; + const groupKeys = dataset.fields + .filter((f) => f.group === group.id) + .map((f) => f.key); + const on = groupKeys.filter((k) => selectedSet.has(k)).length; + return ( + + + + 0 && on < groupKeys.length} + onClick={(e) => { + e.stopPropagation(); + toggleGroup(group.id); + }} + onChange={() => undefined} + /> + + {group.label} + + + {on}/{groupKeys.length} + + + + + + {fields.map((field) => ( + toggleField(field.key)} + /> + ))} + + + + ); + })} + + + +
+ + {/* Options */} +
+ +
+ + Format + + setFormat(v as ExportFormat)}> + + {dataset.formats.map((f) => { + const { label, Icon } = FORMAT_META[f]; + return ( + + + + + {label} + + + + ); + })} + + +
+ + + } /> From 6e95c5b8b8b02958994cfb8ad83997365ba02e07 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 08:20:00 +0000 Subject: [PATCH 08/22] fix(backoffice): map overview layouts to the org's real position keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview layout table matched invented keys (`edr_operations_officer`, `edr_marketing`, …) that only ever existed as IAM roles. The positions actually configured under the unit use their own keys — `edr_freight_app/opn`, `ethiopian_gl`, `edr_freight_app/finance` — so most staff fell through to the executive fallback regardless of desk. Map every position key in the current org tree, roots and sub-positions alike, and keep the legacy role-form keys so accounts that model the desks as roles still resolve. Finance was previously unmapped entirely. Also drop a stray console.log from resolveOverviewLayout. Safety (`edr_freight_app/sf_146`) stays unmapped — no such layout exists yet. Co-Authored-By: Claude Opus 5 (1M context) --- .../overview/role-dashboards.config.ts | 87 +++++++++++++++---- .../src/pages/dashboard/OverviewPage.tsx | 36 +++++--- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts index 509f17a3c..b683a3fbe 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts +++ b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts @@ -4,44 +4,93 @@ import { getPositionKeys } from "@/lib/permissions"; /** One overview composition. Every backoffice user lands on exactly one of these. */ export type OverviewLayoutKey = | "executive" - | "operations" + | "operation" | "occ" - | "marketing" + | "marketer" | "finance" | "clearance"; export const OVERVIEW_LAYOUT_LABEL: Record = { executive: "Executive dashboard", - operations: "Operations dashboard", + operation: "Operations dashboard", occ: "Control centre dashboard", - marketing: "Marketing dashboard", + marketer: "Marketing dashboard", finance: "Finance dashboard", clearance: "Clearance & logistics dashboard", }; /** - * Role/position key → layout, in match priority order: a user holding several + * Position/role key → layout, in match priority order: a user holding several * of these keys gets the first match, so the specific operational view wins - * over the broad executive one. Position keys are matched too because the IAM - * payload models the GL desks as positions (`ethiopian_gl`) on some accounts - * and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. + * over the broad executive one. Roles are matched alongside positions because + * the IAM payload models the GL desks as positions (`ethiopian_gl`) on some + * accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. + * + * The `edr_freight_app/…` keys are the org's real position keys (root desks and + * their sub-positions) as configured under Unit → Departments. They are typed + * by hand in the Add/Edit Department form, so a new sub-position appears here + * only once someone adds it — unmapped keys fall through to `executive`. */ const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [ - ["edr_operations_officer", "operations"], - ["truck_machinery_chief", "operations"], - ["edr_line_staff", "occ"], - ["edr_gl_ethiopia", "clearance"], - ["edr_gl_djibouti", "clearance"], + // ── Clearance & logistics: both GL desks, root and sub-positions ────────── ["ethiopian_gl", "clearance"], + ["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief + ["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director + ["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer ["djibouti_gl", "clearance"], - ["edr_marketing", "marketing"], - ["edr_finance", "finance"], - ["edr_director", "executive"], - ["edr_ceo", "executive"], - ["edr_org_manager", "executive"], + ["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director + ["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief + ["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer + ["edr_gl_ethiopia", "clearance"], // legacy role form + ["edr_gl_djibouti", "clearance"], // legacy role form + + // ── Control centre ─────────────────────────────────────────────────────── + ["edr_freight_app/occ_001", "occ"], // OCC + ["edr_freight_app/occ_005", "occ"], // OCC Director + ["edr_line_staff", "occ"], // legacy role form + + // ── Operations: operations desk, track & machinery, rolling stock ───────── + ["edr_freight_app/opn", "operation"], // Operation + ["edr_freight_app/opcf", "operation"], // Operation Chief + ["edr_freight_app/opdr", "operation"], // Operation Director + ["edr_freight_app/opco", "operation"], // Operation Officer + ["edr_freight_app/opp_005", "operation"], // Operation Dispatcher + ["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director + ["edr_freight_app/track_001", "operation"], // Track And Machinery + ["edr_freight_app/ttk_001", "operation"], // Track Director + ["edr_freight_app/tto_001", "operation"], // Track Operator + ["edr_freight_app/rool_001", "operation"], // Rolling Stock + ["edr_freight_app/rl_003", "operation"], // Rolling Stock Director + ["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead + ["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher + ["operation", "operation"], + ["operations_chief", "operation"], + ["dispatcher", "operation"], + ["truck_machinery_chief", "operation"], + ["edr_operations_officer", "operation"], // legacy role form + + // ── Marketing ──────────────────────────────────────────────────────────── + ["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing + ["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director + ["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief + ["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer + ["marketer", "marketer"], + ["edr_marketing", "marketer"], // legacy role form + + // ── Finance ────────────────────────────────────────────────────────────── + ["edr_freight_app/finance", "finance"], + ["edr_finance", "finance"], // legacy role form + + // ── Executive: org-wide desks with no operational queue of their own ────── + ["ceo", "executive"], + ["director", "executive"], + ["chief", "executive"], + ["edr_ceo", "executive"], // legacy role form + ["edr_director", "executive"], // legacy role form + ["edr_org_manager", "executive"], // legacy role form ]; -/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */ +/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */ export function resolveOverviewLayout( user: AuthUser | null | undefined, ): OverviewLayoutKey { diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 0d1f5a6e7..eadd2f1c6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -24,14 +24,21 @@ import { useOverview } from "@/hooks/useOverview"; import type { OverviewRange } from "@/types/overview"; import "@/components/overview/summary/overview-summary.css"; -const RANGE_LABEL: Record = { "7d": "7d", "30d": "30d", "90d": "90d" }; +const RANGE_LABEL: Record = { + "7d": "7d", + "30d": "30d", + "90d": "90d", +}; /** Which composition each role sees below the hero. */ -const LAYOUTS: Record ReactElement> = { +const LAYOUTS: Record< + OverviewLayoutKey, + (props: RoleOverviewProps) => ReactElement +> = { executive: ExecutiveOverview, - operations: OperationsOverview, + operation: OperationsOverview, occ: OccOverview, - marketing: MarketingOverview, + marketer: MarketingOverview, finance: FinanceOverview, clearance: ClearanceOverview, }; @@ -51,15 +58,17 @@ const OverviewPage = () => { const [range, setRange] = useState("30d"); const queryClient = useQueryClient(); const { user } = useAuth(); - const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range); + const { data, isLoading, isError, error, refetch, isFetching } = + useOverview(range); // Hero, range control and headline KPIs are role-neutral; everything below // them is chosen by role key. const layoutKey = resolveOverviewLayout(user); - const RoleLayout = LAYOUTS[layoutKey]; + const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null; const accessDenied = - (error as { response?: { status?: number } } | null)?.response?.status === 403; + (error as { response?: { status?: number } } | null)?.response?.status === + 403; const handleRefresh = () => { void refetch(); @@ -79,7 +88,9 @@ const OverviewPage = () => { label={OVERVIEW_LAYOUT_LABEL[layoutKey]} /> {data ? ( -
+
{ > Check your connection and try again. - @@ -117,7 +133,7 @@ const OverviewPage = () => { - ) : data ? ( + ) : data && RoleLayout ? ( ) : null} From 87ec7cec07c205f68e952e081637c7d9b7493b1c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 08:25:47 +0000 Subject: [PATCH 09/22] fix(export-ui): let field groups be expanded and collapsed by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accordion's open state was derived from the selection on every render, which made it fully controlled with nothing driving it. Clicking a group that had no fields selected opened it for one render and the recomputed value immediately shut it again, so such a group could only be opened by selecting something inside it — and conversely a group with a selection could not be collapsed at all. Open state is now real state with an onChange, seeded from the fields marked default rather than the live selection, so clearing every field doesn't close the groups underneath the user. Search still force-opens every group holding a match, but only as a display override — the manual state survives and returns when the search clears. --- .../src/components/export/ExportDialog.tsx | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx index 195d05f08..6c7af128c 100644 --- a/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx @@ -105,14 +105,22 @@ export function ExportDialog({ opened, onClose, dataset, params }: ExportDialogP return out; }, [dataset.fields, dataset.groups, search]); - // Searching force-expands so matches aren't hidden inside collapsed groups. - // Otherwise open only groups that already have something selected, which is - // what keeps 77 fields tractable on open. - const openGroups = search.trim() - ? [...visibleByGroup.keys()] - : dataset.groups - .filter((g) => dataset.fields.some((f) => f.group === g.id && selectedSet.has(f.key))) - .map((g) => g.id); + // Which groups are expanded. Real state, NOT derived from the selection: + // deriving it made the accordion fully controlled with no way to change it, + // so clicking a group that had nothing selected re-collapsed on the next + // render and the group could only be opened by selecting a field in it. + // Seeded from `default` (not the live selection) so clearing every field + // doesn't slam the open groups shut underneath the user. + const [expanded, setExpanded] = useState(() => + dataset.groups + .filter((g) => dataset.fields.some((f) => f.group === g.id && f.default)) + .map((g) => g.id), + ); + + // Searching force-opens every group holding a match, so a hit can't hide + // inside a collapsed section. It only overrides what is displayed — the + // user's own expand state is untouched and returns when the search clears. + const openGroups = search.trim() ? [...visibleByGroup.keys()] : expanded; const toggleField = (key: string) => setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); @@ -264,7 +272,13 @@ export function ExportDialog({ opened, onClose, dataset, params }: ExportDialogP - + {dataset.groups.map((group) => { const fields = visibleByGroup.get(group.id); if (!fields) return null; From 54cf0c294460665f143716f6f49c2ab5fc7da4e3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 09:05:31 +0000 Subject: [PATCH 10/22] fix(filters): make the whole filter option row clickable The padding around an enum filter's checkbox/radio carried the hover cue but swallowed the click: the only element that toggles a Mantine Checkbox is its native