mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +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:
@@ -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