From fb21ad1541823c54214f6d25b477c092190b8f39 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 19 Aug 2026 13:51:18 +0000 Subject: [PATCH] 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