Files
edr-platform/apps/edr-freight-api/src/modules/reports/operations-classification.ts
Nathnael ac325a6282 feat(reports): report charged vs actual volume in revenue categories
Four changes the business asked for on Charged and Actual Volumes:

- Split the Leg column into From and To, both sortable.
- Add 20ft and 40ft container counts beside TEU, off the marshalling
  record's container items.
- Classify cargo into the revenue vocabulary rather than the operational
  one, so a corridor's tonnage and its revenue read in the same buckets.
  Charge-only buckets (incidental, first/last mile, customs) cannot be
  emitted — no physical wagon is one.
- Carry the empty wagons as rows of their own, the way the marshalling
  document lists them. `allocationLedgerQb` gains `includeEmptyWagons`,
  which starts the ledger from the wagon instead of the allocation; the
  empty and total wagon counts become plain group aggregates, so a
  departure's wagons now add up down its rows instead of every row
  repeating the train's total. Vehicle-Km lands on the empty rows and
  sums across legs.

`LOADED_WAGONS_EXPR` gains a FILTER on the allocation being present — a
no-op for every allocation-grain report, and the fix at the one place
all of them route through. `SCHEDULE_EMPTY_WAGONS` had no callers left
and is deleted.

Verified: type-check clean, 50 report specs pass (incl. a new one
asserting the cargo expression only emits keys the revenue vocabulary
offers), and the report SQL EXPLAINs and runs against edr_dev — 60 empty
wagons over 4 rows, 53,539 Vehicle-Km, 20ft/40ft counts populating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:05:18 +00:00

