feat: updateing the loading and unloading

This commit is contained in:
Nathnael
2026-08-24 14:01:19 +00:00
parent 17c2dca3cc
commit 562c8b48ba
9 changed files with 357 additions and 37 deletions

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Standard loading-and-unloading time, so the handling figure can be reported
* the way the OCC scorecard reports it — hours against a target, with a rate.
*
* Nullable with NO default, unlike every other column in this table. The
* reporting spec publishes standards for a station stay (10h / 13h) and for a
* turn-around cycle (65 / 88 / 96) but none for handling, so there is no
* honest figure to seed. Until a planner enters one in Operating standards the
* rate reads empty rather than judging trains against an invented number.
*/
export class HandlingStandards3700000000000 implements MigrationInterface {
name = "HandlingStandards3700000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_standards
ADD COLUMN IF NOT EXISTS handling_standard_hours_container numeric(6,2),
ADD COLUMN IF NOT EXISTS handling_standard_hours_bulk numeric(6,2);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_standards
DROP COLUMN IF EXISTS handling_standard_hours_container,
DROP COLUMN IF EXISTS handling_standard_hours_bulk;
`);
}
}

View File

@@ -67,6 +67,24 @@ export class UpdateOperationsStandardsDto {
@Min(0)
delayToleranceMinutes?: number;
/**
* Handling standards have no spec figure, so they are the only two that may
* be cleared: null puts the report back to reporting hours without a rate.
*/
@ApiPropertyOptional({ example: 6.75, nullable: true })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
handlingStandardHoursContainer?: number | null;
@ApiPropertyOptional({ example: 12, nullable: true })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
handlingStandardHoursBulk?: number | null;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@Transform(toNumber)

View File

@@ -179,6 +179,34 @@ export class OperationsStandard extends BaseEntity {
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
defaultFullTrainsetWagons!: number;
/**
* Standard loading-and-unloading time for a container train's stop, in hours.
*
* Null until a planner sets it, and deliberately so: the reporting spec names
* no handling standard, so an unset value reports no rate rather than judging
* a train against a guess. Same for the bulk figure below.
*/
@Column({
name: 'handling_standard_hours_container',
type: 'numeric',
precision: 6,
scale: 2,
nullable: true,
transformer: asNumber,
})
handlingStandardHoursContainer?: number | null;
/** Standard loading-and-unloading time for a bulk train's stop, in hours. */
@Column({
name: 'handling_standard_hours_bulk',
type: 'numeric',
precision: 6,
scale: 2,
nullable: true,
transformer: asNumber,
})
handlingStandardHoursBulk?: number | null;
/** IAM user id of the last operator to change a standard. */
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById?: string | null;

View File

@@ -1,10 +1,13 @@
import { ReportDefinition } from '../report.types';
import { ReportContext, ReportDefinition, ReportFilterDef } from '../report.types';
import {
COUNTRY_FILTER,
DIRECTION_FILTER,
TRAIN_TYPE_FILTER,
cycleRateExpr,
handlingHours,
hoursBetween,
loadingHours,
loadingSource,
otherActivityHours,
stationStaysQb,
unloadingHours,
@@ -26,9 +29,9 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class
* because that is how the corridor measures a turnaround; bulk stations, which
* load OR unload rather than both, get the two halves in their own columns.
*
* Averages skip the stops nobody logged handling for rather than counting them
* as zero — `AVG` ignores nulls — so `Stops` is the population and `Handling
* logged` says how much of it the handling averages actually rest on.
* Averages skip the stops with no handling window rather than counting them as
* zero — `AVG` ignores nulls — so `Stops` is the population and `Handling
* measured` says how much of it the handling averages actually rest on.
*/
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
const HANDLING_HOURS = handlingHours('s');
@@ -39,6 +42,29 @@ const avg = (expr: string): string => `ROUND(AVG((${expr})::numeric), 1)::float8
/** Reused verbatim in the GROUP BY — the group key is the coalesced value. */
const TRAIN_NUMBER = "COALESCE(s.train_number, '—')";
const HANDLING_STANDARD = 'MAX(s.handling_standard_hours)';
/**
* Which way the rows roll up.
*
* Per train is the spec's container format; per station is its bulk one, which
* asks for the loading and unloading time AT Negad, BCC, DMP, Sebeta, GMP,
* Adama and Modjo rather than per train. Same measurements either way — only
* the group key moves — so one definition serves both.
*/
const GRAIN_FILTER: ReportFilterDef = {
key: 'grain',
label: 'Group by',
type: 'select',
options: [
{ value: 'train', label: 'Train' },
{ value: 'station', label: 'Station' },
],
};
/** Whitelisted here, so the user's value never reaches SQL. */
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
export const loadingUnloadingReport: ReportDefinition = {
key: 'loading-unloading',
title: 'Loading & Unloading',
@@ -49,13 +75,19 @@ export const loadingUnloadingReport: ReportDefinition = {
'the stops handling window, unloading start to loading end, which is the container ' +
'measure; the unloading and loading columns split it for bulk stations that only do ' +
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
'on the Ethiopian). Other activity is the rest of the station stay, against the 10h ' +
'Ethiopia / 13h Djibouti standard from Operating standards. Averages rest only on the ' +
'stops whose handling was actually logged — “Handling logged” counts them.',
'on the Ethiopian) — switch Group by to Station for that view. Other activity is the rest ' +
'of the station stay, against the 10h Ethiopia / 13h Djibouti standard from Operating ' +
'standards. The loading window falls back to the first and last booking loaded here when ' +
'nobody recorded it by hand, which “Loading from” reports as Derived; unloading is only ' +
'ever hand-recorded. Averages rest only on the stops that have a handling window at all — ' +
'“Handling measured” counts them. Handling rate needs a handling standard set in Operating ' +
'standards and reads empty until there is one.',
group: 'Operations',
filters: [
PERIOD_FILTER,
GRAIN_FILTER,
{ key: 'date', label: 'Arrival', type: 'daterange' },
TRAIN_TYPE_FILTER,
DIRECTION_FILTER,
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'station', label: 'Station', type: 'text' },
@@ -66,8 +98,10 @@ export const loadingUnloadingReport: ReportDefinition = {
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'country', label: 'Country', type: 'string' },
{ key: 'trainType', label: 'Train type', type: 'string' },
{ key: 'stops', label: 'Stops', type: 'number', sortable: true },
{ key: 'handlingLogged', label: 'Handling logged', type: 'number' },
{ key: 'handlingMeasured', label: 'Handling measured', type: 'number' },
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
{ key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true },
{ key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true },
{
@@ -78,8 +112,10 @@ export const loadingUnloadingReport: ReportDefinition = {
},
{ key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' },
{ key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true },
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
{ key: 'verdict', label: 'Verdict', type: 'string' },
{ key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' },
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
{ key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' },
{ key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true },
],
defaultSort: { key: 'period', dir: 'DESC' },
chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] },
@@ -88,40 +124,57 @@ export const loadingUnloadingReport: ReportDefinition = {
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucket = periodTruncExprOn('s.arrived_at', params);
return stationStaysQb(ctx)
const perStation = byStation(ctx);
const qb = stationStaysQb(ctx)
.select(periodExprOn('s.arrived_at', params), 'period')
.addSelect(TRAIN_NUMBER, 'trainNumber')
.addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber')
.addSelect('s.station', 'station')
.addSelect('s.country', 'country')
.addSelect('MAX(s.train_type)', 'trainType')
.addSelect('COUNT(*)::int', 'stops')
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingLogged')
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
// Which side of the COALESCE the loading columns came from. A group that
// mixes both says so rather than claiming either.
.addSelect(
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
ELSE MAX(${loadingSource('s')}) END`,
'loadingSource',
)
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
.addSelect('MAX(s.standard_hours)::float8', 'standardHours')
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
.addSelect(
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
THEN 'Encouraging' ELSE 'Needs reason' END`,
'verdict',
'stayVerdict',
)
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
// Same formula the turnaround cycle publishes, so the two read alike.
// NULL standard in, NULL rate out — nothing to measure against yet.
.addSelect(
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
'handlingRate',
)
.groupBy(bucket)
.addGroupBy(TRAIN_NUMBER)
.addGroupBy('s.station')
.addGroupBy('s.country');
if (!perStation) qb.addGroupBy(TRAIN_NUMBER);
return qb;
},
async summary(ctx) {
const row = await stationStaysQb(ctx)
.select('COUNT(*)::int', 'stops')
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'logged')
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
.getRawOne<{ stops: number; logged: number; avgHandling: number; avgOther: number }>();
.getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>();
return [
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
{ label: 'Handling logged', value: Number(row?.logged ?? 0) },
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
{ label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' },
];

View File

@@ -6,8 +6,8 @@ import {
CYCLE_STANDARD_HOURS_EXPR,
DIRECTION_FILTER,
cycleRateExpr,
handlingEnd,
handlingStart,
handlingEndOn,
handlingStartOn,
hoursBetween,
otherActivityHours,
scheduleLedgerQb,
@@ -65,23 +65,38 @@ const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end');
const TRAVEL_HOURS = `ROUND(GREATEST((${AD_HOURS})::numeric - ${ETHIOPIA_HOURS} - ${DJIBOUTI_HOURS}, 0), 1)::float8`;
/**
* Loading and unloading logged at the cycle's own stops, summed across both
* ends of the line.
* The leg the train leaves a stop on, which is the one it loads for. Inside a
* cycle the order is known outright — it arrives at the far end on the first
* schedule and departs on the return, arrives home on the return and departs on
* the next cycle's first leg — so the booking-derived loading window can be
* resolved here without the window function the stop-shaped query uses.
*/
const CYCLE_DEPARTING_SCHEDULE = `CASE
WHEN e.train_schedule_id = c.schedule_id THEN c.return_schedule_id
WHEN e.train_schedule_id = c.return_schedule_id THEN c.next_cycle_schedule_id
END`;
const cycleHandlingStart = handlingStartOn('e', CYCLE_DEPARTING_SCHEDULE, 'e.yard_id');
const cycleHandlingEnd = handlingEndOn('e', CYCLE_DEPARTING_SCHEDULE, 'e.yard_id');
/**
* Loading and unloading at the cycle's own stops, summed across both ends of
* the line.
*
* NULL — not zero — when no stop in the cycle recorded a handling window: SUM
* over no rows is NULL, and a cycle nobody logged handling for has an unknown
* handling time. Other activity follows it, so an unlogged cycle shows both
* columns empty rather than claiming the whole stay was other activity.
* NULL — not zero — when no stop in the cycle has a handling window: SUM over
* no rows is NULL, and a cycle nobody measured has an unknown handling time.
* Other activity follows it, so such a cycle shows both columns empty rather
* than claiming the whole stay was other activity.
*/
const HANDLING_HOURS = `(
SELECT ROUND(SUM(
EXTRACT(EPOCH FROM (${handlingEnd('e')} - ${handlingStart('e')})) / 3600
EXTRACT(EPOCH FROM (${cycleHandlingEnd} - ${cycleHandlingStart})) / 3600
)::numeric, 1)::float8
FROM freight.train_checkpoint_events e
WHERE e.deleted_at IS NULL
AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id)
AND ${handlingStart('e')} IS NOT NULL
AND ${handlingEnd('e')} IS NOT NULL
AND ${cycleHandlingStart} IS NOT NULL
AND ${cycleHandlingEnd} IS NOT NULL
)`;
const STATION_STAY_HOURS = `(${ETHIOPIA_HOURS} + ${DJIBOUTI_HOURS})`;

View File

@@ -4,9 +4,13 @@ import {
CARGO_CATEGORY_LABEL_EXPR,
CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR,
HANDLING_STANDARD_HOURS_EXPR,
TARGET_DIMENSION_KEYS,
cycleRateExpr,
handlingHours,
implementRateExpr,
loadingHours,
otherActivityHours,
plannedRowsSql,
} from './operations-classification';
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
@@ -24,6 +28,41 @@ function emittedKeys(expr: string): string[] {
}
describe('operations classification', () => {
/**
* The loading window falls back to the bookings that boarded at the stop, off
* the DEPARTING schedule — a turnaround loads for the leg it leaves on, not
* the one it arrived on. Losing either half of that silently turns a
* populated report back into an empty one.
*/
it('falls back to the booking-derived loading window, off the departing leg', () => {
for (const expr of [loadingHours('s'), handlingHours('s')]) {
expect(expr).toContain('COALESCE(s.loading_started_at');
expect(expr).toContain('s.departed_schedule_id');
expect(expr).toContain('b.origin_yard_id = (s.yard_id)');
}
});
/** Unloading is never derived — auto-unload would report it as ~0 hours. */
it('never derives the unloading half', () => {
expect(handlingHours('s')).toContain('s.unloading_started_at');
expect(handlingHours('s')).not.toContain('b.destination_yard_id');
});
/**
* Every other standard coalesces to the spec's figure. This one must not:
* the spec names no handling standard, and a fallback would publish a rate
* against a number nobody agreed to.
*/
it('leaves the handling standard null when nobody has set one', () => {
expect(HANDLING_STANDARD_HOURS_EXPR).toContain('std.handling_standard_hours_container');
expect(HANDLING_STANDARD_HOURS_EXPR).not.toContain('COALESCE(std.handling');
});
/** An unmeasured stop reports unknown activity, not a full stay of it. */
it('keeps other activity null when there is no handling window', () => {
expect(otherActivityHours('stay', 'handling')).toContain('IS NULL THEN NULL');
});
it('offers every cargo category the expression can emit as a filter option', () => {
const offered = new Set(CARGO_CATEGORIES.map((o) => o.value));
const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k));

View File

@@ -207,6 +207,20 @@ END`;
export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`;
/**
* Standard loading-and-unloading time for a stop, by what the train carries.
*
* Deliberately NOT wrapped in a fallback like every other standard here: the
* reporting spec publishes no figure for handling, so there is nothing honest
* to fall back to. Until a planner enters one in Operating standards this is
* NULL, and the rate and verdict that read it stay empty rather than judging a
* train against a number nobody agreed to.
*/
export const HANDLING_STANDARD_HOURS_EXPR = `CASE
WHEN ${SCHEDULE_IS_CONTAINER} THEN std.handling_standard_hours_container
ELSE std.handling_standard_hours_bulk
END`;
/**
* 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.
@@ -489,19 +503,87 @@ export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectL
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
/**
* The loading window this stop's cargo actually took, read off the bookings
* that boarded here.
*
* `bookings.loaded_at` is stamped per booking the moment a yard operator
* presses Load, so the first and last of them bound the loading work without
* anyone entering a second set of times. The bookings belong to the DEPARTING
* schedule — a train arrives on one leg and loads for the next — which is why
* this reads `departed_schedule_id` rather than the arrival's own schedule.
*
* There is no equivalent for unloading: `autoUnloadAtYard` stamps every
* booking's `arrived_at` at the moment the checkpoint is logged, so a window
* derived from it would collapse onto the arrival and report ~0 hours of
* unloading. Unloading is only ever what staff recorded by hand.
*/
const derivedLoading = (agg: 'MIN' | 'MAX', scheduleExpr: string, yardExpr: string): string => `(
SELECT ${agg}(b.loaded_at)
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
AND tsb.train_schedule_id = (${scheduleExpr})
WHERE b.deleted_at IS NULL
AND b.origin_yard_id = (${yardExpr})
AND b.loaded_at IS NOT NULL
)`;
/**
* The departing schedule and the yard are passed in rather than read off a
* fixed alias: the stop-shaped query carries them as columns, while the
* turnaround report reads raw `train_checkpoint_events` rows and works out the
* departing leg from the cycle it already knows.
*/
export const loadingStartOn = (
alias: string,
scheduleExpr: string,
yardExpr: string,
): string =>
`COALESCE(${alias}.loading_started_at, ${derivedLoading('MIN', scheduleExpr, yardExpr)})`;
export const loadingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string =>
`COALESCE(${alias}.loading_completed_at, ${derivedLoading('MAX', scheduleExpr, yardExpr)})`;
/** The stop-shaped query's own columns — what every stay-based report uses. */
const STOP_SCHEDULE = (alias: string): string => `${alias}.departed_schedule_id`;
const STOP_YARD = (alias: string): string => `${alias}.yard_id`;
/** Hand-recorded times win; the booking-derived window is the fallback. */
export const loadingStart = (alias: string): string =>
loadingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
export const loadingEnd = (alias: string): string =>
loadingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
/** Which of the two the loading columns came from, so nobody mistakes one for the other. */
export const loadingSource = (alias: string): string => `CASE
WHEN ${alias}.loading_started_at IS NOT NULL
OR ${alias}.loading_completed_at IS NOT NULL THEN 'Logged'
WHEN ${derivedLoading('MIN', STOP_SCHEDULE(alias), STOP_YARD(alias))} IS NOT NULL THEN 'Derived'
ELSE '—'
END`;
/**
* When station work began and ended at a stop.
*
* LEAST and GREATEST ignore nulls, so a stop that only loaded (an export
* origin) or only unloaded reports that half's window on its own, and where
* both halves are logged the pair spans exactly what the spec measures —
* unloading start to loading end. NULL when nothing was logged: an unlogged
* stop has an unknown handling time, not a zero one.
* both halves are known the pair spans exactly what the spec measures —
* unloading start to loading end. NULL when nothing was logged or derived: an
* unlogged stop has an unknown handling time, not a zero one.
*/
export const handlingStartOn = (
alias: string,
scheduleExpr: string,
yardExpr: string,
): string =>
`LEAST(${alias}.unloading_started_at, ${loadingStartOn(alias, scheduleExpr, yardExpr)})`;
export const handlingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string =>
`GREATEST(${loadingEndOn(alias, scheduleExpr, yardExpr)}, ${alias}.unloading_completed_at)`;
export const handlingStart = (alias: string): string =>
`LEAST(${alias}.unloading_started_at, ${alias}.loading_started_at)`;
handlingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
export const handlingEnd = (alias: string): string =>
`GREATEST(${alias}.loading_completed_at, ${alias}.unloading_completed_at)`;
handlingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
/** Total loading and unloading time at a stop, in hours. */
export const handlingHours = (alias: string): string =>
@@ -510,7 +592,7 @@ export const handlingHours = (alias: string): string =>
export const unloadingHours = (alias: string): string =>
hoursBetween(`${alias}.unloading_started_at`, `${alias}.unloading_completed_at`);
export const loadingHours = (alias: string): string =>
hoursBetween(`${alias}.loading_started_at`, `${alias}.loading_completed_at`);
hoursBetween(loadingStart(alias), loadingEnd(alias));
/**
* What the stay was spent on other than handling — the spec's "other activity".
@@ -549,15 +631,24 @@ export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder<ObjectLit
.addSelect("COALESCE(y.code, '—')", 'station_code')
.addSelect("COALESCE(y.country, '—')", 'country')
.addSelect('ev.kind', 'kind')
.addSelect('ev.yard_id', 'yard_id')
.addSelect('ev.occurred_at', 'arrived_at')
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
// The leg the train LEAVES on, which is the one it loads for. A turnaround
// departs on a different schedule than it arrived on, so the booking-derived
// loading window has to follow this rather than `ev.train_schedule_id`.
.addSelect(`lead(ev.train_schedule_id) OVER (${STAY_WINDOW})`, 'departed_schedule_id')
// Handling rides the arrival row, which is the row a stay is built from.
.addSelect('ev.unloading_started_at', 'unloading_started_at')
.addSelect('ev.unloading_completed_at', 'unloading_completed_at')
.addSelect('ev.loading_started_at', 'loading_started_at')
.addSelect('ev.loading_completed_at', 'loading_completed_at')
// Classed by the leg that ARRIVED. A stop whose inbound and outbound legs
// differ in type is rare and reads as what pulled in.
.addSelect(`CASE WHEN ${SCHEDULE_IS_CONTAINER} THEN 'Container' ELSE 'Bulk' END`, 'train_type')
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
.addSelect(`ROUND(${HANDLING_STANDARD_HOURS_EXPR}, 1)`, 'handling_standard_hours')
.addSelect("COALESCE(ev.note, '')", 'note');
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
@@ -570,11 +661,25 @@ export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder<ObjectLit
}
if (params.station) qb.andWhere('y.code = :station', { station: params.station });
if (params.country) qb.andWhere('y.country = :country', { country: params.country });
// Whitelisted, not bound: the same EXISTS has to read identically in the
// SELECT above, and a bound parameter cannot be reused across both.
if (params.trainType === 'CONTAINER') qb.andWhere(SCHEDULE_IS_CONTAINER);
if (params.trainType === 'BULK') qb.andWhere(`NOT ${SCHEDULE_IS_CONTAINER}`);
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
export const TRAIN_TYPE_FILTER: ReportFilterDef = {
key: 'trainType',
label: 'Train type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
};
/** Only completed stops — an arrival whose departure was also logged, alias `s`. */
export function stationStaysQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const inner = stationStopsQb(ctx);