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:
Nathnael
2026-08-25 13:05:18 +00:00
parent 6d1e4a630e
commit ac325a6282
3 changed files with 136 additions and 46 deletions

View File

@@ -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 });
}
}