From a3f06597ccb7af67515c8f143d143227f65e10c3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:12:32 +0000 Subject: [PATCH] feat(reports): break charged vs actual volume down by leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../charged-vs-actual-volume.report.ts | 87 ++++++++++++++----- 1 file changed, 67 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index e495f409b..9784589e0 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -1,5 +1,6 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { Yard } from '../../rule-engine/entities/yard.entity'; import { ReportContext, ReportDefinition } from '../report.types'; import { ACTUAL_TONS_EXPR, @@ -14,14 +15,43 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + distanceKmBetween, } 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. + * A leg is one station-to-station move the train actually made: two consecutive + * checkpoints at different yards. DISTINCT because a train that works the same + * 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})`; /** @@ -29,8 +59,8 @@ const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; * 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`; +const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`; +const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`; function baseQuery(ctx: ReportContext): SelectQueryBuilder { const qb = allocationLedgerQb(ctx); @@ -38,22 +68,37 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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 { + 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 = { 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.', + 'Charged versus actual volume per leg and cargo type, with Ton/Km and Vehicle-Km. ' + + 'A leg is one station-to-station move the train actually made, read from its logged ' + + 'checkpoints; a departure with no checkpoints logged shows as its single planned ' + + '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 ' + + '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 leg’s own, so they add up across legs into the real corridor ' + + 'figure.', 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: 'leg', label: 'Leg', 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 }, @@ -66,27 +111,29 @@ export const chargedVsActualVolumeReport: ReportDefinition = { ], defaultSort: { key: 'departedAt', dir: 'DESC' }, query(ctx) { - return baseQuery(ctx) + return legQuery(ctx) .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("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(`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(`${LEG_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('leg.from_yard_id') + .addGroupBy('leg.to_yard_id') + .addGroupBy('lfy.label') + .addGroupBy('lfy.code') + .addGroupBy('lty.label') + .addGroupBy('lty.code') .addGroupBy(CARGO_CATEGORY_EXPR); }, async summary(ctx) {