Merge pull request #1411 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-24 17:02:40 +03:00
committed by GitHub
22 changed files with 1084 additions and 107 deletions

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Loading and unloading times, on the stop that already records the train
* standing at a station.
*
* The OCC report publishes, per train, "total loading and unloading time" and
* the "other activity" left over from the station stay. Nothing recorded when
* handling started or ended — the July 2026 seed had to write the figure into
* a checkpoint's note — so the staying-time report could only ever publish the
* whole stay.
*
* These four go on `train_checkpoint_events` rather than a table of their own:
* a stop is already one row there, keyed (schedule, sequence_no), and the
* arrival row is the one the staying-time report builds a stay from. All four
* are nullable — a stop where nobody logged the handling still reports its
* staying time, with the handling columns empty rather than zero.
*/
export class CheckpointHandlingTimes3690000000000 implements MigrationInterface {
name = "CheckpointHandlingTimes3690000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_checkpoint_events
ADD COLUMN IF NOT EXISTS unloading_started_at timestamptz,
ADD COLUMN IF NOT EXISTS unloading_completed_at timestamptz,
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz,
ADD COLUMN IF NOT EXISTS loading_completed_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_checkpoint_events
DROP COLUMN IF EXISTS unloading_started_at,
DROP COLUMN IF EXISTS unloading_completed_at,
DROP COLUMN IF EXISTS loading_started_at,
DROP COLUMN IF EXISTS loading_completed_at;
`);
}
}

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

@@ -0,0 +1,182 @@
import { ReportContext, ReportDefinition, ReportFilterDef } from '../report.types';
import {
COUNTRY_FILTER,
DIRECTION_FILTER,
TRAIN_TYPE_FILTER,
cycleRateExpr,
handlingHours,
hoursBetween,
loadingHours,
loadingSource,
otherActivityHours,
stationStaysQb,
unloadingHours,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
/**
* Loading and unloading per train — the spec's own report format: train number,
* total loading and unloading time, other activity, station staying time.
*
* The staying-time report publishes one row per individual stop; this one rolls
* a train's stops up into the chosen period, which is what "for week report,
* calculate average in the week" asks for. The station stays in the grain
* because a train works both ends of the corridor and the standard it is judged
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
* Nagad and Gelan stops together would compare that mixture to one standard.
*
* Container work reads as one handling window (unloading start to loading end)
* 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 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');
const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS);
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',
description:
'Loading and unloading per train, at the granularity you choose — one row per train per ' +
'station per period, which at week or month grain is that trains average over its stops ' +
'in the period, the way the OCC report publishes it. Total loading and unloading is ' +
'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) — 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' },
COUNTRY_FILTER,
],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{ 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: '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 },
{
key: 'avgLoadUnloadHours',
label: 'Avg loading + unloading (hrs)',
type: 'number',
sortable: true,
},
{ key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' },
{ key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true },
{ 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'] },
query(ctx) {
const { params } = ctx;
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucket = periodTruncExprOn('s.arrived_at', params);
const perStation = byStation(ctx);
const qb = stationStaysQb(ctx)
.select(periodExprOn('s.arrived_at', params), 'period')
.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`, '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', 'stayStandardHours')
.addSelect(
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
THEN 'Encouraging' ELSE 'Needs reason' END`,
'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('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`, 'measured')
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
.getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>();
return [
{ label: 'Stops measured', value: Number(row?.stops ?? 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

@@ -1,87 +1,18 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity';
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportDefinition } from '../report.types';
import {
COUNTRY_FILTER,
DIRECTION_FILTER,
OPS_DATE,
STANDARDS_JOIN,
STATION_STANDARD_HOURS_EXPR,
handlingHours,
hoursBetween,
otherActivityHours,
stationStaysQb,
} from '../operations-classification';
/**
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
* same physical train — NOT by the same schedule.
*
* When a train turns around at a station the two halves belong to different
* departures: the arrival closes the inbound schedule and the departure opens
* the outbound one. Pairing within a schedule finds only pass-through stops and
* silently drops every turnaround, which is the longest stay a train makes.
*/
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`;
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
const HANDLING_HOURS = handlingHours('s');
const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS);
const STANDARD_HOURS = 's.standard_hours';
/** Every logged stop, with the event that followed it at the same station. */
function stopsQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainCheckpointEvent, 'ev')
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
.innerJoin(Yard, 'y', 'y.id = ev.yard_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('ev.deleted_at IS NULL')
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
.select('ts.train_number', 'train_number')
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
.addSelect("COALESCE(y.country, '—')", 'country')
.addSelect('ev.kind', 'kind')
.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')
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
.addSelect("COALESCE(ev.note, '')", 'note');
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.station) qb.andWhere('y.code = :station', { station: params.station });
if (params.country) qb.andWhere('y.country = :country', { country: params.country });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
/** Only completed stops — an arrival whose departure was also logged. */
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const inner = stopsQuery(ctx);
return ctx.ds
.createQueryBuilder()
.from(`(${inner.getQuery()})`, 's')
.setParameters(inner.getParameters())
.where("s.kind = 'ARRIVED'")
.andWhere("s.next_kind = 'DEPARTED'");
}
export const stationStayingTimeReport: ReportDefinition = {
key: 'station-staying-time',
title: 'Station Staying Time',
@@ -89,8 +20,9 @@ export const stationStayingTimeReport: ReportDefinition = {
'How long each train stood at each station — the logged arrival to the same trains ' +
'next departure from that station — against the standard for that side of the line ' +
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
'the system records when they start and end yet.',
'standard needs a reason. Loading and unloading time is the stops logged handling ' +
'window, unloading start to loading end, and other activity is whatever is left of the ' +
'stay; both read empty on a stop whose handling was never logged, rather than zero.',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
@@ -106,6 +38,14 @@ export const stationStayingTimeReport: ReportDefinition = {
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
{ key: 'departedAt', label: 'Departed', type: 'date' },
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
{
key: 'loadUnloadHours',
label: 'Loading + unloading (hrs)',
type: 'number',
sortable: true,
sortExpr: HANDLING_HOURS,
},
{ key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' },
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
{ key: 'verdict', label: 'Verdict', type: 'string' },
@@ -113,13 +53,15 @@ export const stationStayingTimeReport: ReportDefinition = {
],
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
return stationStaysQb(ctx)
.select("COALESCE(s.train_number, '—')", 'trainNumber')
.addSelect('s.station', 'station')
.addSelect('s.country', 'country')
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect(STAYING_HOURS, 'stayingHours')
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
.addSelect(
@@ -132,18 +74,20 @@ export const stationStayingTimeReport: ReportDefinition = {
.addSelect('s.note', 'reason');
},
async summary(ctx) {
const row = await baseQuery(ctx)
const row = await stationStaysQb(ctx)
.select('COUNT(*)::int', 'stops')
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
.addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling')
.addSelect(
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
'overStandard',
)
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
.getRawOne<{ stops: number; avgHours: number; avgHandling: number; overStandard: number }>();
return [
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
];
},

View File

@@ -6,7 +6,10 @@ import {
CYCLE_STANDARD_HOURS_EXPR,
DIRECTION_FILTER,
cycleRateExpr,
handlingEndOn,
handlingStartOn,
hoursBetween,
otherActivityHours,
scheduleLedgerQb,
} from '../operations-classification';
@@ -61,6 +64,44 @@ const DJIBOUTI_HOURS = stayHours('Djibouti');
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`;
/**
* 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 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 (${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 ${cycleHandlingStart} IS NOT NULL
AND ${cycleHandlingEnd} IS NOT NULL
)`;
const STATION_STAY_HOURS = `(${ETHIOPIA_HOURS} + ${DJIBOUTI_HOURS})`;
const OTHER_ACTIVITY_HOURS = otherActivityHours(STATION_STAY_HOURS, HANDLING_HOURS);
/** The completed cycles, before the per-cycle stay decomposition. */
function cycleQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return scheduleLedgerQb(ctx)
@@ -99,7 +140,10 @@ export const turnaroundCycleReport: ReportDefinition = {
'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' +
'rate is [(SC AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' +
'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' +
'and reads zero for a train whose stops were never logged.',
'and reads zero for a train whose stops were never logged. Loading and unloading is ' +
'the handling logged at those stops, unloading start to loading end, summed over the ' +
'cycle; other activity is the rest of the time standing at stations. Both read empty ' +
'where no stop in the cycle recorded its handling.',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
@@ -116,6 +160,8 @@ export const turnaroundCycleReport: ReportDefinition = {
{ key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true },
{ key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' },
{ key: 'djiboutiHours', label: 'Djibouti stay (hrs)', type: 'number' },
{ key: 'loadUnloadHours', label: 'Loading + unloading (hrs)', type: 'number' },
{ key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' },
{ key: 'travellingHours', label: 'Travelling (hrs)', type: 'number' },
{ key: 'averageDays', label: 'Average day', type: 'number' },
],
@@ -132,6 +178,8 @@ export const turnaroundCycleReport: ReportDefinition = {
.addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate')
.addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours')
.addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours')
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
.addSelect(TRAVEL_HOURS, 'travellingHours')
.addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays');
},
@@ -143,12 +191,14 @@ export const turnaroundCycleReport: ReportDefinition = {
`ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`,
'avgRate',
)
.getRawOne<{ cycles: number; avgHours: number; avgRate: number }>();
.addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling')
.getRawOne<{ cycles: number; avgHours: number; avgRate: number; avgHandling: number }>();
return [
{ label: 'Cycles', value: Number(row?.cycles ?? 0) },
{ label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' },
{ label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' },
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
];
},
};

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

@@ -3,7 +3,9 @@ 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 { TrainCheckpointEvent } from '../train-scheduling/entities/train-checkpoint-event.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.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';
@@ -205,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.
@@ -471,6 +487,210 @@ export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectL
return qb;
}
// ---------------------------------------------------------------------------
// Station stops — the stay, and the work done inside it
// ---------------------------------------------------------------------------
/**
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
* same physical train — NOT by the same schedule.
*
* When a train turns around at a station the two halves belong to different
* departures: the arrival closes the inbound schedule and the departure opens
* the outbound one. Pairing within a schedule finds only pass-through stops and
* silently drops every turnaround, which is the longest stay a train makes.
*/
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 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 =>
handlingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
export const handlingEnd = (alias: string): string =>
handlingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias));
/** Total loading and unloading time at a stop, in hours. */
export const handlingHours = (alias: string): string =>
hoursBetween(handlingStart(alias), handlingEnd(alias));
export const unloadingHours = (alias: string): string =>
hoursBetween(`${alias}.unloading_started_at`, `${alias}.unloading_completed_at`);
export const loadingHours = (alias: string): string =>
hoursBetween(loadingStart(alias), loadingEnd(alias));
/**
* What the stay was spent on other than handling — the spec's "other activity".
*
* Stays NULL when handling was never logged rather than collapsing to the whole
* stay, and floors at zero: handling logged slightly outside the arrival and
* departure pair is a sloppy entry, not negative activity.
*/
export const otherActivityHours = (stayExpr: string, handlingExpr: string): string =>
`CASE WHEN (${handlingExpr}) IS NULL THEN NULL
ELSE ROUND(GREATEST((${stayExpr})::numeric - (${handlingExpr})::numeric, 0), 1)::float8 END`;
/**
* Every logged stop, with the event that followed it at the same station and
* whatever loading and unloading was recorded against it.
*
* Shared by the staying-time and loading-and-unloading reports so "a stop"
* means one thing across the suite.
*/
export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainCheckpointEvent, 'ev')
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
.innerJoin(Yard, 'y', 'y.id = ev.yard_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('ev.deleted_at IS NULL')
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
.select('ts.train_number', 'train_number')
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
.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 });
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.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);
return ctx.ds
.createQueryBuilder()
.from(`(${inner.getQuery()})`, 's')
.setParameters(inner.getParameters())
.where("s.kind = 'ARRIVED'")
.andWhere("s.next_kind = 'DEPARTED'");
}
export function applyOperationsFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
params: Record<string, unknown>,

