mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
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:
@@ -4,19 +4,20 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
ALLOC_CONTAINERS_20,
|
||||
ALLOC_CONTAINERS_40,
|
||||
CHARGED_TONS_EXPR,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPERATIONS_FILTERS,
|
||||
SCHEDULE_EMPTY_WAGONS,
|
||||
REVENUE_CARGO_CATEGORY_EXPR,
|
||||
REVENUE_CARGO_FILTER,
|
||||
SCHEDULE_KM_EXPR,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
distanceKmBetween,
|
||||
} from '../operations-classification';
|
||||
import { CATEGORY_LABEL_OF } from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* 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)';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Distance is 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 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
|
||||
@@ -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`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
applyCategoryFilter(qb, ctx.params);
|
||||
const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
|
||||
applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -89,20 +111,25 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
'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.',
|
||||
'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
|
||||
'against, so a corridor’s tonnage and its revenue read in the same buckets; wagons ' +
|
||||
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
|
||||
'belong to the train, not to the leg, so they repeat on every leg it ran 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],
|
||||
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_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: 'leg', label: 'Leg', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
||||
{ key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
|
||||
{ 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: '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: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||
@@ -116,10 +143,13 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
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(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
|
||||
.addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||
.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(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||
@@ -137,7 +167,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
.addGroupBy('lfy.code')
|
||||
.addGroupBy('lty.label')
|
||||
.addGroupBy('lty.code')
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
.addGroupBy(CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CARGO_CATEGORIES,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
REVENUE_CARGO_CATEGORY_EXPR,
|
||||
CONTAINER_CLASSES,
|
||||
CONTAINER_CLASS_EXPR,
|
||||
HANDLING_STANDARD_HOURS_EXPR,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
otherActivityHours,
|
||||
plannedRowsSql,
|
||||
} from './operations-classification';
|
||||
import { REVENUE_CATEGORIES } from './revenue-classification';
|
||||
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,20 @@ describe('operations classification', () => {
|
||||
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', () => {
|
||||
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
|
||||
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));
|
||||
|
||||
@@ -11,7 +11,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 { resolvePeriod, yardOptions } from './revenue-classification';
|
||||
import { REVENUE_CATEGORIES, resolvePeriod, yardOptions } from './revenue-classification';
|
||||
|
||||
/**
|
||||
* The shared vocabulary and SQL behind every operations report — turnaround,
|
||||
@@ -115,6 +115,39 @@ export const CARGO_CATEGORY_EXPR = `CASE
|
||||
ELSE 'UNCLASSIFIED'
|
||||
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
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
@@ -330,23 +363,13 @@ export const CHARGED_TONS_EXPR = `(
|
||||
* ${stdAgg('charged_tons_per_wagon_general', 70)}
|
||||
)::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.
|
||||
*
|
||||
* A train-level figure: it belongs to the departure, not to any one cargo type
|
||||
* 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.
|
||||
* Wagons actually carrying cargo in the grouped set. The FILTER only bites on a
|
||||
* query built with `includeEmptyWagons` — every row of an allocation-grain
|
||||
* query has an allocation, so it is a no-op there.
|
||||
*/
|
||||
export const SCHEDULE_EMPTY_WAGONS = `(
|
||||
SELECT COUNT(*) FROM freight.train_set_wagons tw
|
||||
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)
|
||||
)`;
|
||||
export const LOADED_WAGONS_EXPR =
|
||||
'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(WagonBookingAllocation, 'wba')
|
||||
.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')
|
||||
const qb = ctx.ds.createQueryBuilder();
|
||||
|
||||
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')
|
||||
.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(CargoType, 'ct', 'ct.id = b.cargo_type_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('wba.deleted_at IS NULL')
|
||||
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
|
||||
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
|
||||
});
|
||||
@@ -715,10 +758,11 @@ export function applyOperationsFilters(
|
||||
export function applyCategoryFilter(
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
params: Record<string, unknown>,
|
||||
categoryExpr: string = CARGO_CATEGORY_EXPR,
|
||||
): void {
|
||||
const categories = params.categories as string[] | null;
|
||||
if (categories?.length) {
|
||||
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories });
|
||||
qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user