mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(WIP): filtering, exporting and more reports
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
17
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal file
17
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal 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 {}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateOperationsTargetDto } from './create-operations-target.dto';
|
||||
|
||||
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ' +
|
||||
'station’s yard code.',
|
||||
group: 'Operations',
|
||||
filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||
{ key: 'origination', label: 'Origination', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true },
|
||||
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
|
||||
{ key: 'plan', label: 'Plan', type: 'tons' },
|
||||
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'wagons', label: 'Wagons', type: 'number' },
|
||||
{ key: 'trains', label: 'Trains', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'operated', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'station', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const { params } = ctx;
|
||||
const bucket = periodTruncExprOn(OPS_DATE, params);
|
||||
const stationCode = stationExpr(params, 'code');
|
||||
const plan = plannedValueExpr(
|
||||
'VOLUME_TONS',
|
||||
'station',
|
||||
stationCode,
|
||||
`'${resolvePeriod(params).trunc}'`,
|
||||
bucket,
|
||||
);
|
||||
|
||||
return baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, params), 'period')
|
||||
.addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station')
|
||||
.addSelect(
|
||||
`COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`,
|
||||
'origination',
|
||||
)
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
|
||||
.addSelect(`${plan}::float8`, 'plan')
|
||||
.addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
.groupBy(bucket)
|
||||
.addGroupBy(stationCode)
|
||||
.addGroupBy(stationExpr(params, 'label'))
|
||||
.addGroupBy(originationExpr(params, 'label'))
|
||||
.addGroupBy(originationExpr(params, 'code'))
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||
.addSelect(`COUNT(DISTINCT ${stationExpr(ctx.params, 'code')})::int`, 'stations')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.getRawOne<{ actual: number; stations: number; teu: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||
{ label: 'Stations', value: Number(row?.stations ?? 0) },
|
||||
{ label: 'TEU', value: Number(row?.teu ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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 train’s ' +
|
||||
'cargo types rather than being split between them.',
|
||||
group: 'Operations',
|
||||
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||
{ key: 'station', label: 'Station', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
||||
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
|
||||
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
|
||||
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'departedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
|
||||
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
|
||||
.addSelect(TON_KM, 'tonKm')
|
||||
.addSelect(VEHICLE_KM, 'vehicleKm')
|
||||
.groupBy('ts.id')
|
||||
.addGroupBy('ts.train_number')
|
||||
.addGroupBy('ts.actual_departure_at')
|
||||
.addGroupBy('ts.scheduled_departure_date')
|
||||
.addGroupBy('oy.label')
|
||||
.addGroupBy('oy.code')
|
||||
.addGroupBy('dy.label')
|
||||
.addGroupBy('dy.code')
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||
.addSelect(
|
||||
`COUNT(DISTINCT ts.id) FILTER (WHERE ${SCHEDULE_KM_EXPR} IS NULL)::int`,
|
||||
'unmeasuredTrains',
|
||||
)
|
||||
.getRawOne<{ charged: number; actual: number; unmeasuredTrains: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' },
|
||||
{ label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||
// Always shown, even at zero: a corridor with no configured distance
|
||||
// silently drops out of Ton/Km, and that must be visible.
|
||||
{ label: 'Trains without a configured distance', value: Number(row?.unmeasuredTrains ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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: '%' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
))`;
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ export class CargoTypesService {
|
||||
itemsPerWagonMap: dto.itemsPerWagonMap,
|
||||
}),
|
||||
tonsPerWagonMap,
|
||||
fullTrainsetWagons: dto.fullTrainsetWagons ?? null,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
84
apps/edr-freight-api/src/scripts/tmp-ops-plan.ts
Normal file
84
apps/edr-freight-api/src/scripts/tmp-ops-plan.ts
Normal 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();
|
||||
99
apps/edr-freight-api/src/scripts/tmp-ops-verify.ts
Normal file
99
apps/edr-freight-api/src/scripts/tmp-ops-verify.ts
Normal 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();
|
||||
@@ -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",
|
||||
|
||||
18
apps/edr-freight-api/tmp-mkdb.cjs
Normal file
18
apps/edr-freight-api/tmp-mkdb.cjs
Normal 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); });
|
||||
81
apps/edr-freight-api/tmp-ops-reconcile.cjs
Normal file
81
apps/edr-freight-api/tmp-ops-reconcile.cjs
Normal 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); });
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user