999 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { TrainCheckpointEvent } from '../train-scheduling/entities/train-checkpoint-event.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.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 { REVENUE_CATEGORIES, resolvePeriod, 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`;
/**
* The same cargo, classified into the REVENUE vocabulary — the categories
* `revenue-classification.ts` bills against, minus its charge-only buckets
* (incidental, first/last mile, customs), which no physical wagon can be.
*
* Mirrors the cargo arms of `REVENUE_CATEGORY_EXPR` in that expression's own
* order, so a ton and the birr charged for it land in the same bucket: empty
* re-export before domestic, domestic before anything about what is in the box.
* Reports that must reconcile tonnage against revenue group by this one; the
* operational vocabulary above keeps sand and bulk apart, which no invoice does.
*/
export const REVENUE_CARGO_CATEGORY_EXPR = `CASE
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_REEXPORT'
WHEN oy.country IS NOT NULL AND oy.country = dy.country THEN 'DOMESTIC'
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(BREAK_BULK_CODES)}) THEN 'BREAK_BULK'
WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO'
WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO'
WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK'
ELSE 'UNCLASSIFIED'
END`;
/** The revenue vocabulary as a filter, plus the wagon that carries no cargo. */
export const REVENUE_CARGO_FILTER: ReportFilterDef = {
key: 'categories',
label: 'Cargo type',
type: 'multiselect',
options: [...REVENUE_CATEGORIES, { value: 'EMPTY_WAGON', label: 'Empty wagon' }],
};
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);
/**
* The same labelling applied to a key that is already a column — for reports
* that classify in a subquery and label in the wrapper.
*/
export const CATEGORY_LABEL_OF = (keyExpr: string): string =>
labelCase(keyExpr, CARGO_CATEGORIES);
export const CONTAINER_CLASS_LABEL_OF = (keyExpr: string): string =>
labelCase(keyExpr, 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)`;
/**
* Standard loading-and-unloading time for a stop, by what the train carries.
*
* Deliberately NOT wrapped in a fallback like every other standard here: the
* reporting spec publishes no figure for handling, so there is nothing honest
* to fall back to. Until a planner enters one in Operating standards this is
* NULL, and the rate and verdict that read it stay empty rather than judging a
* train against a number nobody agreed to.
*/
export const HANDLING_STANDARD_HOURS_EXPR = `CASE
WHEN ${SCHEDULE_IS_CONTAINER} THEN std.handling_standard_hours_container
ELSE std.handling_standard_hours_bulk
END`;
/**
* 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. The FILTER only bites on a
* query built with `includeEmptyWagons` — every row of an allocation-grain
* query has an allocation, so it is a no-op there.
*/
export const LOADED_WAGONS_EXPR =
'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int';
/**
* 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.
*
* `includeEmptyWagons` turns the ledger around to start from the wagon instead:
* every wagon of the departure is a row, and one that carried nothing has a
* NULL `wba`. Only the volume report wants that — it reports the empty wagons
* as their own line — and it costs the other reports a row grain they would
* have to filter back out.
*/
export function allocationLedgerQb(
ctx: ReportContext,
opts: { includeEmptyWagons?: boolean } = {},
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds.createQueryBuilder();
if (opts.includeEmptyWagons) {
qb.from(TrainSetWagon, 'tsw')
.leftJoin(
WagonBookingAllocation,
'wba',
'wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL',
)
.where('tsw.deleted_at IS NULL');
} else {
qb.from(WagonBookingAllocation, 'wba')
.innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL')
.where('wba.deleted_at IS NULL');
}
qb.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)
.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;
}
// ---------------------------------------------------------------------------
// Station stops — the stay, and the work done inside it
// ---------------------------------------------------------------------------
/**
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
* same physical train — NOT by the same schedule.
*
* When a train turns around at a station the two halves belong to different
* departures: the arrival closes the inbound schedule and the departure opens
* the outbound one. Pairing within a schedule finds only pass-through stops and
* silently drops every turnaround, which is the longest stay a train makes.
*/
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
/**
* The loading window this stop's cargo actually took, read off the bookings
* that boarded here.
*
* `bookings.loaded_at` is stamped per booking the moment a yard operator
* presses Load, so the first and last of them bound the loading work without
* anyone entering a second set of times. The bookings belong to the DEPARTING
* schedule — a train arrives on one leg and loads for the next — which is why
* this reads `departed_schedule_id` rather than the arrival's own schedule.
*
* There is no equivalent for unloading: `autoUnloadAtYard` stamps every
* booking's `arrived_at` at the moment the checkpoint is logged, so a window
* derived from it would collapse onto the arrival and report ~0 hours of
* unloading. Unloading is only ever what staff recorded by hand.
*/
const derivedLoading = (agg: 'MIN' | 'MAX', scheduleExpr: string, yardExpr: string): string => `(
SELECT ${agg}(b.loaded_at)
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
AND tsb.train_schedule_id = (${scheduleExpr})
WHERE b.deleted_at IS NULL
AND b.origin_yard_id = (${yardExpr})
AND b.loaded_at IS NOT NULL
)`;
/**
* The departing schedule and the yard are passed in rather than read off a
* fixed alias: the stop-shaped query carries them as columns, while the
* turnaround report reads raw `train_checkpoint_events` rows and works out the
* departing leg from the cycle it already knows.
*/
export const loadingStartOn = (
alias: string,
scheduleExpr: string,
yardExpr: string,
): string =>
`COALESCE(${alias}.loading_started_at, ${derivedLoading('MIN', scheduleExpr, yardExpr)})`;
export const loadingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string =>
`COALESCE(${alias}.loading_completed_at, ${derivedLoading('MAX', scheduleExpr, yardExpr)})`;
/** The stop-shaped query's own columns — what every stay-based report uses. */
const STOP_SCHEDULE = (alias: string): string => `${alias}.departed_schedule_id`;
const STOP_YARD = (alias: string): string => `${alias}.yard_id`;
/** Hand-recorded times win; the booking-derived window is the fallback. */
export const loadingStart = (alias: string): string =>
loadingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
export const loadingEnd = (alias: string): string =>
loadingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
/** Which of the two the loading columns came from, so nobody mistakes one for the other. */
export const loadingSource = (alias: string): string => `CASE
WHEN ${alias}.loading_started_at IS NOT NULL
OR ${alias}.loading_completed_at IS NOT NULL THEN 'Logged'
WHEN ${derivedLoading('MIN', STOP_SCHEDULE(alias), STOP_YARD(alias))} IS NOT NULL THEN 'Derived'
ELSE '—'
END`;
/**
* When station work began and ended at a stop.
*
* LEAST and GREATEST ignore nulls, so a stop that only loaded (an export
* origin) or only unloaded reports that half's window on its own, and where
* both halves are known the pair spans exactly what the spec measures —
* unloading start to loading end. NULL when nothing was logged or derived: an
* unlogged stop has an unknown handling time, not a zero one.
*/
export const handlingStartOn = (
alias: string,
scheduleExpr: string,
yardExpr: string,
): string =>
`LEAST(${alias}.unloading_started_at, ${loadingStartOn(alias, scheduleExpr, yardExpr)})`;
export const handlingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string =>
`GREATEST(${loadingEndOn(alias, scheduleExpr, yardExpr)}, ${alias}.unloading_completed_at)`;
export const handlingStart = (alias: string): string =>
handlingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
export const handlingEnd = (alias: string): string =>
handlingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
/** Total loading and unloading time at a stop, in hours. */
export const handlingHours = (alias: string): string =>
hoursBetween(handlingStart(alias), handlingEnd(alias));
export const unloadingHours = (alias: string): string =>
hoursBetween(`${alias}.unloading_started_at`, `${alias}.unloading_completed_at`);
export const loadingHours = (alias: string): string =>
hoursBetween(loadingStart(alias), loadingEnd(alias));
/**
* What the stay was spent on other than handling — the spec's "other activity".
*
* Stays NULL when handling was never logged rather than collapsing to the whole
* stay, and floors at zero: handling logged slightly outside the arrival and
* departure pair is a sloppy entry, not negative activity.
*/
export const otherActivityHours = (stayExpr: string, handlingExpr: string): string =>
`CASE WHEN (${handlingExpr}) IS NULL THEN NULL
ELSE ROUND(GREATEST((${stayExpr})::numeric - (${handlingExpr})::numeric, 0), 1)::float8 END`;
/**
* Every logged stop, with the event that followed it at the same station and
* whatever loading and unloading was recorded against it.
*
* Shared by the staying-time and loading-and-unloading reports so "a stop"
* means one thing across the suite.
*/
export function stationStopsQb(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')
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.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')")
.select('ts.train_number', 'train_number')
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
.addSelect("COALESCE(y.code, '—')", 'station_code')
.addSelect("COALESCE(y.country, '—')", 'country')
.addSelect('ev.kind', 'kind')
.addSelect('ev.yard_id', 'yard_id')
.addSelect('ev.occurred_at', 'arrived_at')
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
// The leg the train LEAVES on, which is the one it loads for. A turnaround
// departs on a different schedule than it arrived on, so the booking-derived
// loading window has to follow this rather than `ev.train_schedule_id`.
.addSelect(`lead(ev.train_schedule_id) OVER (${STAY_WINDOW})`, 'departed_schedule_id')
// Handling rides the arrival row, which is the row a stay is built from.
.addSelect('ev.unloading_started_at', 'unloading_started_at')
.addSelect('ev.unloading_completed_at', 'unloading_completed_at')
.addSelect('ev.loading_started_at', 'loading_started_at')
.addSelect('ev.loading_completed_at', 'loading_completed_at')
// Classed by the leg that ARRIVED. A stop whose inbound and outbound legs
// differ in type is rare and reads as what pulled in.
.addSelect(`CASE WHEN ${SCHEDULE_IS_CONTAINER} THEN 'Container' ELSE 'Bulk' END`, 'train_type')
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
.addSelect(`ROUND(${HANDLING_STANDARD_HOURS_EXPR}, 1)`, 'handling_standard_hours')
.addSelect("COALESCE(ev.note, '')", 'note');
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 });
// Whitelisted, not bound: the same EXISTS has to read identically in the
// SELECT above, and a bound parameter cannot be reused across both.
if (params.trainType === 'CONTAINER') qb.andWhere(SCHEDULE_IS_CONTAINER);
if (params.trainType === 'BULK') qb.andWhere(`NOT ${SCHEDULE_IS_CONTAINER}`);
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
export const TRAIN_TYPE_FILTER: ReportFilterDef = {
key: 'trainType',
label: 'Train type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
};
/** Only completed stops — an arrival whose departure was also logged, alias `s`. */
export function stationStaysQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const inner = stationStopsQb(ctx);
return ctx.ds
.createQueryBuilder()
.from(`(${inner.getQuery()})`, 's')
.setParameters(inner.getParameters())
.where("s.kind = 'ARRIVED'")
.andWhere("s.next_kind = 'DEPARTED'");
}
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>,
categoryExpr: string = CARGO_CATEGORY_EXPR,
): void {
const categories = params.categories as string[] | null;
if (categories?.length) {
qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories });
}
}
/**
* Appended to every plan-versus-actual report's description, because neither
* the re-bucketing nor the catch-up rule is guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both. ' +
'Plan is the committed figure and never moves. Required is the same target treated as a ' +
'quota: whatever is still outstanding, spread across the time still left, so a period ' +
'that fell behind raises what the periods after it must carry. A target already met in ' +
'full requires nothing further. No target carries a route, a train or a direction, so ' +
'filtering by one leaves the plan columns empty rather than comparing a corridors whole ' +
'target against one slice of its work.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
/**
* How long one target's period runs. A target's span is exact — 90 days is 90
* days — and need not line up with the ragged year-end display blocks the
* `nine_month` and `ninety_day` granularities produce. The spread below is
* proportional, so partial overlap resolves correctly either way.
*/
const TARGET_SPAN = `CASE ot.period_type
WHEN 'day' THEN INTERVAL '1 day'
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'half_year' THEN INTERVAL '6 months'
WHEN 'nine_month' THEN INTERVAL '9 months'
WHEN 'ninety_day' THEN INTERVAL '90 days'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END`;
/**
* The planned rows for a metric, as a derived table: one row per bucket per
* planned key, carrying both a committed and a required figure.
*
* **Plan** — a target is a rate over its own period, not a lump at its start.
* The committed value is spread evenly across the days it covers and
* re-gathered into the report's buckets, so three monthly targets add up to a
* quarter exactly, a daily view gets a thirty-first of the month, and a week
* straddling a month boundary draws proportionally on both. The even spread is
* an assumption, and the only one available: a monthly figure carries no
* information about which days inside it were busier. This number never moves —
* Implement Rate is measured against it, so a month that missed keeps reading
* as a month that missed.
*
* **Required** — the same target read as a quota. At each bucket, whatever is
* still outstanding (committed minus everything delivered in earlier buckets)
* is spread across the time still left in the period. A year 20% met at the
* halfway mark asks the remaining months for the other 80%. Over-delivery
* clamps to zero rather than going negative: a met quota requires nothing more.
*
* `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* be built **without the user's date bounds** — see {@link attainmentCtx}.
* Attainment is a fact about the target's whole period; measuring it through
* the report's date filter would read a mid-year view as "nothing delivered
* yet" and demand the entire year's work from one month.
*
* The reports FULL OUTER JOIN this to their operated aggregate so a category
* that was planned but never ran still appears, at zero. The OCC monthly report
* does exactly that — NagadDire Dawa is planned 2,106 t and operated none, and
* publishes as 0%. Dropping the row would hide a total miss, which is the one
* thing a plan-versus-actual table exists to show.
*
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter.
*/
/**
* The plan side of a plan-versus-actual report has to obey the same cargo
* filter the operated side does. Without it the FULL OUTER JOIN re-introduces
* every planned key the user filtered out, as a row of zeros.
*
* Values are whitelisted against the vocabulary and inlined rather than bound:
* this fragment is assembled into raw CTE text, and the filter params are not
* validated upstream. An unknown value matches nothing — same as it does on the
* operated side, where the CASE can never emit it.
*/
const planKeyFilter = (dimension: string, params: Record<string, unknown>): string => {
const isClass = dimension === 'container_class';
const selected = (isClass ? params.classes : params.categories) as string[] | null;
if (!selected?.length) return '';
const vocab = isClass ? CONTAINER_CLASSES : CARGO_CATEGORIES;
const valid = selected.filter((v) => vocab.some((o) => o.value === v));
// A station target is keyed on the yard and carries its cargo type alongside.
const column = dimension === 'station' ? 'ot.cargo_category' : 'ot.dimension_key';
return valid.length ? `AND ${column} IN (${quote(valid)})` : 'AND FALSE';
};
/**
* Filters that narrow the operated population below the grain any target is
* kept at. No target carries a route, a train or a direction, so a plan read
* beside a route-filtered actual is the whole corridor's plan sitting next to
* one slice of its work — the implement rate then reads as a miss that never
* happened.
*
* There is no honest number to show, so the plan side reports nothing at all
* and Implement Rate goes NULL, the same way it does for a period with no
* target. `country` is absent on purpose: on a station plan it is a property of
* the planned key itself, and {@link planCountryFilter} narrows rather than
* suppresses.
*/
const PLAN_GRAIN_BREAKERS = ['origin', 'destination', 'trainNumber', 'direction'];
const planGrainFilter = (params: Record<string, unknown>): string =>
PLAN_GRAIN_BREAKERS.some((key) => params[key]) ? 'AND FALSE' : '';
/**
* A station target is keyed on a yard code, so the country filter — which
* decides which end of the corridor the report calls "the station" — is a real
* predicate on the plan, not a grain break. Without it the Djibouti view lists
* every Ethiopian station's target as a row that moved nothing.
*/
const planCountryFilter = (dimension: string, params: Record<string, unknown>): string => {
if (dimension !== 'station') return '';
const country = COUNTRY_FILTER.options?.find((o) => o.value === params.country)?.value;
if (!country) return '';
return `AND EXISTS (SELECT 1 FROM freight.yards y
WHERE y.code = ot.dimension_key AND y.deleted_at IS NULL
AND y.country = '${country}')`;
};
export const plannedRowsSql = (
metric: string,
dimension: string,
params: Record<string, unknown>,
actualsSql: string,
): string => {
const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
return `
WITH tgt AS (
SELECT ot.id,
ot.dimension_key,
ot.cargo_category,
ot.planned_value,
ot.period_start::timestamptz AS starts,
ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
FROM freight.operations_targets ot
WHERE ot.deleted_at IS NULL
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND ot.planned_value > 0
${planKeyFilter(dimension, params)}
${planCountryFilter(dimension, params)}
${planGrainFilter(params)}
),
-- One row per target per bucket. Generated a day at a time rather than a
-- bucket at a time: the ragged units restart their blocks each January, so
-- stepping by the unit's own width walks off the anchor in the second year.
-- Day grain also makes a bucket that only partly overlaps the target fall out
-- for free, at the same sub-day precision the clipping used before.
spread AS (
SELECT t.id,
t.dimension_key,
t.cargo_category,
t.planned_value,
EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
${bucketOf} AS bucket,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends)
- GREATEST(d.day, t.starts))))) AS secs_full,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
- GREATEST(d.day, t.starts, ${PLAN_FROM}))))) AS secs_in
FROM tgt t
CROSS JOIN LATERAL generate_series(
date_trunc('day', t.starts),
t.ends - INTERVAL '1 microsecond',
INTERVAL '1 day'
) AS d(day)
GROUP BY t.id, t.dimension_key, t.cargo_category, t.planned_value,
t.starts, t.ends, ${bucketOf}
),
-- secs_before and actual_before are strictly-preceding running sums, so a
-- bucket's requirement is decided by what happened before it, never by its
-- own result. The frame is spelled out rather than defaulted: the default
-- RANGE frame would fold peer rows into the current one.
cascaded AS (
SELECT s.*,
COALESCE(SUM(s.secs_full) OVER prior, 0) AS secs_before,
COALESCE(SUM(a.actual) OVER prior, 0) AS actual_before
FROM spread s
LEFT JOIN (${actualsSql}) a
ON a.bucket = s.bucket
AND a.act_key = s.dimension_key
AND a.act_category IS NOT DISTINCT FROM s.cargo_category
WINDOW prior AS (
PARTITION BY s.id ORDER BY s.bucket
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)
)
SELECT ${unit.labelOn('c.bucket')} AS period,
c.dimension_key AS plan_key,
c.cargo_category AS plan_category,
SUM(c.planned_value * c.secs_in / NULLIF(c.secs_total, 0)) AS plan_value,
SUM(GREATEST(0, c.planned_value - c.actual_before)
* c.secs_in / NULLIF(c.secs_total - c.secs_before, 0)) AS plan_required
FROM cascaded c
WHERE c.secs_in > 0
GROUP BY 1, 2, 3`;
};
/**
* The report's own ledger with the user's date bounds removed, for the
* attainment series {@link plannedRowsSql} cascades from. Every other filter
* stays applied, so the catch-up figure is measured on the same population as
* the `operated` column it sits beside.
*/
export const attainmentCtx = (ctx: ReportContext): ReportContext => ({
...ctx,
params: { ...ctx.params, dateFrom: null, dateTo: null },
});
/** The bindings {@link plannedRowsSql} expects. */
export const plannedRowsParams = (
params: Record<string, unknown>,
): Record<string, unknown> => ({
planFrom: params.dateFrom ?? null,
planTo: params.dateTo ?? null,
});