mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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 { ReportContext, ReportDefinition } from '../report.types';
|
||||||
import {
|
import {
|
||||||
ACTUAL_TONS_EXPR,
|
ACTUAL_TONS_EXPR,
|
||||||
CARGO_CATEGORY_EXPR,
|
ALLOC_CONTAINERS_20,
|
||||||
CARGO_CATEGORY_FILTER,
|
ALLOC_CONTAINERS_40,
|
||||||
CARGO_CATEGORY_LABEL_EXPR,
|
|
||||||
CHARGED_TONS_EXPR,
|
CHARGED_TONS_EXPR,
|
||||||
LOADED_WAGONS_EXPR,
|
LOADED_WAGONS_EXPR,
|
||||||
OPERATIONS_FILTERS,
|
OPERATIONS_FILTERS,
|
||||||
SCHEDULE_EMPTY_WAGONS,
|
REVENUE_CARGO_CATEGORY_EXPR,
|
||||||
|
REVENUE_CARGO_FILTER,
|
||||||
SCHEDULE_KM_EXPR,
|
SCHEDULE_KM_EXPR,
|
||||||
TEU_EXPR,
|
TEU_EXPR,
|
||||||
allocationLedgerQb,
|
allocationLedgerQb,
|
||||||
applyCategoryFilter,
|
applyCategoryFilter,
|
||||||
distanceKmBetween,
|
distanceKmBetween,
|
||||||
} from '../operations-classification';
|
} from '../operations-classification';
|
||||||
|
import { CATEGORY_LABEL_OF } from '../revenue-classification';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A leg is one station-to-station move the train actually made: two consecutive
|
* 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)';
|
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distance and empty-wagon count are constant within a group that includes
|
* Distance is constant within a group that includes `ts.id` and the leg —
|
||||||
* `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar
|
* MAX() satisfies Postgres without dragging a scalar subselect through the
|
||||||
* subselect through the GROUP BY.
|
* GROUP BY.
|
||||||
*/
|
*/
|
||||||
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
|
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
|
* 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`;
|
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
|
||||||
|
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
const qb = allocationLedgerQb(ctx);
|
const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
|
||||||
applyCategoryFilter(qb, ctx.params);
|
applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,20 +111,25 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
|||||||
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
|
'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 ' +
|
'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 ' +
|
'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, ' +
|
'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
|
||||||
'so they repeat on every leg it ran and across its cargo types rather than being split ' +
|
'against, so a corridor’s tonnage and its revenue read in the same buckets; wagons ' +
|
||||||
'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' +
|
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
|
||||||
'exception and are the leg’s own, so they add up across legs into the real corridor ' +
|
'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
|
||||||
'figure.',
|
'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',
|
group: 'Operations',
|
||||||
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
{ 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: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||||
{ key: 'leg', label: 'Leg', type: 'string' },
|
{ key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
|
||||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
{ 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: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||||
{ key: 'actualTons', label: 'Actual 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: 'teu', label: 'TEU', type: 'number' },
|
||||||
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||||
@@ -116,10 +143,13 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
|||||||
return legQuery(ctx)
|
return legQuery(ctx)
|
||||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
.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(`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("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
|
||||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
.addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
|
||||||
|
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
.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(TEU_EXPR, 'teu')
|
||||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||||
@@ -137,7 +167,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
|||||||
.addGroupBy('lfy.code')
|
.addGroupBy('lfy.code')
|
||||||
.addGroupBy('lty.label')
|
.addGroupBy('lty.label')
|
||||||
.addGroupBy('lty.code')
|
.addGroupBy('lty.code')
|
||||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
.addGroupBy(CATEGORY_EXPR);
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await baseQuery(ctx)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
CARGO_CATEGORIES,
|
CARGO_CATEGORIES,
|
||||||
CARGO_CATEGORY_EXPR,
|
CARGO_CATEGORY_EXPR,
|
||||||
CARGO_CATEGORY_LABEL_EXPR,
|
CARGO_CATEGORY_LABEL_EXPR,
|
||||||
|
REVENUE_CARGO_CATEGORY_EXPR,
|
||||||
CONTAINER_CLASSES,
|
CONTAINER_CLASSES,
|
||||||
CONTAINER_CLASS_EXPR,
|
CONTAINER_CLASS_EXPR,
|
||||||
HANDLING_STANDARD_HOURS_EXPR,
|
HANDLING_STANDARD_HOURS_EXPR,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
otherActivityHours,
|
otherActivityHours,
|
||||||
plannedRowsSql,
|
plannedRowsSql,
|
||||||
} from './operations-classification';
|
} from './operations-classification';
|
||||||
|
import { REVENUE_CATEGORIES } from './revenue-classification';
|
||||||
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
|
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +71,20 @@ describe('operations classification', () => {
|
|||||||
expect(missing).toEqual([]);
|
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', () => {
|
it('offers every container class the expression can emit', () => {
|
||||||
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
|
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
|
||||||
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));
|
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 { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types';
|
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,
|
* The shared vocabulary and SQL behind every operations report — turnaround,
|
||||||
@@ -115,6 +115,39 @@ export const CARGO_CATEGORY_EXPR = `CASE
|
|||||||
ELSE 'UNCLASSIFIED'
|
ELSE 'UNCLASSIFIED'
|
||||||
END`;
|
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
|
export const CONTAINER_CLASS_EXPR = `CASE
|
||||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
|
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
|
||||||
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||||
@@ -330,23 +363,13 @@ export const CHARGED_TONS_EXPR = `(
|
|||||||
* ${stdAgg('charged_tons_per_wagon_general', 70)}
|
* ${stdAgg('charged_tons_per_wagon_general', 70)}
|
||||||
)::float8`;
|
)::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.
|
* Wagons actually carrying cargo in the grouped set. The FILTER only bites on a
|
||||||
*
|
* query built with `includeEmptyWagons` — every row of an allocation-grain
|
||||||
* A train-level figure: it belongs to the departure, not to any one cargo type
|
* query has an allocation, so it is a no-op there.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export const SCHEDULE_EMPTY_WAGONS = `(
|
export const LOADED_WAGONS_EXPR =
|
||||||
SELECT COUNT(*) FROM freight.train_set_wagons tw
|
'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int';
|
||||||
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)
|
|
||||||
)`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trainsets operated: wagons loaded divided by a full trainset for this cargo.
|
* 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
|
* The booking is LEFT joined — a wagon can be allocated before its booking data
|
||||||
* is complete, and dropping those rows would understate wagon usage.
|
* 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 { params, directions } = ctx;
|
||||||
|
|
||||||
const qb = ctx.ds
|
const qb = ctx.ds.createQueryBuilder();
|
||||||
.createQueryBuilder()
|
|
||||||
.from(WagonBookingAllocation, 'wba')
|
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')
|
.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')
|
.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(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL')
|
||||||
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
|
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
|
||||||
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
||||||
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
||||||
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
||||||
.where('wba.deleted_at IS NULL')
|
|
||||||
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
|
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
|
||||||
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
|
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
|
||||||
});
|
});
|
||||||
@@ -715,10 +758,11 @@ export function applyOperationsFilters(
|
|||||||
export function applyCategoryFilter(
|
export function applyCategoryFilter(
|
||||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
|
categoryExpr: string = CARGO_CATEGORY_EXPR,
|
||||||
): void {
|
): void {
|
||||||
const categories = params.categories as string[] | null;
|
const categories = params.categories as string[] | null;
|
||||||
if (categories?.length) {
|
if (categories?.length) {
|
||||||
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories });
|
qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user