View File

@@ -34,6 +34,7 @@ import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-perform
import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report";
import { loadingUnloadingReport } from "./definitions/loading-unloading.report";
import { ReportDefinition } from "./report.types";
/**
@@ -77,6 +78,7 @@ export const REPORTS: ReportDefinition[] = [
chargedVsActualVolumeReport,
cargoVolumeByStationReport,
portWarehouseSummaryReport,
loadingUnloadingReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(

View File

@@ -0,0 +1,69 @@
import { TrainSchedulingService } from './services/train-scheduling.service';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
/**
* Loading and unloading stamps decide two published figures — total handling
* (unloading start to loading end) and the other activity left over from the
* stay. A crossed or future pair would publish negative hours, so the guard is
* the thing worth pinning down.
*/
const svc = Object.create(TrainSchedulingService.prototype) as {
handlingPatch: (
dto: Record<string, string | null | undefined>,
existing?: TrainCheckpointEvent | null,
) => Record<string, Date | null>;
};
const iso = (h: number): string => new Date(Date.UTC(2026, 6, 3, h)).toISOString();
const stop = (fields: Partial<TrainCheckpointEvent>) => fields as TrainCheckpointEvent;
describe('checkpoint handling times', () => {
it('takes a sane handling window', () => {
const patch = svc.handlingPatch({
unloadingStartedAt: iso(4),
loadingCompletedAt: iso(9),
});
expect(patch.unloadingStartedAt).toEqual(new Date(iso(4)));
expect(patch.loadingCompletedAt).toEqual(new Date(iso(9)));
});
it('rejects loading finishing before unloading started', () => {
expect(() =>
svc.handlingPatch({ unloadingStartedAt: iso(9), loadingCompletedAt: iso(4) }),
).toThrow('Loading cannot finish before unloading started');
});
it('rejects a window that runs backwards', () => {
expect(() =>
svc.handlingPatch({ loadingStartedAt: iso(9), loadingCompletedAt: iso(8) }),
).toThrow('Loading cannot finish before it started');
});
it('rejects a stamp in the future', () => {
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
expect(() => svc.handlingPatch({ unloadingStartedAt: tomorrow })).toThrow(
'Unloading start cannot be in the future',
);
});
// A body that moves one end of a window is still checked against the end
// already stored, or a two-step edit could walk the stop into a crossed pair.
it('checks a one-sided edit against the stored stop', () => {
expect(() =>
svc.handlingPatch(
{ loadingCompletedAt: iso(4) },
stop({ unloadingStartedAt: new Date(iso(9)) }),
),
).toThrow('Loading cannot finish before unloading started');
});
it('clears a stamp on null and leaves an untouched one alone', () => {
const patch = svc.handlingPatch(
{ unloadingStartedAt: null },
stop({ unloadingStartedAt: new Date(iso(4)), loadingCompletedAt: new Date(iso(9)) }),
);
expect(patch).toEqual({ unloadingStartedAt: null });
});
});

