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>
This commit is contained in:
Nathnael
2026-08-25 13:05:18 +00:00
parent 6d1e4a630e
commit ac325a6282
3 changed files with 136 additions and 46 deletions

View File

@@ -4,19 +4,20 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types'; import { ReportContext, ReportDefinition } from '../report.types';
import { import {
ACTUAL_TONS_EXPR, ACTUAL_TONS_EXPR,
CARGO_CATEGORY_EXPR, ALLOC_CONTAINERS_20,
CARGO_CATEGORY_FILTER, ALLOC_CONTAINERS_40,
CARGO_CATEGORY_LABEL_EXPR,
CHARGED_TONS_EXPR, CHARGED_TONS_EXPR,
LOADED_WAGONS_EXPR, LOADED_WAGONS_EXPR,
OPERATIONS_FILTERS, OPERATIONS_FILTERS,
SCHEDULE_EMPTY_WAGONS, REVENUE_CARGO_CATEGORY_EXPR,
REVENUE_CARGO_FILTER,
SCHEDULE_KM_EXPR, SCHEDULE_KM_EXPR,
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
distanceKmBetween, distanceKmBetween,
} from '../operations-classification'; } from '../operations-classification';
import { CATEGORY_LABEL_OF } from '../revenue-classification';
/** /**
* A leg is one station-to-station move the train actually made: two consecutive * A leg is one station-to-station move the train actually made: two consecutive
@@ -47,13 +48,34 @@ const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)';
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)'; const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
/** /**
* Distance and empty-wagon count are constant within a group that includes * Distance is constant within a group that includes `ts.id` and the leg —
* `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar * MAX() satisfies Postgres without dragging a scalar subselect through the
* subselect through the GROUP BY. * GROUP BY.
*/ */
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`; const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
const TOTAL_WAGONS = `(COUNT(DISTINCT tsw.id) + ${EMPTY_WAGONS})::int`; /**
* The ledger carries the empty wagons as rows of their own, so both counts are
* plain aggregates over the group: an empty-wagon row has no loaded wagons and
* a cargo row has no empty ones. Read down a departure's rows and its wagons
* add up once, instead of every row repeating the train's empty total.
*/
const EMPTY_WAGONS = 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NULL)';
const TOTAL_WAGONS = 'COUNT(DISTINCT tsw.id)::int';
/**
* Cargo in the revenue vocabulary, plus the wagon that carried none.
*
* The label is built out of the key expression rather than beside it: Postgres
* only accepts an aggregate-query column inside a GROUP BY expression it can
* match verbatim, so a second `wba.id IS NULL` test of its own would demand
* `wba.id` in the GROUP BY — which would split the grain down to one row per
* allocation.
*/
const CATEGORY_EXPR = `CASE WHEN wba.id IS NULL THEN 'EMPTY_WAGON'
ELSE ${REVENUE_CARGO_CATEGORY_EXPR} END`;
const CATEGORY_LABEL_EXPR = `CASE WHEN (${CATEGORY_EXPR}) = 'EMPTY_WAGON' THEN 'Empty wagon'
ELSE ${CATEGORY_LABEL_OF(CATEGORY_EXPR)} END`;
/** /**
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no * Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
@@ -64,8 +86,8 @@ const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`; const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx); const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
applyCategoryFilter(qb, ctx.params); applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
return qb; return qb;
} }
@@ -89,20 +111,25 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' + 'corridor. 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 ' + '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 ' + 'perishables — all editable in Operating standards. Actual volume is what the ' +
'marshalling recorded. Volumes and wagon counts belong to the train, not to the leg, ' + 'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
'so they repeat on every leg it ran and across its cargo types rather than being split ' + 'against, so a corridors tonnage and its revenue read in the same buckets; wagons ' +
'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' + 'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
'exception and are the legs own, so they add up across legs into the real corridor ' + 'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
'figure.', 'being split between them — the KPIs above count each train once. Ton/Km and ' +
'Vehicle-Km are the exception and are the legs own, so they add up across legs into ' +
'the real corridor figure.',
group: 'Operations', group: 'Operations',
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
columns: [ columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, { 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: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
{ key: 'leg', label: 'Leg', type: 'string' }, { key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR }, { key: 'legTo', label: 'To', type: 'string', sortable: true, sortExpr: 'COALESCE(lty.label, lty.code)' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CATEGORY_EXPR },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true }, { key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number' }, { key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Loaded wagons', type: 'number' }, { key: 'wagons', label: 'Loaded wagons', type: 'number' },
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' }, { key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
@@ -116,10 +143,13 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
return legQuery(ctx) return legQuery(ctx)
.select("COALESCE(ts.train_number, '—')", 'trainNumber') .select("COALESCE(ts.train_number, '—')", 'trainNumber')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt') .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg') .addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') .addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
.addSelect(CATEGORY_LABEL_EXPR, 'category')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons') .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_20}), 0)::int`, 'containers20')
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_40}), 0)::int`, 'containers40')
.addSelect(TEU_EXPR, 'teu') .addSelect(TEU_EXPR, 'teu')
.addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect(LOADED_WAGONS_EXPR, 'wagons')
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons') .addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
@@ -137,7 +167,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
.addGroupBy('lfy.code') .addGroupBy('lfy.code')
.addGroupBy('lty.label') .addGroupBy('lty.label')
.addGroupBy('lty.code') .addGroupBy('lty.code')
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CATEGORY_EXPR);
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)

View File

@@ -2,6 +2,7 @@ import {
CARGO_CATEGORIES, CARGO_CATEGORIES,
CARGO_CATEGORY_EXPR, CARGO_CATEGORY_EXPR,
CARGO_CATEGORY_LABEL_EXPR, CARGO_CATEGORY_LABEL_EXPR,
REVENUE_CARGO_CATEGORY_EXPR,
CONTAINER_CLASSES, CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR, CONTAINER_CLASS_EXPR,
HANDLING_STANDARD_HOURS_EXPR, HANDLING_STANDARD_HOURS_EXPR,
@@ -13,6 +14,7 @@ import {
otherActivityHours, otherActivityHours,
plannedRowsSql, plannedRowsSql,
} from './operations-classification'; } from './operations-classification';
import { REVENUE_CATEGORIES } from './revenue-classification';
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity'; import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
/** /**
@@ -69,6 +71,20 @@ describe('operations classification', () => {
expect(missing).toEqual([]); expect(missing).toEqual([]);
}); });
/**
* The volume report groups tonnage by this expression and the finance reports
* group birr by `REVENUE_CATEGORY_EXPR`. A key only one side can emit is a
* bucket that never reconciles — and it fails silently, as a row that simply
* has no counterpart.
*/
it('classifies cargo into keys the revenue vocabulary offers', () => {
const offered = new Set(REVENUE_CATEGORIES.map((o) => o.value));
const missing = [...new Set(emittedKeys(REVENUE_CARGO_CATEGORY_EXPR))].filter(
(k) => !offered.has(k),
);
expect(missing).toEqual([]);
});
it('offers every container class the expression can emit', () => { it('offers every container class the expression can emit', () => {
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value)); const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k)); const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));

View File

@@ -11,7 +11,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types';
import { resolvePeriod, yardOptions } from './revenue-classification'; import { REVENUE_CATEGORIES, resolvePeriod, yardOptions } from './revenue-classification';
/** /**
* The shared vocabulary and SQL behind every operations report — turnaround, * The shared vocabulary and SQL behind every operations report — turnaround,
@@ -115,6 +115,39 @@ export const CARGO_CATEGORY_EXPR = `CASE
ELSE 'UNCLASSIFIED' ELSE 'UNCLASSIFIED'
END`; 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 export const CONTAINER_CLASS_EXPR = `CASE
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN' WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
@@ -330,23 +363,13 @@ export const CHARGED_TONS_EXPR = `(
* ${stdAgg('charged_tons_per_wagon_general', 70)} * ${stdAgg('charged_tons_per_wagon_general', 70)}
)::float8`; )::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. * Wagons actually carrying cargo in the grouped set. The FILTER only bites on a
* * query built with `includeEmptyWagons` — every row of an allocation-grain
* A train-level figure: it belongs to the departure, not to any one cargo type * query has an allocation, so it is a no-op there.
* 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 = `( export const LOADED_WAGONS_EXPR =
SELECT COUNT(*) FROM freight.train_set_wagons tw 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int';
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. * Trainsets operated: wagons loaded divided by a full trainset for this cargo.
@@ -440,21 +463,41 @@ const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED'];
* *
* The booking is LEFT joined — a wagon can be allocated before its booking data * The booking is LEFT joined — a wagon can be allocated before its booking data
* is complete, and dropping those rows would understate wagon usage. * 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): SelectQueryBuilder<ObjectLiteral> { export function allocationLedgerQb(
ctx: ReportContext,
opts: { includeEmptyWagons?: boolean } = {},
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx; const { params, directions } = ctx;
const qb = ctx.ds const qb = ctx.ds.createQueryBuilder();
.createQueryBuilder()
.from(WagonBookingAllocation, 'wba') 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') .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') .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(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL')
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
.where('wba.deleted_at IS NULL')
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
}); });
@@ -715,10 +758,11 @@ export function applyOperationsFilters(
export function applyCategoryFilter( export function applyCategoryFilter(
qb: SelectQueryBuilder<ObjectLiteral>, qb: SelectQueryBuilder<ObjectLiteral>,
params: Record<string, unknown>, params: Record<string, unknown>,
categoryExpr: string = CARGO_CATEGORY_EXPR,
): void { ): void {
const categories = params.categories as string[] | null; const categories = params.categories as string[] | null;
if (categories?.length) { if (categories?.length) {
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories }); qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories });
} }
} }