diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts index 22041146a..dd111d22a 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -5,7 +5,7 @@ import { ACTUAL_TONS_EXPR, CARGO_CATEGORY_EXPR, CARGO_CATEGORY_FILTER, - CARGO_CATEGORY_LABEL_EXPR, + CATEGORY_LABEL_OF, COUNTRY_FILTER, LOADED_WAGONS_EXPR, OPS_DATE, @@ -13,10 +13,12 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + PLAN_GRANULARITY_NOTE, implementRateExpr, - plannedValueExpr, + plannedRowsParams, + plannedRowsSql, } from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; /** The two sides of the line. Anything else is ignored rather than interpolated. */ const COUNTRIES = ['Ethiopia', 'Djibouti']; @@ -73,7 +75,8 @@ export const cargoVolumeByStationReport: ReportDefinition = { 'the Ethiopian view (GMP, Modjo, Dire Dawa, Adama, Sebeta) and the Djibouti view ' + '(DMP, DCT, Nagad), which changes which end of the corridor counts as the station and ' + 'which counts as the origination. Plan comes from Operational targets, keyed on the ' + - 'station’s yard code.', + 'station’s yard code.' + + PLAN_GRANULARITY_NOTE, group: 'Operations', filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], columns: [ @@ -94,25 +97,17 @@ export const cargoVolumeByStationReport: ReportDefinition = { const { params } = ctx; const bucket = periodTruncExprOn(OPS_DATE, params); const stationCode = stationExpr(params, 'code'); - const plan = plannedValueExpr( - 'VOLUME_TONS', - 'station', - stationCode, - `'${resolvePeriod(params).trunc}'`, - bucket, - ); - return baseQuery(ctx) + const operated = baseQuery(ctx) .select(periodExprOn(OPS_DATE, params), 'period') + .addSelect(stationCode, 'station_code') .addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station') .addSelect( `COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`, 'origination', ) - .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect(CARGO_CATEGORY_EXPR, 'category_key') .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated') - .addSelect(`${plan}::float8`, 'plan') - .addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate') .addSelect(TEU_EXPR, 'teu') .addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') @@ -122,6 +117,45 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addGroupBy(originationExpr(params, 'label')) .addGroupBy(originationExpr(params, 'code')) .addGroupBy(CARGO_CATEGORY_EXPR); + + // A station plan is keyed on station AND cargo type, so the join needs + // both. Full outer, so a station-and-cargo line that was planned and never + // ran still reports its miss — the OCC report is full of those. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.station_code, p.plan_key) AS station_code, + COALESCE(o.station, + (SELECT y2.label FROM freight.yards y2 + WHERE y2.code = p.plan_key AND y2.deleted_at IS NULL LIMIT 1), + p.plan_key) AS station, + COALESCE(o.origination, '—') AS origination, + COALESCE(o.category_key, p.plan_category) AS category_key, + COALESCE(o.operated, 0) AS operated, + COALESCE(o.teu, 0) AS teu, + COALESCE(o.wagons, 0) AS wagons, + COALESCE(o.trains, 0) AS trains, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p + ON p.period = o.period + AND p.plan_key = o.station_code + AND p.plan_category = o.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) + .select('r.period', 'period') + .addSelect('r.station', 'station') + .addSelect('r.origination', 'origination') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('r.operated::float8', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') + .addSelect('r.teu::int', 'teu') + .addSelect('r.wagons::int', 'wagons') + .addSelect('r.trains::int', 'trains'); }, async summary(ctx) { const row = await baseQuery(ctx) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts index 3f1fb4c87..68c255c11 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -5,7 +5,7 @@ import { ACTUAL_TONS_EXPR, CARGO_CATEGORY_EXPR, CARGO_CATEGORY_FILTER, - CARGO_CATEGORY_LABEL_EXPR, + CATEGORY_LABEL_OF, CHARGED_TONS_EXPR, LOADED_WAGONS_EXPR, OPS_DATE, @@ -13,10 +13,12 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + PLAN_GRANULARITY_NOTE, implementRateExpr, - plannedValueExpr, + plannedRowsParams, + plannedRowsSql, } from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; function baseQuery(ctx: ReportContext): SelectQueryBuilder { const qb = allocationLedgerQb(ctx); @@ -24,15 +26,6 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { return qb; } -const planned = (params: Record): string => - plannedValueExpr( - 'VOLUME_TONS', - 'cargo_category', - CARGO_CATEGORY_EXPR, - `'${resolvePeriod(params).trunc}'`, - periodTruncExprOn(OPS_DATE, params), - ); - export const cargoVolumePerformanceReport: ReportDefinition = { key: 'cargo-volume-performance', title: 'Cargo Volume Performance', @@ -40,7 +33,8 @@ export const cargoVolumePerformanceReport: ReportDefinition = { 'Tonnage moved per cargo category against plan. Operated is the actual loaded weight ' + 'from the marshalling record; charged volume is the standard weight capacity the same ' + 'cargo is billed on. Plan comes from Operational targets and is measured against the ' + - 'actual, not the charged, tonnage.', + 'actual, not the charged, tonnage.' + + PLAN_GRANULARITY_NOTE, group: 'Operations', filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], columns: [ @@ -58,20 +52,46 @@ export const cargoVolumePerformanceReport: ReportDefinition = { chart: { type: 'bar', x: 'category', y: ['operated'] }, query(ctx) { const bucket = periodTruncExprOn(OPS_DATE, ctx.params); - const plan = planned(ctx.params); - return baseQuery(ctx) + const operated = baseQuery(ctx) .select(periodExprOn(OPS_DATE, ctx.params), 'period') - .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') - .addSelect(CARGO_CATEGORY_EXPR, 'categoryKey') + .addSelect(CARGO_CATEGORY_EXPR, 'category_key') .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated') - .addSelect(`${plan}::float8`, 'plan') - .addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate') - .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'chargedTons') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged_tons') .addSelect(TEU_EXPR, 'teu') .addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') .groupBy(bucket) .addGroupBy(CARGO_CATEGORY_EXPR); + + // Full outer join so a planned cargo category that moved nothing still + // reports its miss instead of disappearing from the table. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.category_key, p.plan_key) AS category_key, + COALESCE(o.operated, 0) AS operated, + COALESCE(o.charged_tons, 0) AS charged_tons, + COALESCE(o.teu, 0) AS teu, + COALESCE(o.wagons, 0) AS wagons, + COALESCE(o.trains, 0) AS trains, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p + ON p.period = o.period AND p.plan_key = o.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .select('r.period', 'period') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('r.operated::float8', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') + .addSelect('r.charged_tons::float8', 'chargedTons') + .addSelect('r.teu::int', 'teu') + .addSelect('r.wagons::int', 'wagons') + .addSelect('r.trains::int', 'trains'); }, async summary(ctx) { const row = await baseQuery(ctx) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts index cedc7d08e..3cf0464d8 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts @@ -1,8 +1,9 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; -import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity'; import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.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 { Yard } from '../../rule-engine/entities/yard.entity'; import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; @@ -15,36 +16,45 @@ import { hoursBetween, } from '../operations-classification'; -const ARRIVAL = "MIN(ev.occurred_at) FILTER (WHERE ev.kind = 'ARRIVED')"; -const DEPARTURE = "MAX(ev.occurred_at) FILTER (WHERE ev.kind = 'DEPARTED')"; -const STAYING_HOURS = hoursBetween(ARRIVAL, DEPARTURE); -const STANDARD_HOURS = `MAX(${STATION_STANDARD_HOURS_EXPR})`; - /** - * Station staying time, from the checkpoints staff log as a train works a stop: - * departure minus arrival at the same station. + * A stay is an ARRIVED followed by the next DEPARTED at the same station by the + * same physical train — NOT by the same schedule. * - * The spec also asks for total loading and unloading time and for "other - * activity" (staying time minus the two). Neither is built here, because - * nothing in the schema records when loading or unloading STARTED and ENDED — - * the checkpoint kinds are only ARRIVED, DEPARTED and PASSED, and - * `facility_handling_events` stamps a single moment per booking, not a window. - * Those two columns arrive when that capture does; the staying time this report - * measures is unaffected by their absence. + * 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. */ -function baseQuery(ctx: ReportContext): SelectQueryBuilder { +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`; + +const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at'); +const STANDARD_HOURS = 's.standard_hours'; + +/** Every logged stop, with the event that followed it at the same station. */ +function stopsQuery(ctx: ReportContext): SelectQueryBuilder { 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')"); + .andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')") + .select('ts.train_number', 'train_number') + .addSelect("COALESCE(y.label, y.code, '—')", 'station') + .addSelect("COALESCE(y.country, '—')", 'country') + .addSelect('ev.kind', 'kind') + .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') + .addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, '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 }); @@ -61,18 +71,26 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { return qb; } -/** Only stops where both ends of the stay were logged can be measured. */ -const COMPLETE_STOP = `${ARRIVAL} IS NOT NULL AND ${DEPARTURE} IS NOT NULL`; +/** Only completed stops — an arrival whose departure was also logged. */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const inner = stopsQuery(ctx); + return ctx.ds + .createQueryBuilder() + .from(`(${inner.getQuery()})`, 's') + .setParameters(inner.getParameters()) + .where("s.kind = 'ARRIVED'") + .andWhere("s.next_kind = 'DEPARTED'"); +} export const stationStayingTimeReport: ReportDefinition = { key: 'station-staying-time', title: 'Station Staying Time', description: - 'How long each train stood at each station — departure minus arrival on the logged ' + - 'checkpoints — against the standard for that side of the line (10h Ethiopia, 13h ' + - 'Djibouti, both editable in Operating standards). A stop over standard needs a reason. ' + - 'Loading and unloading times are not shown: nothing in the system records when they ' + - 'start and end yet.', + 'How long each train stood at each station — the logged arrival to the same train’s ' + + 'next departure from that station — against the standard for that side of the line ' + + '(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' + + 'standard needs a reason. Loading and unloading times are not split out: nothing in ' + + 'the system records when they start and end yet.', group: 'Operations', filters: [ { key: 'date', label: 'Departure', type: 'daterange' }, @@ -82,10 +100,10 @@ export const stationStayingTimeReport: ReportDefinition = { COUNTRY_FILTER, ], columns: [ - { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, - { key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 'y.label' }, + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 's.train_number' }, + { key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 's.station' }, { key: 'country', label: 'Country', type: 'string' }, - { key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: ARRIVAL }, + { key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' }, { key: 'departedAt', label: 'Departed', type: 'date' }, { key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS }, { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, @@ -96,47 +114,31 @@ export const stationStayingTimeReport: ReportDefinition = { defaultSort: { key: 'arrivedAt', dir: 'DESC' }, query(ctx) { return baseQuery(ctx) - .select('ts.train_number', 'trainNumber') - .addSelect("COALESCE(y.label, y.code, '—')", 'station') - .addSelect("COALESCE(y.country, '—')", 'country') - .addSelect(`to_char(${ARRIVAL}, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') - .addSelect(`to_char(${DEPARTURE}, 'YYYY-MM-DD HH24:MI')`, 'departedAt') + .select("COALESCE(s.train_number, '—')", 'trainNumber') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') + .addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt') .addSelect(STAYING_HOURS, 'stayingHours') - .addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours') + .addSelect(`${STANDARD_HOURS}::float8`, 'standardHours') .addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours') .addSelect( `CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS} THEN 'Encouraging' ELSE 'Needs reason' END`, 'verdict', ) - // The note staff leave on a checkpoint is the only free text on the stop, + // The note staff leave on the checkpoint is the only free text on a stop, // so it is where a reason for an over-standard stay is recorded today. - .addSelect("COALESCE(MAX(ev.note) FILTER (WHERE ev.note IS NOT NULL), '')", 'reason') - .groupBy('ts.id') - .addGroupBy('ts.train_number') - .addGroupBy('y.id') - .addGroupBy('y.label') - .addGroupBy('y.code') - .addGroupBy('y.country') - .having(COMPLETE_STOP); + .addSelect('s.note', 'reason'); }, async summary(ctx) { - const inner = baseQuery(ctx) - .select('1', 'one') - .addSelect(STAYING_HOURS, 'staying') - .addSelect(STANDARD_HOURS, 'standard') - .groupBy('ts.id') - .addGroupBy('y.id') - .addGroupBy('y.country') - .having(COMPLETE_STOP); - - const row = await ctx.ds - .createQueryBuilder() + const row = await baseQuery(ctx) .select('COUNT(*)::int', 'stops') - .addSelect('ROUND(AVG(s.staying)::numeric, 1)::float8', 'avgHours') - .addSelect('COUNT(*) FILTER (WHERE s.staying > s.standard)::int', 'overStandard') - .from(`(${inner.getQuery()})`, 's') - .setParameters(inner.getParameters()) + .addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours') + .addSelect( + `COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`, + 'overStandard', + ) .getRawOne<{ stops: number; avgHours: number; overStandard: number }>(); return [ diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts index 5ecf6bbd1..38a8ef04f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -4,16 +4,18 @@ import { ReportContext, ReportDefinition } from '../report.types'; import { CONTAINER_CLASSES, CONTAINER_CLASS_EXPR, - CONTAINER_CLASS_LABEL_EXPR, + CONTAINER_CLASS_LABEL_OF, CONTAINERS_EXPR, OPS_DATE, OPERATIONS_FILTERS, TEU_EXPR, allocationLedgerQb, + PLAN_GRANULARITY_NOTE, implementRateExpr, - plannedValueExpr, + plannedRowsParams, + plannedRowsSql, } from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; const CONTAINERS_20 = `COALESCE(SUM(( SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci @@ -36,15 +38,6 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { return qb; } -const planned = (params: Record): string => - plannedValueExpr( - 'TEU', - 'container_class', - CONTAINER_CLASS_EXPR, - `'${resolvePeriod(params).trunc}'`, - periodTruncExprOn(OPS_DATE, params), - ); - export const teuPerformanceReport: ReportDefinition = { key: 'teu-performance', title: 'TEU Performance', @@ -52,7 +45,8 @@ export const teuPerformanceReport: ReportDefinition = { 'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' + 'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' + 'marshalling record — the containers actually allocated to wagons — not from the ' + - 'billing lines. Plan comes from Operational targets.', + 'billing lines. Plan comes from Operational targets.' + + PLAN_GRANULARITY_NOTE, group: 'Operations', filters: [ PERIOD_FILTER, @@ -73,19 +67,43 @@ export const teuPerformanceReport: ReportDefinition = { chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, query(ctx) { const bucket = periodTruncExprOn(OPS_DATE, ctx.params); - const plan = planned(ctx.params); - return baseQuery(ctx) + const operated = baseQuery(ctx) .select(periodExprOn(OPS_DATE, ctx.params), 'period') - .addSelect(CONTAINER_CLASS_LABEL_EXPR, 'containerClass') - .addSelect(CONTAINER_CLASS_EXPR, 'containerClassKey') + .addSelect(CONTAINER_CLASS_EXPR, 'class_key') .addSelect(CONTAINERS_20, 'containers20') .addSelect(CONTAINERS_40, 'containers40') .addSelect(CONTAINERS_EXPR, 'containers') .addSelect(TEU_EXPR, 'operated') - .addSelect(`${plan}::float8`, 'plan') - .addSelect(implementRateExpr(TEU_EXPR, plan), 'implementRate') .groupBy(bucket) .addGroupBy(CONTAINER_CLASS_EXPR); + + // Full outer join so a planned container class that never moved still + // reports, at zero rather than vanishing. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.class_key, p.plan_key) AS class_key, + COALESCE(o.containers20, 0) AS containers20, + COALESCE(o.containers40, 0) AS containers40, + COALESCE(o.containers, 0) AS containers, + COALESCE(o.operated, 0) AS operated, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p + ON p.period = o.period AND p.plan_key = o.class_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .select('r.period', 'period') + .addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass') + .addSelect('r.class_key', 'containerClassKey') + .addSelect('r.containers20::int', 'containers20') + .addSelect('r.containers40::int', 'containers40') + .addSelect('r.containers::int', 'containers') + .addSelect('r.operated::int', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); }, async summary(ctx) { const row = await baseQuery(ctx) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts index 8ad9bf70d..8e1c52434 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -6,17 +6,19 @@ import { ReportContext, ReportDefinition } from '../report.types'; import { CARGO_CATEGORY_EXPR, CARGO_CATEGORY_FILTER, - CARGO_CATEGORY_LABEL_EXPR, + CATEGORY_LABEL_OF, LOADED_WAGONS_EXPR, OPS_DATE, OPERATIONS_FILTERS, TRAINSETS_EXPR, allocationLedgerQb, applyCategoryFilter, + PLAN_GRANULARITY_NOTE, implementRateExpr, - plannedValueExpr, + plannedRowsParams, + plannedRowsSql, } from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; function baseQuery(ctx: ReportContext): SelectQueryBuilder { const qb = allocationLedgerQb(ctx); @@ -24,15 +26,6 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { return qb; } -const planned = (params: Record): string => - plannedValueExpr( - 'TRAINSET', - 'cargo_category', - CARGO_CATEGORY_EXPR, - `'${resolvePeriod(params).trunc}'`, - periodTruncExprOn(OPS_DATE, params), - ); - export const trainsetPerformanceReport: ReportDefinition = { key: 'trainset-performance', title: 'Trainset Performance', @@ -41,7 +34,8 @@ export const trainsetPerformanceReport: ReportDefinition = { 'loaded divided by a full trainset for that cargo (37 for vehicles, 22 for sand, ' + 'otherwise the default of 50 — all editable on Cargo Types and Operating standards), so ' + '30 wagons of a 50-wagon set reads 0.6. Plan comes from Operational targets; a period ' + - 'with no target shows no plan rather than a zero.', + 'with no target shows no plan rather than a zero.' + + PLAN_GRANULARITY_NOTE, group: 'Operations', filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], columns: [ @@ -57,18 +51,40 @@ export const trainsetPerformanceReport: ReportDefinition = { chart: { type: 'bar', x: 'category', y: ['operated'] }, query(ctx) { const bucket = periodTruncExprOn(OPS_DATE, ctx.params); - const plan = planned(ctx.params); - return baseQuery(ctx) + const operated = baseQuery(ctx) .select(periodExprOn(OPS_DATE, ctx.params), 'period') - .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') - .addSelect(CARGO_CATEGORY_EXPR, 'categoryKey') + .addSelect(CARGO_CATEGORY_EXPR, 'category_key') .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') .addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect(TRAINSETS_EXPR, 'operated') - .addSelect(`${plan}::float8`, 'plan') - .addSelect(implementRateExpr(TRAINSETS_EXPR, plan), 'implementRate') .groupBy(bucket) .addGroupBy(CARGO_CATEGORY_EXPR); + + // FULL OUTER JOIN so a category that was planned but never ran still shows, + // at zero — TypeORM's builder has no full-outer join, hence the raw text. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.category_key, p.plan_key) AS category_key, + COALESCE(o.trains, 0) AS trains, + COALESCE(o.wagons, 0) AS wagons, + COALESCE(o.operated, 0) AS operated, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p + ON p.period = o.period AND p.plan_key = o.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .select('r.period', 'period') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('r.trains::int', 'trains') + .addSelect('r.wagons::int', 'wagons') + .addSelect('r.operated::float8', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); }, async summary(ctx) { const row = await baseQuery(ctx) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts index de55dd32b..1a130e329 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts @@ -11,10 +11,17 @@ import { } from '../operations-classification'; /** - * A turn-around cycle is a whole out-and-back: Djibouti → Ethiopia → Djibouti - * (DCT1 → GMP1 → GMP2 → DCT2 in the spec's notation). That spans TWO - * departures, so the cycle's end is the NEXT departure's arrival for the same - * physical train — `lead()` over the train's departures. + * A turn-around cycle is a whole out-and-back, measured departure to the SAME + * train's next departure from the same end: Djibouti → Ethiopia → Djibouti + * (DCT1 → GMP1 → GMP2 → DCT2 in the spec's notation). + * + * That is departure-to-departure two legs later, NOT departure-to-arrival. The + * standard is built that way — the 65-hour container cycle is 21 travel + 13 + * working Nagad + 21 travel + 10 working Indode, and the closing 10 hours only + * exist if the cycle ends at the next departure. Ending it at the arrival would + * measure 55 against a 65-hour standard and report every train as early. + * EDR's own July 2026 figure checks out this way: 22:52 travel + 31:21 at DCT + + * 22:52 travel + 7:12 at Gelan = 84:17, against the 84:07 published average. * * Trains are paired by `train_sets.train_id`, the physical consist. A train set * is one-to-one with a departure, so pairing by set alone would never find a @@ -23,8 +30,8 @@ import { */ const CYCLE_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)'; const CYCLE_ORDER = 'ts.actual_departure_at'; -const lead = (column: string): string => - `lead(${column}) OVER (PARTITION BY ${CYCLE_KEY} ORDER BY ${CYCLE_ORDER})`; +const lead = (column: string, offset = 1): string => + `lead(${column}, ${offset}) OVER (PARTITION BY ${CYCLE_KEY} ORDER BY ${CYCLE_ORDER})`; /** * Hours a train stood still on one side of the line during the cycle. @@ -43,7 +50,7 @@ const stayHours = (country: string): string => `( JOIN freight.yards yy ON yy.id = e.yard_id WHERE e.deleted_at IS NULL AND yy.country = '${country}' - AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id) + AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id) GROUP BY e.train_schedule_id, e.yard_id ) q WHERE q.arr IS NOT NULL AND q.dep IS NOT NULL @@ -65,8 +72,10 @@ function cycleQuery(ctx: ReportContext): SelectQueryBuilder { .addSelect("COALESCE(oy.label, oy.code, '?')", 'origin') .addSelect("COALESCE(dy.label, dy.code, '?')", 'destination') .addSelect('ts.actual_departure_at', 'cycle_start') - .addSelect(lead('ts.actual_arrival_at'), 'cycle_end') + // Two legs on: the train is back where it started and leaving again. + .addSelect(lead('ts.actual_departure_at', 2), 'cycle_end') .addSelect(lead('ts.id'), 'return_schedule_id') + .addSelect(lead('ts.id', 2), 'next_cycle_schedule_id') .addSelect(`ROUND(${CYCLE_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours'); } @@ -84,11 +93,13 @@ export const turnaroundCycleReport: ReportDefinition = { key: 'turnaround-cycle', title: 'Turnaround Cycle', description: - 'Full out-and-back cycle per train: actual duration against the standard cycle ' + - '(65h container, 88h bulk via DMP, 96h via Negad or BCC — editable in Operating ' + - 'standards). Implement rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on ' + - 'standard scores 100. The Ethiopia, Djibouti and travelling split comes from logged ' + - 'station checkpoints and reads zero for a train whose stops were never logged.', + 'Full out-and-back cycle per train, measured from one departure to the same train’s ' + + 'departure two legs later — the way the standard is built, so the closing station ' + + 'stay is inside the cycle. Compared against the standard cycle (65h container, 88h ' + + 'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' + + 'rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' + + 'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' + + 'and reads zero for a train whose stops were never logged.', group: 'Operations', filters: [ { key: 'date', label: 'Departure', type: 'daterange' }, diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index 016c1d846..ed39536b7 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -9,7 +9,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin 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 { yardOptions } from './revenue-classification'; +import { resolvePeriod, yardOptions } from './revenue-classification'; /** * The shared vocabulary and SQL behind every operations report — turnaround, @@ -139,6 +139,15 @@ const labelCase = (keyExpr: string, options: ReportFilterOption[]): string => 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 // --------------------------------------------------------------------------- @@ -494,32 +503,92 @@ export function applyCategoryFilter( } /** - * The planned value for a group, as a correlated subselect against - * `operations_targets`. + * The planned rows for a metric, as a derived table. * - * Correlated rather than joined because the period bucket is an expression, not - * a column: joining would need the same `date_trunc` repeated in the ON clause - * and in the GROUP BY, and a mismatch between the two silently drops targets. + * A target is a rate over its own period, not a lump at its start: the plan is + * spread evenly across the days it covers, then re-gathered into the report's + * buckets. One rule covers every direction — 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 months. * - * Wrapped in MAX() so the correlated references sit inside an aggregate's - * argument. Postgres does not recognise a grouped EXPRESSION as grouped when it - * appears inside a subquery — `subquery uses ungrouped column` — and an - * aggregate argument is the one place ungrouped columns are legal. The value is - * constant within the group, so MAX() picks it exactly. + * The even spread is an assumption, and the only one available: a monthly + * figure carries no information about which days inside it were busier. + * + * The share is clipped to the user's date filter as well as to the bucket, so + * the plan always covers exactly the span the operated figure beside it covers. + * Without that, filtering to July and viewing by year would put a whole year's + * plan next to one month's work. + * + * 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 — Nagad–Dire 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. */ -export const plannedValueExpr = ( +/** + * Appended to every plan-versus-actual report's description, because the + * re-bucketing rule is not 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.'; + +/** + * 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)"; + +export const plannedRowsSql = ( metric: string, dimension: string, - dimensionKeyExpr: string, - periodTypeExpr: string, - periodStartExpr: string, -): string => `MAX(( - SELECT ot.planned_value FROM freight.operations_targets ot + params: Record, +): string => { + const unit = resolvePeriod(params); + return ` + SELECT to_char(g.bucket, '${unit.fmt}') AS period, + ot.dimension_key AS plan_key, + ot.cargo_category AS plan_category, + SUM(ot.planned_value * ( + GREATEST(0, EXTRACT(EPOCH FROM ( + LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) + - GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) + / NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) + )) AS plan_value + FROM freight.operations_targets ot + CROSS JOIN LATERAL ( + SELECT ot.period_start + CASE ot.period_type + WHEN 'week' THEN INTERVAL '7 days' + WHEN 'month' THEN INTERVAL '1 month' + WHEN 'quarter' THEN INTERVAL '3 months' + WHEN 'year' THEN INTERVAL '1 year' + ELSE INTERVAL '1 day' + END AS ends + ) t + CROSS JOIN LATERAL generate_series( + date_trunc('${unit.trunc}', ot.period_start::timestamptz), + date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), + INTERVAL '${unit.step}' + ) AS g(bucket) WHERE ot.deleted_at IS NULL AND ot.metric = '${metric}' AND ot.dimension = '${dimension}' - AND ot.dimension_key = ${dimensionKeyExpr} - AND ot.period_type = ${periodTypeExpr} - AND ot.period_start = (${periodStartExpr})::date - LIMIT 1 -))`; + AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} + AND g.bucket < ${PLAN_TO} + GROUP BY 1, 2, 3 + HAVING SUM(ot.planned_value) > 0`; +}; + +/** The bindings {@link plannedRowsSql} expects. */ +export const plannedRowsParams = ( + params: Record, +): Record => ({ + planFrom: params.dateFrom ?? null, + planTo: params.dateTo ?? null, +}); +