View File

@@ -35,6 +35,31 @@ export class RecordCheckpointDto {
@IsISO8601()
occurredAt?: string;
/**
* Station work during the stay this stop opens — what the OCC report calls
* loading and unloading time. All optional: a stop logged without them still
* records its staying time.
*/
@ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' })
@IsOptional()
@IsISO8601()
unloadingStartedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
unloadingCompletedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
loadingStartedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
loadingCompletedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@@ -52,6 +77,27 @@ export class UpdateCheckpointDto {
@IsISO8601()
occurredAt?: string;
/** Null clears a mis-entered stamp; undefined leaves it as it is. */
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
unloadingStartedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
unloadingCompletedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
loadingStartedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
loadingCompletedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()

View File

@@ -37,6 +37,28 @@ export class TrainCheckpointEvent extends BaseEntity {
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
/**
* Station work, on the stop it happened at. Null where nobody logged it —
* an unlogged stop still reports its staying time, with the handling split
* empty rather than zero.
*
* The stay these belong to opens with THIS arrival and closes with the next
* departure, which is a different schedule when the train turns around. That
* is why they ride the arrival row: it is the row the staying-time report
* builds a stop from.
*/
@Column({ name: 'unloading_started_at', type: 'timestamptz', nullable: true })
unloadingStartedAt?: Date | null;
@Column({ name: 'unloading_completed_at', type: 'timestamptz', nullable: true })
unloadingCompletedAt?: Date | null;
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
loadingStartedAt?: Date | null;
@Column({ name: 'loading_completed_at', type: 'timestamptz', nullable: true })
loadingCompletedAt?: Date | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;

View File

@@ -211,6 +211,16 @@ import {
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
/** The station-work stamps a stop can carry, with the label an error names. */
const HANDLING_FIELDS = [
['unloadingStartedAt', 'Unloading start'],
['unloadingCompletedAt', 'Unloading completion'],
['loadingStartedAt', 'Loading start'],
['loadingCompletedAt', 'Loading completion'],
] as const;
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
@@ -4372,11 +4382,65 @@ export class TrainSchedulingService {
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
occurredAt: e.occurredAt.toISOString(),
unloadingStartedAt: e.unloadingStartedAt?.toISOString() ?? null,
unloadingCompletedAt: e.unloadingCompletedAt?.toISOString() ?? null,
loadingStartedAt: e.loadingStartedAt?.toISOString() ?? null,
loadingCompletedAt: e.loadingCompletedAt?.toISOString() ?? null,
note: e.note ?? null,
})),
};
}
/**
* The station-work stamps off a record/update body, validated as two windows.
*
* Staff enter these after the fact, so the past is allowed and the future is
* not — the same rule the checkpoint's own time follows. Neither window may
* run backwards, and loading may not finish before unloading began: the OCC
* figure is `loading end unloading start`, and a crossed pair would publish
* negative handling and negative other activity.
*
* `undefined` leaves a stamp untouched; `null` clears a mis-entered one.
*/
private handlingPatch(
dto: Partial<Record<HandlingField, string | null>>,
existing?: TrainCheckpointEvent | null,
): Partial<Record<HandlingField, Date | null>> {
const patch: Partial<Record<HandlingField, Date | null>> = {};
for (const [field, label] of HANDLING_FIELDS) {
const raw = dto[field];
if (raw === undefined) continue;
if (raw === null) {
patch[field] = null;
continue;
}
const at = new Date(raw);
this.assertNotFuture(at, label);
patch[field] = at;
}
if (!Object.keys(patch).length) return patch;
// The stop as it will stand after the patch — a body that moves only one
// end of a window is still checked against the end already stored.
const merged = (field: HandlingField): Date | null =>
field in patch ? (patch[field] ?? null) : (existing?.[field] ?? null);
const inOrder = (from: HandlingField, to: HandlingField, message: string): void => {
const start = merged(from);
const end = merged(to);
if (start && end && end.getTime() < start.getTime()) {
throw new BadRequestException(message);
}
};
inOrder('unloadingStartedAt', 'unloadingCompletedAt', 'Unloading cannot finish before it started');
inOrder('loadingStartedAt', 'loadingCompletedAt', 'Loading cannot finish before it started');
inOrder(
'unloadingStartedAt',
'loadingCompletedAt',
'Loading cannot finish before unloading started',
);
return patch;
}
/** Log the train passing a station. Logging the destination station triggers arrival. */
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
// Slim graph: checkpoint logging reads stops, locomotives, the built
@@ -4411,12 +4475,14 @@ export class TrainSchedulingService {
const [existing] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
});
const handling = this.handlingPatch(dto, existing);
if (existing) {
await this.trainCheckpointEventsRepository.update(existing.id, {
kind,
occurredAt,
note: dto.note ?? null,
yardId: station.yardId,
...handling,
});
} else {
await this.trainCheckpointEventsRepository.create({
@@ -4426,6 +4492,7 @@ export class TrainSchedulingService {
kind,
occurredAt,
note: dto.note ?? null,
...handling,
});
}
@@ -4686,6 +4753,7 @@ export class TrainSchedulingService {
patch.occurredAt = occurredAt;
}
if (dto.note !== undefined) patch.note = dto.note;
Object.assign(patch, this.handlingPatch(dto, existing));
if (Object.keys(patch).length) {
await this.trainCheckpointEventsRepository.update(existing.id, patch);
}

View File

@@ -487,6 +487,12 @@ async function upsertTrain(ds: DataSource, code: string): Promise<string> {
return row.id;
}
/** The handling window a stop records — the loading/unloading report's input. */
interface Handling {
unloadingStartedAt: Date;
loadingCompletedAt: Date;
}
async function upsertCheckpoint(
ds: DataSource,
scheduleId: string,
@@ -495,7 +501,18 @@ async function upsertCheckpoint(
kind: 'ARRIVED' | 'DEPARTED',
occurredAt: Date,
note: string,
handling?: Handling,
): Promise<void> {
const params = [
scheduleId,
yardId,
sequenceNo,
kind,
occurredAt,
note,
handling?.unloadingStartedAt ?? null,
handling?.loadingCompletedAt ?? null,
];
const existing = await ds.query<Array<{ id: string }>>(
`SELECT id FROM freight.train_checkpoint_events
WHERE train_schedule_id = $1 AND yard_id = $2 AND kind = $3 AND deleted_at IS NULL`,
@@ -503,17 +520,20 @@ async function upsertCheckpoint(
);
if (existing.length) {
await ds.query(
`UPDATE freight.train_checkpoint_events SET occurred_at = $2, note = $3, updated_at = now()
`UPDATE freight.train_checkpoint_events
SET occurred_at = $2, note = $3,
unloading_started_at = $4, loading_completed_at = $5, updated_at = now()
WHERE id = $1`,
[existing[0].id, occurredAt, note],
[existing[0].id, occurredAt, note, params[6], params[7]],
);
return;
}
await ds.query(
`INSERT INTO freight.train_checkpoint_events
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note)
VALUES ($1, $2, $3, $4, $5, $6)`,
[scheduleId, yardId, sequenceNo, kind, occurredAt, note],
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note,
unloading_started_at, loading_completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
params,
);
}
@@ -605,13 +625,17 @@ async function seedDmpTrains(ds: DataSource, ids: Ids): Promise<void> {
arrivedAt: arrivedGelan,
});
// The loading/unloading figure has nowhere of its own to live yet — no
// table records when handling starts and ends — so it rides on the stop's
// note, where the staying-time report surfaces it as the stop's reason.
// The measured handling window, on the arrival row the staying-time report
// builds the stop from: work starts when the train lands and ends when
// loading finishes, which is what the OCC figure measures. The rest of the
// stay reports as other activity.
const note =
`OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` +
`${stayingHours.toFixed(2)}h total staying`;
await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note);
await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note, {
unloadingStartedAt: arrivedAtDmp,
loadingCompletedAt: addHours(arrivedAtDmp, handlingHours),
});
await upsertCheckpoint(ds, schedule, dmp, 0, 'DEPARTED', departedDmp, note);
}
console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`);

View File

@@ -98,6 +98,7 @@ const SEEDED_REPORT_KEYS = [
"charged-vs-actual-volume",
"cargo-volume-by-station",
"port-warehouse-summary",
"loading-unloading",
] as const;
/**

View File

@@ -1,13 +1,38 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMediaQuery } from "@mantine/hooks";
import { useEffect, useState } from "react";
import type { CheckpointHandlingTimes } from "@/types/trainScheduling";
/** The four station-work stamps, in the order they happen. */
const HANDLING_FIELDS = [
["unloadingStartedAt", "Unloading started"],
["unloadingCompletedAt", "Unloading finished"],
["loadingStartedAt", "Loading started"],
["loadingCompletedAt", "Loading finished"],
] as const;
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
type HandlingState = Record<HandlingField, Date | null>;
const EMPTY_HANDLING: HandlingState = {
unloadingStartedAt: null,
unloadingCompletedAt: null,
loadingStartedAt: null,
loadingCompletedAt: null,
};
/**
* Time + note for one leg of a train's journey — used both to log a pass
* (defaults to now) and to correct an already-logged leg (prefilled). Past
* times are allowed (staff record after the fact); the future is not, and the
* server additionally keeps legs in corridor order.
* Time, station work and note for one leg of a train's journey — used both to
* log a pass (defaults to now) and to correct an already-logged leg
* (prefilled). Past times are allowed (staff record after the fact); the future
* is not, and the server additionally keeps legs in corridor order.
*
* The four handling stamps are what the loading-and-unloading reports measure:
* total handling is unloading start to loading end, and whatever is left of the
* stay is other activity. All four are optional — a stop logged without them
* still reports its staying time.
*/
export function CheckpointTimeModal({
opened,
@@ -17,6 +42,7 @@ export function CheckpointTimeModal({
description,
initialOccurredAt,
initialNote,
initialHandling,
submitLabel,
submitColor = "edr-green",
loading,
@@ -30,19 +56,37 @@ export function CheckpointTimeModal({
/** ISO; omit to default to now. */
initialOccurredAt?: string | null;
initialNote?: string | null;
initialHandling?: CheckpointHandlingTimes | null;
submitLabel: string;
submitColor?: string;
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
onSubmit: (
values: { occurredAt: string; note: string } & Record<HandlingField, string | null>,
) => void;
}) {
const isSmallScreen = useMediaQuery("(max-width: 48em)");
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
useEffect(() => {
if (!opened) return;
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
setNote(initialNote ?? "");
}, [opened, initialOccurredAt, initialNote]);
setHandling({
unloadingStartedAt: initialHandling?.unloadingStartedAt
? new Date(initialHandling.unloadingStartedAt)
: null,
unloadingCompletedAt: initialHandling?.unloadingCompletedAt
? new Date(initialHandling.unloadingCompletedAt)
: null,
loadingStartedAt: initialHandling?.loadingStartedAt
? new Date(initialHandling.loadingStartedAt)
: null,
loadingCompletedAt: initialHandling?.loadingCompletedAt
? new Date(initialHandling.loadingCompletedAt)
: null,
});
}, [opened, initialOccurredAt, initialNote, initialHandling]);
return (
<Modal
@@ -76,6 +120,35 @@ export function CheckpointTimeModal({
clearable={false}
radius="md"
/>
<Divider
label="Station work (optional)"
labelPosition="left"
styles={{ label: { fontWeight: 600 } }}
/>
<Text size="xs" c="dimmed" mt={-8}>
Loading and unloading times for this stop. Total handling is unloading start to
loading finish; the rest of the stay reports as other activity.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{HANDLING_FIELDS.map(([field, label]) => (
<DateTimePicker
key={field}
label={label}
value={handling[field]}
onChange={(v) =>
setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null }))
}
maxDate={new Date()}
dropdownType={isSmallScreen ? "modal" : "popover"}
popoverProps={{ withinPortal: true }}
valueFormat="DD MMM YYYY HH:mm"
clearable
radius="md"
/>
))}
</SimpleGrid>
<Textarea
label="Note"
placeholder="Optional"
@@ -96,7 +169,15 @@ export function CheckpointTimeModal({
loading={loading}
disabled={!at}
onClick={() =>
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
at &&
onSubmit({
occurredAt: at.toISOString(),
note: note.trim(),
unloadingStartedAt: handling.unloadingStartedAt?.toISOString() ?? null,
unloadingCompletedAt: handling.unloadingCompletedAt?.toISOString() ?? null,
loadingStartedAt: handling.loadingStartedAt?.toISOString() ?? null,
loadingCompletedAt: handling.loadingCompletedAt?.toISOString() ?? null,
})
}
>
{submitLabel}

View File

@@ -24,6 +24,8 @@ type Field = {
hint: string;
unit: string;
integer?: boolean;
/** May be left blank, which clears it. Only the handling standards are. */
optional?: boolean;
};
type Section = { title: string; description: string; fields: Field[] };
@@ -84,6 +86,27 @@ const SECTIONS: Section[] = [
},
],
},
{
title: "Loading and unloading",
description:
"What a stop's handling should take, for the rate on Loading & Unloading. The reporting spec names no figure here, so both start blank and the rate stays empty until one is set. Leave blank to clear.",
fields: [
{
name: "handlingStandardHoursContainer",
label: "Container trains",
hint: "Unloading start to loading finish",
unit: "hrs",
optional: true,
},
{
name: "handlingStandardHoursBulk",
label: "Bulk trains",
hint: "Loading or unloading at the station",
unit: "hrs",
optional: true,
},
],
},
{
title: "Delay",
description:
@@ -189,6 +212,8 @@ export default function OperationsStandardsPage() {
const invalid = (field: Field): boolean => {
const raw = draft[field.name];
if (raw === undefined) return false;
// Blank on an optional field is a deliberate clear, not a bad number.
if (field.optional && raw.trim() === "") return false;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return true;
return field.integer ? !Number.isInteger(parsed) : false;
@@ -200,7 +225,10 @@ export default function OperationsStandardsPage() {
const handleSave = async () => {
if (anyInvalid || !dirty) return;
const patch = Object.fromEntries(
Object.entries(draft).map(([key, value]) => [key, Number(value)]),
Object.entries(draft).map(([key, value]) => [
key,
value.trim() === "" ? null : Number(value),
]),
);
await update.mutateAsync(patch);
setDraft({});

View File

@@ -33,7 +33,11 @@ import { PageContainer } from "@/components/page";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
import type {
CheckpointHandlingTimes,
TrackStation,
TrainCheckpoint,
} from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
@@ -46,6 +50,51 @@ import { useToast } from "@/hooks/use-toast";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { trainSchedulingService } from "@/services/trainScheduling.service";
type CheckpointModalValues = { occurredAt: string; note: string } & Required<
Record<keyof CheckpointHandlingTimes, string | null>
>;
/**
* The station-work stamps off the modal.
*
* On an edit a cleared picker means "remove this stamp", so nulls are sent.
* On a first log there is nothing to remove, and the record endpoint takes no
* nulls — the untouched pickers are dropped instead.
*/
const pickHandling = (
values: CheckpointModalValues,
keepNulls: boolean,
): CheckpointHandlingTimes =>
Object.fromEntries(
(
[
"unloadingStartedAt",
"unloadingCompletedAt",
"loadingStartedAt",
"loadingCompletedAt",
] as const
)
.map((field) => [field, values[field]] as const)
.filter(([, value]) => keepNulls || value !== null),
);
/**
* Total loading and unloading at a stop, the way the reports measure it:
* earliest start to latest finish, so a stop that only loaded or only unloaded
* still reads. Null when nothing was logged.
*/
const handlingHours = (cp: TrainCheckpoint): number | null => {
const times = [cp.unloadingStartedAt, cp.loadingStartedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
if (!times.length || !ends.length) return null;
const hours = (Math.max(...ends) - Math.min(...times)) / 3_600_000;
return Math.round(hours * 10) / 10;
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
@@ -262,7 +311,7 @@ export default function TrainScheduleTrackPage() {
setLogModal({ station, isFinal });
};
const submitLog = (values: { occurredAt: string; note: string }) => {
const submitLog = (values: CheckpointModalValues) => {
if (!logModal) return;
const { station, isFinal } = logModal;
recordCheckpoint.mutate(
@@ -272,6 +321,8 @@ export default function TrainScheduleTrackPage() {
sequenceNo: station.sequenceNo,
occurredAt: values.occurredAt,
...(values.note ? { note: values.note } : {}),
// Nothing to clear on a first log — send only what was entered.
...pickHandling(values, false),
},
},
{
@@ -293,13 +344,18 @@ export default function TrainScheduleTrackPage() {
);
};
const submitEdit = (values: { occurredAt: string; note: string }) => {
const submitEdit = (values: CheckpointModalValues) => {
if (!editModal) return;
updateCheckpoint.mutate(
{
id: scheduleId,
sequenceNo: editModal.sequenceNo,
payload: { occurredAt: values.occurredAt, note: values.note || null },
payload: {
occurredAt: values.occurredAt,
note: values.note || null,
// Nulls are meaningful here: clearing a picker clears the stamp.
...pickHandling(values, true),
},
},
{
onSuccess: () => {
@@ -687,6 +743,11 @@ export default function TrainScheduleTrackPage() {
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{handlingHours(cp) !== null ? (
<Text size="xs" c="dimmed" mt={2}>
Loading + unloading {handlingHours(cp)} h
</Text>
) : null}
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
@@ -723,9 +784,10 @@ export default function TrainScheduleTrackPage() {
onClose={() => setEditModal(null)}
title={`Edit ${editModal?.label ?? "checkpoint"}`}
icon={<Pencil size={18} />}
description="Corrects this leg's time and note only — nothing else changes."
description="Corrects this leg's time, station work and note only — nothing else changes."
initialOccurredAt={editModal?.occurredAt}
initialNote={editModal?.note}
initialHandling={editModal}
submitLabel="Save"
loading={updateCheckpoint.isPending}
onSubmit={submitEdit}

View File

@@ -19,6 +19,9 @@ export interface OperationsStandards {
cycleStandardHoursBulkBcc: number;
defaultLegStandardHours: number;
delayToleranceMinutes: number;
/** Null until a planner sets one — the spec names no handling standard. */
handlingStandardHoursContainer: number | null;
handlingStandardHoursBulk: number | null;
chargedTonsFull20ft: number;
chargedTonsFull40ft: number;
chargedTonsEmpty20ft: number;

View File

@@ -897,9 +897,22 @@ export interface TrainCheckpoint {
label: string | null;
kind: TrainCheckpointKind;
occurredAt: string;
/** Station work during the stay this stop opens. Null = never logged. */
unloadingStartedAt: string | null;
unloadingCompletedAt: string | null;
loadingStartedAt: string | null;
loadingCompletedAt: string | null;
note: string | null;
}
/** The four station-work stamps, as a payload fragment both endpoints accept. */
export interface CheckpointHandlingTimes {
unloadingStartedAt?: string | null;
unloadingCompletedAt?: string | null;
loadingStartedAt?: string | null;
loadingCompletedAt?: string | null;
}
export interface TrainTrackResponse {
scheduleId: string;
status: TrainScheduleStatus | string;
@@ -914,7 +927,7 @@ export interface TrainTrackResponse {
checkpoints: TrainCheckpoint[];
}
export interface RecordCheckpointPayload {
export interface RecordCheckpointPayload extends CheckpointHandlingTimes {
sequenceNo: number;
kind?: TrainCheckpointKind;
/** When the train was at the station; defaults to now. Past OK, future rejected. */
@@ -923,7 +936,7 @@ export interface RecordCheckpointPayload {
}
/** Edit an already-logged leg — pure correction, no side effects. */
export interface UpdateCheckpointPayload {
export interface UpdateCheckpointPayload extends CheckpointHandlingTimes {
occurredAt?: string;
note?: string | null;
}

View File

@@ -370,6 +370,11 @@ export interface ITrainCheckpointEvent extends BaseEntity {
sequenceNo: number;
kind: TrainCheckpointKind;
occurredAt: string;
/** Station work during the stay this stop opens. Null = never logged. */
unloadingStartedAt?: string | null;
unloadingCompletedAt?: string | null;
loadingStartedAt?: string | null;
loadingCompletedAt?: string | null;
note?: string | null;
recordedByUserId?: string | null;
}