feat(reports): break charged vs actual volume down by leg

The report priced every departure against its planned origin-to-destination
corridor, so a train that worked several station-to-station moves showed one
row and one distance. Ton/Km and Vehicle-Km were then computed against that
single corridor and understated the work actually done.

The row grain is now the leg: consecutive checkpoint events at different
yards, read with lead() over each schedule. DISTINCT because a train that
works the same pair twice in one departure is still one leg — without it the
join fans the cargo out and doubles every SUM in the group. Both ends fall
back to the schedule's own corridor, so a departure with no checkpoints
logged keeps exactly the single row it had before.

Only query() joins the legs. The KPIs stay corridor-level and would count the
same cargo once per leg if they had that join.
This commit is contained in:
Nathnael
2026-08-24 07:12:32 +00:00
parent cdb2f234c3
commit a3f06597cc

View File

@@ -1,5 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
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,
@@ -14,14 +15,43 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
distanceKmBetween,
} from '../operations-classification'; } from '../operations-classification';
/** /**
* Distance and empty-wagon count belong to the departure, so they are constant * A leg is one station-to-station move the train actually made: two consecutive
* within a group that includes `ts.id` — MAX() satisfies Postgres without * checkpoints at different yards. DISTINCT because a train that works the same
* dragging a scalar subselect through the GROUP BY. * pair twice in one departure is still one leg — without it the join would fan
* the cargo out again and double every SUM in the group.
*/ */
const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`; const LEGS = `(
SELECT DISTINCT e.train_schedule_id, e.from_yard_id, e.to_yard_id
FROM (
SELECT ev.train_schedule_id,
ev.yard_id AS from_yard_id,
lead(ev.yard_id) OVER (
PARTITION BY ev.train_schedule_id ORDER BY ev.occurred_at
) AS to_yard_id
FROM freight.train_checkpoint_events ev
WHERE ev.deleted_at IS NULL
) e
WHERE e.to_yard_id IS NOT NULL AND e.to_yard_id <> e.from_yard_id
)`;
/**
* LEFT joined, and both ends fall back to the schedule's own corridor: a
* departure with no checkpoints logged has no legs, and must keep the single
* origin-to-destination row it had before this report knew about legs.
*/
const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_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
* `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar
* subselect through the GROUP BY.
*/
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
/** /**
@@ -29,8 +59,8 @@ const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
* configured distance. A missing distance is not a zero distance, and zeroing * configured distance. A missing distance is not a zero distance, and zeroing
* it would understate the corridor's work without anyone noticing. * it would understate the corridor's work without anyone noticing.
*/ */
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`; const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_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);
@@ -38,22 +68,37 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return qb; return qb;
} }
/** The row grain: one leg of one departure. Only `query()` needs it — the KPIs
* are corridor-level and would count the same cargo once per leg if they had
* this join. */
function legQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return baseQuery(ctx)
.leftJoin(LEGS, 'leg', 'leg.train_schedule_id = ts.id')
.leftJoin(Yard, 'lfy', `lfy.id = ${LEG_FROM}`)
.leftJoin(Yard, 'lty', `lty.id = ${LEG_TO}`);
}
export const chargedVsActualVolumeReport: ReportDefinition = { export const chargedVsActualVolumeReport: ReportDefinition = {
key: 'charged-vs-actual-volume', key: 'charged-vs-actual-volume',
title: 'Charged and Actual Volumes', title: 'Charged and Actual Volumes',
description: description:
'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' + 'Charged versus actual volume per leg and cargo type, with Ton/Km and Vehicle-Km. ' +
'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' + 'A leg is one station-to-station move the train actually made, read from its logged ' +
'2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' + 'checkpoints; a departure with no checkpoints logged shows as its single planned ' +
'all editable in Operating standards. Actual volume is what the marshalling recorded. ' + 'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
'Vehicle-Km counts the empty wagons on that train, so it repeats across the trains ' + 'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' +
'cargo types rather than being split between them.', '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, ' +
'so they repeat on every leg it ran and across its cargo types rather than 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, CARGO_CATEGORY_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: 'station', label: 'Station', type: 'string' }, { key: 'leg', label: 'Leg', type: 'string' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR }, { key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_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 },
@@ -66,27 +111,29 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
], ],
defaultSort: { key: 'departedAt', dir: 'DESC' }, defaultSort: { key: 'departedAt', dir: 'DESC' },
query(ctx) { query(ctx) {
return baseQuery(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(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station') .addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg')
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') .addSelect(CARGO_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(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')
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm') .addSelect(`${LEG_KM}::float8`, 'distanceKm')
.addSelect(TON_KM, 'tonKm') .addSelect(TON_KM, 'tonKm')
.addSelect(VEHICLE_KM, 'vehicleKm') .addSelect(VEHICLE_KM, 'vehicleKm')
.groupBy('ts.id') .groupBy('ts.id')
.addGroupBy('ts.train_number') .addGroupBy('ts.train_number')
.addGroupBy('ts.actual_departure_at') .addGroupBy('ts.actual_departure_at')
.addGroupBy('ts.scheduled_departure_date') .addGroupBy('ts.scheduled_departure_date')
.addGroupBy('oy.label') .addGroupBy('leg.from_yard_id')
.addGroupBy('oy.code') .addGroupBy('leg.to_yard_id')
.addGroupBy('dy.label') .addGroupBy('lfy.label')
.addGroupBy('dy.code') .addGroupBy('lfy.code')
.addGroupBy('lty.label')
.addGroupBy('lty.code')
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
}, },
async summary(ctx) { async summary(ctx) {