mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
@@ -38,22 +68,37 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
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 = {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user