mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
feat(WIP): filtering, exporting and more reports
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
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';
|
||||
|
||||
/**
|
||||
* The shared vocabulary and SQL behind every operations report — turnaround,
|
||||
* delay, trainset, TEU and cargo volume.
|
||||
*
|
||||
* The fact table is `wagon_booking_allocations`: one row is one booking's cargo
|
||||
* on one wagon of one departure. That is the marshalling record — what was
|
||||
* actually put on the train — and it is the only grain that can answer both
|
||||
* "how many TEU moved" and "how many wagons did it take", which the volume and
|
||||
* trainset reports need together.
|
||||
*
|
||||
* Every consumer builds its FROM through {@link allocationLedgerQb}, so the
|
||||
* table aliases below (`wba tsw ts b ct oy dy std`) are a fixed contract and
|
||||
* the fragments here reference them directly.
|
||||
*
|
||||
* This is deliberately a SECOND classification module rather than an extension
|
||||
* of `revenue-classification.ts`. That one classifies invoice lines by charge
|
||||
* code; this one classifies physical cargo by booking and cargo type. The two
|
||||
* answer different questions and a row that is one revenue category can be a
|
||||
* different operational category — an incidental charge on a container booking,
|
||||
* for instance, is INCIDENTAL revenue but container tonnage.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cargo categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CARGO_CATEGORIES: ReportFilterOption[] = [
|
||||
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' },
|
||||
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' },
|
||||
{ value: 'CONTAINER_EXPORT', label: 'Export container' },
|
||||
{ value: 'EMPTY_CONTAINER', label: 'Empty container' },
|
||||
{ value: 'FERTILIZER', label: 'Fertilizer' },
|
||||
{ value: 'RORO', label: 'RoRo' },
|
||||
{ value: 'BREAK_BULK', label: 'Break bulk' },
|
||||
{ value: 'SAND', label: 'Sand' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
{ value: 'OTHER_IMPORT', label: 'Other imports' },
|
||||
{ value: 'OTHER_EXPORT', label: 'Other export cargo' },
|
||||
{ value: 'UNCLASSIFIED', label: 'Unclassified' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Container classes for the TEU report — the four the spec names.
|
||||
* `EMPTY_CONTAINER_RETURN` is the empty re-export leg.
|
||||
*/
|
||||
export const CONTAINER_CLASSES: ReportFilterOption[] = [
|
||||
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' },
|
||||
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' },
|
||||
{ value: 'CONTAINER_EXPORT', label: 'Full export container' },
|
||||
{ value: 'EMPTY_CONTAINER_RETURN', label: 'Empty container return' },
|
||||
];
|
||||
|
||||
/** `cargo_types.code` is admin-managed, so each set absorbs every spelling seeded so far. */
|
||||
export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO'];
|
||||
export const BREAK_BULK_CODES = [
|
||||
'BREAK_BULK',
|
||||
'STEEL_BILLET',
|
||||
'STEEL',
|
||||
'MACHINERY',
|
||||
'PIPES',
|
||||
'TIMBER',
|
||||
];
|
||||
export const FERTILIZER_CODES = ['FERTILIZER'];
|
||||
export const SAND_CODES = ['SAND'];
|
||||
|
||||
/** Cargo charged at the lighter per-wagon rate — vegetables, milk, meat, livestock. */
|
||||
export const PERISHABLE_CODES = ['PERISHABLE', 'LIVESTOCK'];
|
||||
|
||||
const quote = (values: string[]): string => values.map((v) => `'${v}'`).join(', ');
|
||||
|
||||
/**
|
||||
* A booking whose equipment_return is RETURN is the empty-container movement
|
||||
* itself; WITH_RETURN / WITHOUT_RETURN describe a laden booking's obligation.
|
||||
* This is the only booking-level marker of an empty box — no table records
|
||||
* laden-vs-empty on the container row.
|
||||
*/
|
||||
const IS_EMPTY_CONTAINER = "b.equipment_return = 'RETURN'";
|
||||
|
||||
/**
|
||||
* Multimodal means a named sea carrier is on the booking — the same proxy the
|
||||
* revenue reports use. There is no explicit multimodal flag; confirm with the
|
||||
* business before treating this as definitive.
|
||||
*/
|
||||
const IS_MULTIMODAL = 'b.shipping_line_id IS NOT NULL';
|
||||
|
||||
const IS_CONTAINER = "COALESCE(b.freight_type, wba.load_type) = 'CONTAINER'";
|
||||
|
||||
export const CARGO_CATEGORY_EXPR = `CASE
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER'
|
||||
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(RORO_CODES)}) THEN 'RORO'
|
||||
WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK'
|
||||
WHEN ct.code IN (${quote(SAND_CODES)}) THEN 'SAND'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT'
|
||||
WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT'
|
||||
WHEN b.id IS NOT NULL THEN 'BULK'
|
||||
ELSE 'UNCLASSIFIED'
|
||||
END`;
|
||||
|
||||
export const CONTAINER_CLASS_EXPR = `CASE
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
WHEN ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL'
|
||||
ELSE 'CONTAINER_IMPORT_UNIMODAL'
|
||||
END`;
|
||||
|
||||
/**
|
||||
* Every fixed key a planner may enter on the targets screen — the category and
|
||||
* container-class vocabularies. Station targets are keyed on a yard code, which
|
||||
* is reference data rather than a fixed list, so they are not enumerated here.
|
||||
*/
|
||||
export const TARGET_DIMENSION_KEYS: string[] = [
|
||||
...CARGO_CATEGORIES.map((o) => o.value),
|
||||
...CONTAINER_CLASSES.map((o) => o.value),
|
||||
];
|
||||
|
||||
/** Turns a key-emitting CASE into a label-emitting one, so a report shows business names. */
|
||||
const labelCase = (keyExpr: string, options: ReportFilterOption[]): string =>
|
||||
`CASE ${options
|
||||
.map((o) => `WHEN (${keyExpr}) = '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`)
|
||||
.join(' ')} ELSE (${keyExpr}) END`;
|
||||
|
||||
export const CARGO_CATEGORY_LABEL_EXPR = labelCase(CARGO_CATEGORY_EXPR, CARGO_CATEGORIES);
|
||||
export const CONTAINER_CLASS_LABEL_EXPR = labelCase(CONTAINER_CLASS_EXPR, CONTAINER_CLASSES);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A standard, read off the joined `operations_standards` row.
|
||||
*
|
||||
* The fallback is not decoration: the row is seeded by migration, but a report
|
||||
* must not return zeros — or divide by zero — on an environment where the seed
|
||||
* has not run. The fallbacks are the spec's own figures.
|
||||
*/
|
||||
const stdRow = (column: string, fallback: number): string =>
|
||||
`COALESCE(std.${column}, ${fallback})`;
|
||||
|
||||
/**
|
||||
* The same value in an aggregate select. `std` is a single joined row, so the
|
||||
* column is constant across the group — but Postgres still demands it be
|
||||
* grouped or aggregated, and wrapping it in MAX() is cheaper than dragging it
|
||||
* through every report's GROUP BY.
|
||||
*/
|
||||
const stdAgg = (column: string, fallback: number): string =>
|
||||
`MAX(COALESCE(std.${column}, ${fallback}))`;
|
||||
|
||||
/** Standard hours a train may stand at a station, by the station's country. */
|
||||
export const STATION_STANDARD_HOURS_EXPR = `CASE
|
||||
WHEN y.country = 'Djibouti' THEN ${stdRow('station_standard_hours_djibouti', 13)}
|
||||
ELSE ${stdRow('station_standard_hours_ethiopia', 10)}
|
||||
END`;
|
||||
|
||||
/** Whichever end of the corridor is on the Djibouti side, if either is. */
|
||||
export const DJIBOUTI_YARD_CODE_EXPR = `CASE
|
||||
WHEN oy.country = 'Djibouti' THEN oy.code
|
||||
WHEN dy.country = 'Djibouti' THEN dy.code
|
||||
END`;
|
||||
|
||||
/** True when the departure carried any container allocation. */
|
||||
export const SCHEDULE_IS_CONTAINER = `EXISTS (
|
||||
SELECT 1 FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL
|
||||
WHERE w.train_set_id = ts.train_set_id
|
||||
AND a.deleted_at IS NULL AND a.load_type = 'CONTAINER'
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Standard turn-around cycle for a departure, in hours. Container trains run
|
||||
* the 65-hour cycle; a bulk cycle depends on which Djibouti terminal it works.
|
||||
* Schedule grain — it reads `ts`, `oy` and `dy`, not the allocation aliases.
|
||||
*/
|
||||
export const CYCLE_STANDARD_HOURS_EXPR = `CASE
|
||||
WHEN ${SCHEDULE_IS_CONTAINER} THEN ${stdRow('cycle_standard_hours_container', 65)}
|
||||
WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'DORALEH_MULTIPURPOSE_PORT_DMP' THEN ${stdRow('cycle_standard_hours_bulk_dmp', 88)}
|
||||
WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'BCC' THEN ${stdRow('cycle_standard_hours_bulk_bcc', 96)}
|
||||
ELSE ${stdRow('cycle_standard_hours_bulk_nagad', 96)}
|
||||
END`;
|
||||
|
||||
export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`;
|
||||
|
||||
/**
|
||||
* Joins the single standards row. Restricted by id to the earliest live row so
|
||||
* a stray second row could never fan a report's result out.
|
||||
*/
|
||||
export const STANDARDS_JOIN = `std.id = (
|
||||
SELECT s.id FROM freight.operations_standards s
|
||||
WHERE s.deleted_at IS NULL ORDER BY s.created_at ASC LIMIT 1
|
||||
)`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Distance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configured rail distance for a yard pair, in km. Symmetric: `yard_distances`
|
||||
* stores one row per pair and an A→B row governs B→A.
|
||||
*
|
||||
* Returns NULL when the pair is not configured, and every caller must let that
|
||||
* null through rather than coalescing to zero — a missing distance is not a
|
||||
* zero distance, and Ton/Km computed from one would understate silently.
|
||||
*/
|
||||
export const distanceKmBetween = (fromCol: string, toCol: string): string => `(
|
||||
SELECT yd.distance_km FROM freight.yard_distances yd
|
||||
WHERE yd.deleted_at IS NULL
|
||||
AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol})
|
||||
OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol}))
|
||||
LIMIT 1
|
||||
)`;
|
||||
|
||||
/** Standard running time for a leg, falling back to the default leg standard. */
|
||||
export const legStandardHours = (fromCol: string, toCol: string): string => `COALESCE((
|
||||
SELECT yd.standard_hours FROM freight.yard_distances yd
|
||||
WHERE yd.deleted_at IS NULL
|
||||
AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol})
|
||||
OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol}))
|
||||
LIMIT 1
|
||||
), ${stdRow('default_leg_standard_hours', 21)})`;
|
||||
|
||||
/** The schedule's own corridor, origin to destination. */
|
||||
export const SCHEDULE_KM_EXPR = distanceKmBetween('ts.origin_station_id', 'ts.destination_station_id');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volume — TEU, charged and actual
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-allocation aggregate over its container items. */
|
||||
const containerItems = (selection: string): string => `(
|
||||
SELECT ${selection}
|
||||
FROM freight.wagon_allocation_container_items ci
|
||||
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
|
||||
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL
|
||||
)`;
|
||||
|
||||
/**
|
||||
* TEU for one allocation: a 40ft box is two twenty-foot equivalents, anything
|
||||
* else one.
|
||||
*
|
||||
* Note this is the third TEU derivation in the codebase and the only one taken
|
||||
* from the marshalling record. `revenue-classification.ts` derives TEU from the
|
||||
* charge code's size suffix (billing truth, blind to unsized codes) and
|
||||
* `wagon-teu-utilization.report.ts` from a wagon's currently pinned containers
|
||||
* (live state). This one answers "what did we actually move", which is what the
|
||||
* reporting spec asks for.
|
||||
*/
|
||||
export const ALLOC_TEU = containerItems(
|
||||
'COALESCE(SUM(CASE WHEN cty.size_ft >= 40 THEN 2 ELSE 1 END), 0)',
|
||||
);
|
||||
export const ALLOC_CONTAINERS_20 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft = 20)');
|
||||
export const ALLOC_CONTAINERS_40 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft >= 40)');
|
||||
export const ALLOC_CONTAINERS = containerItems('COUNT(*)');
|
||||
|
||||
export const TEU_EXPR = `COALESCE(SUM(${ALLOC_TEU}), 0)::int`;
|
||||
export const CONTAINERS_EXPR = `COALESCE(SUM(${ALLOC_CONTAINERS}), 0)::int`;
|
||||
|
||||
/**
|
||||
* Actual volume — "loading capacity from marshalling" in the spec.
|
||||
* `allocated_weight_tons` is what the allocation flow recorded onto the wagon,
|
||||
* and is populated for every allocation in the system.
|
||||
*/
|
||||
export const ACTUAL_TONS_EXPR = 'COALESCE(SUM(wba.allocated_weight_tons), 0)::float8';
|
||||
|
||||
const IS_PERISHABLE = `COALESCE(ct.code, '') IN (${quote(PERISHABLE_CODES)})`;
|
||||
const IS_BULK_LOAD = "wba.load_type <> 'CONTAINER'";
|
||||
|
||||
/**
|
||||
* Charged volume — the standard weight capacity the spec bills against, not
|
||||
* what was weighed.
|
||||
*
|
||||
* Containers are charged per box (20/40 tons laden, 2.24/3.88 empty). Bulk is
|
||||
* charged per WAGON (70 tons, or 38 for perishables), so it counts distinct
|
||||
* wagons rather than allocations: two bookings sharing one wagon are one
|
||||
* wagon's charge, not two.
|
||||
*/
|
||||
export const CHARGED_TONS_EXPR = `(
|
||||
COALESCE(SUM(
|
||||
CASE WHEN ${IS_BULK_LOAD} THEN 0 ELSE
|
||||
${ALLOC_CONTAINERS_20} * CASE WHEN ${IS_EMPTY_CONTAINER}
|
||||
THEN ${stdRow('charged_tons_empty_20ft', 2.24)}
|
||||
ELSE ${stdRow('charged_tons_full_20ft', 20)} END
|
||||
+ ${ALLOC_CONTAINERS_40} * CASE WHEN ${IS_EMPTY_CONTAINER}
|
||||
THEN ${stdRow('charged_tons_empty_40ft', 3.88)}
|
||||
ELSE ${stdRow('charged_tons_full_40ft', 40)} END
|
||||
END), 0)
|
||||
+ COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND ${IS_PERISHABLE})
|
||||
* ${stdAgg('charged_tons_per_wagon_perishable', 38)}
|
||||
+ COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND NOT ${IS_PERISHABLE})
|
||||
* ${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.
|
||||
*/
|
||||
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)
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Trainsets operated: wagons loaded divided by a full trainset for this cargo.
|
||||
* Seven full multimodal trains plus 30 of a 50-wagon set reads 7.6 — the
|
||||
* fraction the spec's worked example asks for.
|
||||
*/
|
||||
export const TRAINSETS_EXPR = `ROUND(
|
||||
COUNT(DISTINCT tsw.id)::numeric
|
||||
/ NULLIF(MAX(COALESCE(ct.full_trainset_wagons, ${stdRow('default_full_trainset_wagons', 50)})), 0)
|
||||
, 2)::float8`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Implement rate — operated against plan, as a percentage.
|
||||
*
|
||||
* NULL when there is no plan, never 100 and never 0: an unplanned period has no
|
||||
* achievement to report, and coercing a missing plan to zero would read as
|
||||
* infinite achievement.
|
||||
*/
|
||||
export const implementRateExpr = (operated: string, planned: string): string =>
|
||||
`ROUND(100 * (${operated})::numeric / NULLIF(${planned}, 0), 1)::float8`;
|
||||
|
||||
/**
|
||||
* Turn-around implement rate, the spec's own formula:
|
||||
* `[((SC − AD) / SC) + 1] × 100`. Finishing exactly on standard scores 100;
|
||||
* a cycle an hour quicker than a 65-hour standard scores ~101.5.
|
||||
*/
|
||||
export const cycleRateExpr = (actual: string, standard: string): string =>
|
||||
`ROUND((((${standard}) - (${actual})) / NULLIF(${standard}, 0) + 1) * 100, 1)::float8`;
|
||||
|
||||
/** Hours between two timestamps, one decimal place. */
|
||||
export const hoursBetween = (from: string, to: string): string =>
|
||||
`ROUND(EXTRACT(EPOCH FROM ((${to}) - (${from})))::numeric / 3600, 1)::float8`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filters and the shared ledger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The date every operations report buckets and filters on: when the train
|
||||
* actually left, falling back to the plan for a departure not yet dispatched.
|
||||
*/
|
||||
export const OPS_DATE = 'COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)';
|
||||
|
||||
export const DIRECTION_FILTER: ReportFilterDef = {
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
};
|
||||
|
||||
export const COUNTRY_FILTER: ReportFilterDef = {
|
||||
key: 'country',
|
||||
label: 'Country',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'Ethiopia', label: 'Ethiopia' },
|
||||
{ value: 'Djibouti', label: 'Djibouti' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Shared by every operations report, so they read the same way side by side. */
|
||||
export const OPERATIONS_FILTERS: ReportFilterDef[] = [
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
DIRECTION_FILTER,
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||
{ key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions },
|
||||
{ key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions },
|
||||
];
|
||||
|
||||
export const CARGO_CATEGORY_FILTER: ReportFilterDef = {
|
||||
key: 'categories',
|
||||
label: 'Cargo category',
|
||||
type: 'multiselect',
|
||||
options: CARGO_CATEGORIES,
|
||||
};
|
||||
|
||||
/** Schedule states that never represent an operated train. */
|
||||
const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED'];
|
||||
|
||||
/**
|
||||
* Every operations report starts here: one wagon allocation, joined out to the
|
||||
* departure that carried it and the booking that explains it.
|
||||
*
|
||||
* The booking is LEFT joined — a wagon can be allocated before its booking data
|
||||
* is complete, and dropping those rows would understate wagon usage.
|
||||
*/
|
||||
export function allocationLedgerQb(ctx: ReportContext): 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')
|
||||
.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,
|
||||
});
|
||||
|
||||
applyOperationsFilters(qb, params);
|
||||
applyDirectionScope(qb, 'COALESCE(b.trade_direction, ts.direction)', directions);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule-grain query, for reports that measure trains rather than cargo —
|
||||
* turnaround, delay, station stay. Same aliases, minus the allocation.
|
||||
*/
|
||||
export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(TrainSchedule, 'ts')
|
||||
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
||||
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
||||
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
||||
.where('ts.deleted_at IS NULL')
|
||||
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
|
||||
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
|
||||
});
|
||||
|
||||
applyOperationsFilters(qb, params);
|
||||
applyDirectionScope(qb, 'ts.direction', directions);
|
||||
return qb;
|
||||
}
|
||||
|
||||
export function applyOperationsFilters(
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
params: Record<string, unknown>,
|
||||
): void {
|
||||
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
||||
if (params.trainNumber) {
|
||||
qb.andWhere('ts.train_number ILIKE :trainNumber', {
|
||||
trainNumber: `%${params.trainNumber as string}%`,
|
||||
});
|
||||
}
|
||||
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
|
||||
if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts an allocation-grain query to a set of cargo categories. Kept
|
||||
* separate from {@link applyOperationsFilters} because the schedule-grain
|
||||
* query has no cargo to filter by.
|
||||
*/
|
||||
export function applyCategoryFilter(
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
params: Record<string, unknown>,
|
||||
): void {
|
||||
const categories = params.categories as string[] | null;
|
||||
if (categories?.length) {
|
||||
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The planned value for a group, as a correlated subselect against
|
||||
* `operations_targets`.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const plannedValueExpr = (
|
||||
metric: string,
|
||||
dimension: string,
|
||||
dimensionKeyExpr: string,
|
||||
periodTypeExpr: string,
|
||||
periodStartExpr: string,
|
||||
): string => `MAX((
|
||||
SELECT ot.planned_value FROM freight.operations_targets ot
|
||||
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
|
||||
))`;
|
||||
Reference in New Issue
Block a user