feat(WIP): filtering, exporting and more reports

This commit is contained in:
Nathnael
2026-08-19 13:51:18 +00:00
parent e964a9b8f4
commit fb21ad1541
56 changed files with 3750 additions and 27 deletions

View File

@@ -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,

View File

@@ -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<void> {
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<void> {
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;`);
}
}

View File

@@ -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);
});
});

View File

@@ -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<ExportFormat, { type: string; ext: string }> = {
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<T extends { key: string }>(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;
}

View File

@@ -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 {}

View File

@@ -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 <h1>. */
title: string;
description?: string;
/** Log label handed to PdfRenderService, e.g. "report:bookings-list". */
label: string;
columns: ExportColumnLike[];
rows: Record<string, unknown>[];
kpis?: ExportKpiLike[];
}
const NUMBER_FORMAT: Partial<Record<ExportFieldType, string>> = {
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<Buffer> {
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<Buffer> {
const workbook = this.buildWorkbook(doc, { includeKpis: false });
const buffer = await workbook.csv.writeBuffer();
return Buffer.from(buffer);
}
async toPdf(doc: TabularDoc): Promise<Buffer> {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis?.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div class="tile"><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.subtitle { color: #666; margin: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(title)}</h1>
<p class="subtitle">${esc(description ?? '')}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateOperationsTargetDto } from './create-operations-target.dto';
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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<OperationsStandard>,
) {}
async get(): Promise<OperationsStandard> {
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<OperationsStandard> {
const current = await this.get();
await this.repository.update(current.id, { ...dto, updatedById: userId });
return this.get();
}
}

View File

@@ -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);
}
}

View File

@@ -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<OperationsTarget>,
) {}
findAll(query: ListOperationsTargetsQueryDto): Promise<PaginatedResponse<OperationsTarget>> {
const sortable: Record<string, string> = {
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<OperationsTarget> {
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<OperationsTarget> {
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<OperationsTarget> {
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<void> {
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<void> {
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`,
);
}
}
}

View File

@@ -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, unknown>): 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<string, unknown>, 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<string, unknown>, 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<ObjectLiteral> {
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 ' +
'stations 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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
applyCategoryFilter(qb, ctx.params);
return qb;
}
const planned = (params: Record<string, unknown>): 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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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 trains ' +
'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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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, unknown>): 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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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' },
];
},
};

View File

@@ -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<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
applyCategoryFilter(qb, ctx.params);
return qb;
}
const planned = (params: Record<string, unknown>): 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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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<ObjectLiteral> {
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: '%' },
];
},
};

View File

@@ -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)');
});
});

View File

@@ -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<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral>,
params: Record<string, unknown>,
): 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<ObjectLiteral>,
params: Record<string, unknown>,
): 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
))`;

View File

@@ -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.`,
);

View File

@@ -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<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));

View File

@@ -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, unknown>): string {
const unit = resolvePeriod(params);
return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`;
return periodExprOn(REVENUE_DATE, params);
}
function resolvePeriod(params: Record<string, unknown>): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
export function resolvePeriod(
params: Record<string, unknown>,
): (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, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`;
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): 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, unknown>): 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

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -71,6 +71,15 @@ export class CargoType extends BaseEntity {
@Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true })
tonsPerWagonMap?: Record<string, number> | 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;

View File

@@ -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;
}

View File

@@ -204,6 +204,7 @@ export class CargoTypesService {
itemsPerWagonMap: dto.itemsPerWagonMap,
}),
tonsPerWagonMap,
fullTrainsetWagons: dto.fullTrainsetWagons ?? null,
displayOrder,
});
}

View File

@@ -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);

View File

@@ -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<void> {
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();

View File

@@ -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<string, unknown>[] = [
{},
{ 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<void> {
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();

View File

@@ -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<RuleEngineResourceSlug, string> = {
"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",

View File

@@ -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); });

View File

@@ -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); });

View File

@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight Backoffice</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View File

@@ -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 = () => {
</RequirePermission>
}
/> */}
<Route
path="configuration/operations-standards"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.operationsStandards.view}
>
<OperationsStandardsPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route
path="configuration/cargo-types/:id"

View File

@@ -555,6 +555,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view,
},
{
label: "Operating standards",
href: "/dashboard/configuration/operations-standards",
permission: FREIGHT_PERMS.settings.operationsStandards.view,
},
],
},
{

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings",
},
OPERATIONS_STANDARDS: {
BASE: "/operations-standards",
},
AUDIT_LOGS: {
BASE: "/audit",
},
@@ -542,6 +546,7 @@ export const URL_CONSTANTS = {
YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances",
OPERATIONS_TARGETS: "/operations-targets",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines",

View File

@@ -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,
};
}),
});

View File

@@ -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,
});
};

View File

@@ -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",

View File

@@ -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 <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}
@@ -37,14 +48,31 @@ export default function ReportsLandingPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Revenue dashboard"
subtitle="Billed rail revenue by period, category, corridor and customer. Pick any report in the sidebar for the full table, filters and export."
title="Reports dashboard"
subtitle="Billed rail revenue and operational performance at a glance. Pick any report in the sidebar for the full table, filters and export."
/>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{visible.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
{revenue.length > 0 && (
<Stack gap="sm">
<Title order={3}>Revenue</Title>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{revenue.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
)}
{operations.length > 0 && (
<Stack gap="sm">
<Title order={3}>Operations</Title>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{operations.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
)}
</Stack>
</PageContainer>
);

View File

@@ -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, unknown>) =>
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 {

View File

@@ -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.",
},
],
},
{

View File

@@ -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<OperationsStandards, "id" | "updatedAt">;
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<Record<string, string>>({});
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 (
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Operating standards</h1>
<p className="text-sm text-muted-foreground max-w-3xl">
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.
</p>
</div>
<Button
onClick={handleSave}
disabled={!canEdit || !dirty || anyInvalid || update.isPending}
>
<Save className="h-4 w-4 mr-2" />
{update.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
{SECTIONS.map((section) => (
<Card key={section.title}>
<CardHeader>
<CardTitle>{section.title}</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{section.fields.map((field) => (
<div key={field.name} className="space-y-1">
<label
className="text-sm font-medium"
htmlFor={`standard-${field.name}`}
>
{field.label}
</label>
<div className="flex items-center gap-2">
<Input
id={`standard-${field.name}`}
type="number"
step={field.integer ? 1 : 0.01}
min={field.integer ? 1 : 0.01}
value={valueOf(field)}
disabled={isLoading || !canEdit}
aria-invalid={invalid(field)}
onChange={(e) =>
setDraft((d) => ({ ...d, [field.name]: e.target.value }))
}
/>
<span className="text-sm text-muted-foreground w-16">
{field.unit}
</span>
</div>
<p className="text-xs text-muted-foreground">
{invalid(field)
? field.integer
? "Must be a whole number above zero"
: "Must be above zero"
: field.hint}
</p>
</div>
))}
</CardContent>
</Card>
))}
{!canEdit && (
<p className="text-sm text-muted-foreground">
You can view these standards but not change them.
</p>
)}
</div>
);
}

View File

@@ -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<OperationsStandards, "id" | "updatedAt">
>;
export const operationsStandardsService = {
get: async (): Promise<OperationsStandards> => {
const response = await client.get<ApiResponse<OperationsStandards>>(BASE);
return unwrap(response.data);
},
update: async (
patch: OperationsStandardsPatch,
): Promise<OperationsStandards> => {
const response = await client.patch<ApiResponse<OperationsStandards>>(
BASE,
patch,
);
return unwrap(response.data);
},
};

View File

@@ -96,6 +96,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"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,

View File

@@ -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" },

View File

@@ -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