mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(reports): record loading and unloading time per stop
The OCC report publishes total loading and unloading time and the other activity left over from a station stay, but nothing recorded when handling started or ended — the July 2026 seed had to write the figure into a checkpoint note. Four nullable stamps now ride the stop's arrival row, which is the row the staying-time report builds a stay from (a turnaround's departure belongs to a different schedule). Handling is unloading start to loading end, so a container stop reads as one window and a bulk station that only loads or only unloads still reports its half; other activity is the rest of the stay. Both stay NULL where nothing was logged rather than collapsing to zero. - station-staying-time: + loading/unloading and other activity per stop - turnaround-cycle: + the same, summed over the cycle's stops - loading-unloading (new): per train per station per period, so a week or month view is that train's average over its stops - the stop/stay query moves to operations-classification, shared by both
This commit is contained in:
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { ReportDefinition } from '../report.types';
|
||||||
|
import {
|
||||||
|
COUNTRY_FILTER,
|
||||||
|
DIRECTION_FILTER,
|
||||||
|
handlingHours,
|
||||||
|
hoursBetween,
|
||||||
|
loadingHours,
|
||||||
|
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 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.
|
||||||
|
*/
|
||||||
|
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, '—')";
|
||||||
|
|
||||||
|
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 train’s average over its stops ' +
|
||||||
|
'in the period, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||||
|
'the stop’s 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.',
|
||||||
|
group: 'Operations',
|
||||||
|
filters: [
|
||||||
|
PERIOD_FILTER,
|
||||||
|
{ key: 'date', label: 'Arrival', type: 'daterange' },
|
||||||
|
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: 'stops', label: 'Stops', type: 'number', sortable: true },
|
||||||
|
{ key: 'handlingLogged', label: 'Handling logged', type: 'number' },
|
||||||
|
{ 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: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||||
|
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
|
||||||
|
return stationStaysQb(ctx)
|
||||||
|
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||||
|
.addSelect(TRAIN_NUMBER, 'trainNumber')
|
||||||
|
.addSelect('s.station', 'station')
|
||||||
|
.addSelect('s.country', 'country')
|
||||||
|
.addSelect('COUNT(*)::int', 'stops')
|
||||||
|
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingLogged')
|
||||||
|
.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(
|
||||||
|
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||||
|
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||||
|
'verdict',
|
||||||
|
)
|
||||||
|
.groupBy(bucket)
|
||||||
|
.addGroupBy(TRAIN_NUMBER)
|
||||||
|
.addGroupBy('s.station')
|
||||||
|
.addGroupBy('s.country');
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await stationStaysQb(ctx)
|
||||||
|
.select('COUNT(*)::int', 'stops')
|
||||||
|
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'logged')
|
||||||
|
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
|
||||||
|
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
|
||||||
|
.getRawOne<{ stops: number; logged: number; avgHandling: number; avgOther: number }>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||||
|
{ label: 'Handling logged', value: Number(row?.logged ?? 0) },
|
||||||
|
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
|
||||||
|
{ label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,87 +1,18 @@
|
|||||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
import { ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
COUNTRY_FILTER,
|
COUNTRY_FILTER,
|
||||||
DIRECTION_FILTER,
|
DIRECTION_FILTER,
|
||||||
OPS_DATE,
|
handlingHours,
|
||||||
STANDARDS_JOIN,
|
|
||||||
STATION_STANDARD_HOURS_EXPR,
|
|
||||||
hoursBetween,
|
hoursBetween,
|
||||||
|
otherActivityHours,
|
||||||
|
stationStaysQb,
|
||||||
} from '../operations-classification';
|
} 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 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';
|
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 = {
|
export const stationStayingTimeReport: ReportDefinition = {
|
||||||
key: 'station-staying-time',
|
key: 'station-staying-time',
|
||||||
title: '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 train’s ' +
|
'How long each train stood at each station — the logged arrival to the same train’s ' +
|
||||||
'next departure from that station — against the standard for that side of the line ' +
|
'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 ' +
|
'(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 ' +
|
'standard needs a reason. Loading and unloading time is the stop’s logged handling ' +
|
||||||
'the system records when they start and end yet.',
|
'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',
|
group: 'Operations',
|
||||||
filters: [
|
filters: [
|
||||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
{ 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: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
|
||||||
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
||||||
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
|
{ 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: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||||
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
|
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
|
||||||
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
||||||
@@ -113,13 +53,15 @@ export const stationStayingTimeReport: ReportDefinition = {
|
|||||||
],
|
],
|
||||||
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return stationStaysQb(ctx)
|
||||||
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
||||||
.addSelect('s.station', 'station')
|
.addSelect('s.station', 'station')
|
||||||
.addSelect('s.country', 'country')
|
.addSelect('s.country', 'country')
|
||||||
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
.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(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||||
.addSelect(STAYING_HOURS, 'stayingHours')
|
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||||
|
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||||
|
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||||
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
||||||
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
||||||
.addSelect(
|
.addSelect(
|
||||||
@@ -132,18 +74,20 @@ export const stationStayingTimeReport: ReportDefinition = {
|
|||||||
.addSelect('s.note', 'reason');
|
.addSelect('s.note', 'reason');
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await stationStaysQb(ctx)
|
||||||
.select('COUNT(*)::int', 'stops')
|
.select('COUNT(*)::int', 'stops')
|
||||||
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||||
|
.addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling')
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
||||||
'overStandard',
|
'overStandard',
|
||||||
)
|
)
|
||||||
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
|
.getRawOne<{ stops: number; avgHours: number; avgHandling: number; overStandard: number }>();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||||
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
{ 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) },
|
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
CYCLE_STANDARD_HOURS_EXPR,
|
CYCLE_STANDARD_HOURS_EXPR,
|
||||||
DIRECTION_FILTER,
|
DIRECTION_FILTER,
|
||||||
cycleRateExpr,
|
cycleRateExpr,
|
||||||
|
handlingEnd,
|
||||||
|
handlingStart,
|
||||||
hoursBetween,
|
hoursBetween,
|
||||||
|
otherActivityHours,
|
||||||
scheduleLedgerQb,
|
scheduleLedgerQb,
|
||||||
} from '../operations-classification';
|
} from '../operations-classification';
|
||||||
|
|
||||||
@@ -61,6 +64,29 @@ const DJIBOUTI_HOURS = stayHours('Djibouti');
|
|||||||
const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end');
|
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`;
|
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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const HANDLING_HOURS = `(
|
||||||
|
SELECT ROUND(SUM(
|
||||||
|
EXTRACT(EPOCH FROM (${handlingEnd('e')} - ${handlingStart('e')})) / 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
|
||||||
|
)`;
|
||||||
|
|
||||||
|
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. */
|
/** The completed cycles, before the per-cycle stay decomposition. */
|
||||||
function cycleQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function cycleQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
return scheduleLedgerQb(ctx)
|
return scheduleLedgerQb(ctx)
|
||||||
@@ -99,7 +125,10 @@ export const turnaroundCycleReport: ReportDefinition = {
|
|||||||
'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' +
|
'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. ' +
|
'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 ' +
|
'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',
|
group: 'Operations',
|
||||||
filters: [
|
filters: [
|
||||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||||
@@ -116,6 +145,8 @@ export const turnaroundCycleReport: ReportDefinition = {
|
|||||||
{ key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true },
|
{ key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true },
|
||||||
{ key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' },
|
{ key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' },
|
||||||
{ key: 'djiboutiHours', label: 'Djibouti 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: 'travellingHours', label: 'Travelling (hrs)', type: 'number' },
|
||||||
{ key: 'averageDays', label: 'Average day', type: 'number' },
|
{ key: 'averageDays', label: 'Average day', type: 'number' },
|
||||||
],
|
],
|
||||||
@@ -132,6 +163,8 @@ export const turnaroundCycleReport: ReportDefinition = {
|
|||||||
.addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate')
|
.addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate')
|
||||||
.addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours')
|
.addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours')
|
||||||
.addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours')
|
.addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours')
|
||||||
|
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||||
|
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||||
.addSelect(TRAVEL_HOURS, 'travellingHours')
|
.addSelect(TRAVEL_HOURS, 'travellingHours')
|
||||||
.addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays');
|
.addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays');
|
||||||
},
|
},
|
||||||
@@ -143,12 +176,14 @@ export const turnaroundCycleReport: ReportDefinition = {
|
|||||||
`ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`,
|
`ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`,
|
||||||
'avgRate',
|
'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 [
|
return [
|
||||||
{ label: 'Cycles', value: Number(row?.cycles ?? 0) },
|
{ label: 'Cycles', value: Number(row?.cycles ?? 0) },
|
||||||
{ label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
{ label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||||
{ label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' },
|
{ label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' },
|
||||||
|
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity';
|
import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity';
|
||||||
import { CargoType } from '../rule-engine/entities/cargo-type.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 { 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 { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -471,6 +473,119 @@ export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectL
|
|||||||
return qb;
|
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`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const handlingStart = (alias: string): string =>
|
||||||
|
`LEAST(${alias}.unloading_started_at, ${alias}.loading_started_at)`;
|
||||||
|
export const handlingEnd = (alias: string): string =>
|
||||||
|
`GREATEST(${alias}.loading_completed_at, ${alias}.unloading_completed_at)`;
|
||||||
|
|
||||||
|
/** 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(`${alias}.loading_started_at`, `${alias}.loading_completed_at`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.occurred_at', 'arrived_at')
|
||||||
|
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
|
||||||
|
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
|
||||||
|
// 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')
|
||||||
|
.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, 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(
|
export function applyOperationsFilters(
|
||||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-perform
|
|||||||
import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
|
import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
|
||||||
import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
|
import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
|
||||||
import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report";
|
import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report";
|
||||||
|
import { loadingUnloadingReport } from "./definitions/loading-unloading.report";
|
||||||
import { ReportDefinition } from "./report.types";
|
import { ReportDefinition } from "./report.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +78,7 @@ export const REPORTS: ReportDefinition[] = [
|
|||||||
chargedVsActualVolumeReport,
|
chargedVsActualVolumeReport,
|
||||||
cargoVolumeByStationReport,
|
cargoVolumeByStationReport,
|
||||||
portWarehouseSummaryReport,
|
portWarehouseSummaryReport,
|
||||||
|
loadingUnloadingReport,
|
||||||
];
|
];
|
||||||
|
|
||||||
const BY_KEY = new Map<ReportKey, ReportDefinition>(
|
const BY_KEY = new Map<ReportKey, ReportDefinition>(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,31 @@ export class RecordCheckpointDto {
|
|||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
occurredAt?: string;
|
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 })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -52,6 +77,27 @@ export class UpdateCheckpointDto {
|
|||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
occurredAt?: string;
|
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 })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ export class TrainCheckpointEvent extends BaseEntity {
|
|||||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||||
occurredAt!: Date;
|
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 })
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,16 @@ import {
|
|||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
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. */
|
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||||
function pickDefined<T extends object>(source: T): Partial<T> {
|
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -4372,11 +4382,65 @@ export class TrainSchedulingService {
|
|||||||
label: e.yard?.label ?? e.yard?.code ?? null,
|
label: e.yard?.label ?? e.yard?.code ?? null,
|
||||||
kind: e.kind,
|
kind: e.kind,
|
||||||
occurredAt: e.occurredAt.toISOString(),
|
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,
|
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. */
|
/** Log the train passing a station. Logging the destination station triggers arrival. */
|
||||||
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
|
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
|
||||||
// Slim graph: checkpoint logging reads stops, locomotives, the built
|
// Slim graph: checkpoint logging reads stops, locomotives, the built
|
||||||
@@ -4411,12 +4475,14 @@ export class TrainSchedulingService {
|
|||||||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||||||
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
|
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
|
||||||
});
|
});
|
||||||
|
const handling = this.handlingPatch(dto, existing);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.trainCheckpointEventsRepository.update(existing.id, {
|
await this.trainCheckpointEventsRepository.update(existing.id, {
|
||||||
kind,
|
kind,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
note: dto.note ?? null,
|
note: dto.note ?? null,
|
||||||
yardId: station.yardId,
|
yardId: station.yardId,
|
||||||
|
...handling,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.trainCheckpointEventsRepository.create({
|
await this.trainCheckpointEventsRepository.create({
|
||||||
@@ -4426,6 +4492,7 @@ export class TrainSchedulingService {
|
|||||||
kind,
|
kind,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
note: dto.note ?? null,
|
note: dto.note ?? null,
|
||||||
|
...handling,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4686,6 +4753,7 @@ export class TrainSchedulingService {
|
|||||||
patch.occurredAt = occurredAt;
|
patch.occurredAt = occurredAt;
|
||||||
}
|
}
|
||||||
if (dto.note !== undefined) patch.note = dto.note;
|
if (dto.note !== undefined) patch.note = dto.note;
|
||||||
|
Object.assign(patch, this.handlingPatch(dto, existing));
|
||||||
if (Object.keys(patch).length) {
|
if (Object.keys(patch).length) {
|
||||||
await this.trainCheckpointEventsRepository.update(existing.id, patch);
|
await this.trainCheckpointEventsRepository.update(existing.id, patch);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,6 +487,12 @@ async function upsertTrain(ds: DataSource, code: string): Promise<string> {
|
|||||||
return row.id;
|
return row.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The handling window a stop records — the loading/unloading report's input. */
|
||||||
|
interface Handling {
|
||||||
|
unloadingStartedAt: Date;
|
||||||
|
loadingCompletedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertCheckpoint(
|
async function upsertCheckpoint(
|
||||||
ds: DataSource,
|
ds: DataSource,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -495,7 +501,18 @@ async function upsertCheckpoint(
|
|||||||
kind: 'ARRIVED' | 'DEPARTED',
|
kind: 'ARRIVED' | 'DEPARTED',
|
||||||
occurredAt: Date,
|
occurredAt: Date,
|
||||||
note: string,
|
note: string,
|
||||||
|
handling?: Handling,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const params = [
|
||||||
|
scheduleId,
|
||||||
|
yardId,
|
||||||
|
sequenceNo,
|
||||||
|
kind,
|
||||||
|
occurredAt,
|
||||||
|
note,
|
||||||
|
handling?.unloadingStartedAt ?? null,
|
||||||
|
handling?.loadingCompletedAt ?? null,
|
||||||
|
];
|
||||||
const existing = await ds.query<Array<{ id: string }>>(
|
const existing = await ds.query<Array<{ id: string }>>(
|
||||||
`SELECT id FROM freight.train_checkpoint_events
|
`SELECT id FROM freight.train_checkpoint_events
|
||||||
WHERE train_schedule_id = $1 AND yard_id = $2 AND kind = $3 AND deleted_at IS NULL`,
|
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) {
|
if (existing.length) {
|
||||||
await ds.query(
|
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`,
|
WHERE id = $1`,
|
||||||
[existing[0].id, occurredAt, note],
|
[existing[0].id, occurredAt, note, params[6], params[7]],
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ds.query(
|
await ds.query(
|
||||||
`INSERT INTO freight.train_checkpoint_events
|
`INSERT INTO freight.train_checkpoint_events
|
||||||
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note)
|
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
unloading_started_at, loading_completed_at)
|
||||||
[scheduleId, yardId, sequenceNo, kind, occurredAt, note],
|
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,
|
arrivedAt: arrivedGelan,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The loading/unloading figure has nowhere of its own to live yet — no
|
// The measured handling window, on the arrival row the staying-time report
|
||||||
// table records when handling starts and ends — so it rides on the stop's
|
// builds the stop from: work starts when the train lands and ends when
|
||||||
// note, where the staying-time report surfaces it as the stop's reason.
|
// loading finishes, which is what the OCC figure measures. The rest of the
|
||||||
|
// stay reports as other activity.
|
||||||
const note =
|
const note =
|
||||||
`OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` +
|
`OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` +
|
||||||
`${stayingHours.toFixed(2)}h total staying`;
|
`${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);
|
await upsertCheckpoint(ds, schedule, dmp, 0, 'DEPARTED', departedDmp, note);
|
||||||
}
|
}
|
||||||
console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`);
|
console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const SEEDED_REPORT_KEYS = [
|
|||||||
"charged-vs-actual-volume",
|
"charged-vs-actual-volume",
|
||||||
"cargo-volume-by-station",
|
"cargo-volume-by-station",
|
||||||
"port-warehouse-summary",
|
"port-warehouse-summary",
|
||||||
|
"loading-unloading",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { DateTimePicker } from "@mantine/dates";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
import { useEffect, useState } from "react";
|
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
|
* Time, station work and note for one leg of a train's journey — used both to
|
||||||
* (defaults to now) and to correct an already-logged leg (prefilled). Past
|
* log a pass (defaults to now) and to correct an already-logged leg
|
||||||
* times are allowed (staff record after the fact); the future is not, and the
|
* (prefilled). Past times are allowed (staff record after the fact); the future
|
||||||
* server additionally keeps legs in corridor order.
|
* 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({
|
export function CheckpointTimeModal({
|
||||||
opened,
|
opened,
|
||||||
@@ -17,6 +42,7 @@ export function CheckpointTimeModal({
|
|||||||
description,
|
description,
|
||||||
initialOccurredAt,
|
initialOccurredAt,
|
||||||
initialNote,
|
initialNote,
|
||||||
|
initialHandling,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
submitColor = "edr-green",
|
submitColor = "edr-green",
|
||||||
loading,
|
loading,
|
||||||
@@ -30,19 +56,37 @@ export function CheckpointTimeModal({
|
|||||||
/** ISO; omit to default to now. */
|
/** ISO; omit to default to now. */
|
||||||
initialOccurredAt?: string | null;
|
initialOccurredAt?: string | null;
|
||||||
initialNote?: string | null;
|
initialNote?: string | null;
|
||||||
|
initialHandling?: CheckpointHandlingTimes | null;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
submitColor?: string;
|
submitColor?: string;
|
||||||
loading: boolean;
|
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 isSmallScreen = useMediaQuery("(max-width: 48em)");
|
||||||
const [at, setAt] = useState<Date | null>(null);
|
const [at, setAt] = useState<Date | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) return;
|
if (!opened) return;
|
||||||
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
|
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
|
||||||
setNote(initialNote ?? "");
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -76,6 +120,35 @@ export function CheckpointTimeModal({
|
|||||||
clearable={false}
|
clearable={false}
|
||||||
radius="md"
|
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
|
<Textarea
|
||||||
label="Note"
|
label="Note"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
@@ -96,7 +169,15 @@ export function CheckpointTimeModal({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!at}
|
disabled={!at}
|
||||||
onClick={() =>
|
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}
|
{submitLabel}
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import { PageContainer } from "@/components/page";
|
|||||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||||
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
|
import type {
|
||||||
|
CheckpointHandlingTimes,
|
||||||
|
TrackStation,
|
||||||
|
TrainCheckpoint,
|
||||||
|
} from "@/types/trainScheduling";
|
||||||
import {
|
import {
|
||||||
RouteCorridor,
|
RouteCorridor,
|
||||||
StatusPill,
|
StatusPill,
|
||||||
@@ -46,6 +50,51 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
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) => {
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||||
@@ -262,7 +311,7 @@ export default function TrainScheduleTrackPage() {
|
|||||||
setLogModal({ station, isFinal });
|
setLogModal({ station, isFinal });
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitLog = (values: { occurredAt: string; note: string }) => {
|
const submitLog = (values: CheckpointModalValues) => {
|
||||||
if (!logModal) return;
|
if (!logModal) return;
|
||||||
const { station, isFinal } = logModal;
|
const { station, isFinal } = logModal;
|
||||||
recordCheckpoint.mutate(
|
recordCheckpoint.mutate(
|
||||||
@@ -272,6 +321,8 @@ export default function TrainScheduleTrackPage() {
|
|||||||
sequenceNo: station.sequenceNo,
|
sequenceNo: station.sequenceNo,
|
||||||
occurredAt: values.occurredAt,
|
occurredAt: values.occurredAt,
|
||||||
...(values.note ? { note: values.note } : {}),
|
...(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;
|
if (!editModal) return;
|
||||||
updateCheckpoint.mutate(
|
updateCheckpoint.mutate(
|
||||||
{
|
{
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
sequenceNo: editModal.sequenceNo,
|
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: () => {
|
onSuccess: () => {
|
||||||
@@ -687,6 +743,11 @@ export default function TrainScheduleTrackPage() {
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{formatDateTime(cp.occurredAt)}
|
{formatDateTime(cp.occurredAt)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{handlingHours(cp) !== null ? (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
Loading + unloading {handlingHours(cp)} h
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{cp.note ? (
|
{cp.note ? (
|
||||||
<Text size="xs" mt={2}>
|
<Text size="xs" mt={2}>
|
||||||
{cp.note}
|
{cp.note}
|
||||||
@@ -723,9 +784,10 @@ export default function TrainScheduleTrackPage() {
|
|||||||
onClose={() => setEditModal(null)}
|
onClose={() => setEditModal(null)}
|
||||||
title={`Edit ${editModal?.label ?? "checkpoint"}`}
|
title={`Edit ${editModal?.label ?? "checkpoint"}`}
|
||||||
icon={<Pencil size={18} />}
|
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}
|
initialOccurredAt={editModal?.occurredAt}
|
||||||
initialNote={editModal?.note}
|
initialNote={editModal?.note}
|
||||||
|
initialHandling={editModal}
|
||||||
submitLabel="Save"
|
submitLabel="Save"
|
||||||
loading={updateCheckpoint.isPending}
|
loading={updateCheckpoint.isPending}
|
||||||
onSubmit={submitEdit}
|
onSubmit={submitEdit}
|
||||||
|
|||||||
@@ -897,9 +897,22 @@ export interface TrainCheckpoint {
|
|||||||
label: string | null;
|
label: string | null;
|
||||||
kind: TrainCheckpointKind;
|
kind: TrainCheckpointKind;
|
||||||
occurredAt: string;
|
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;
|
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 {
|
export interface TrainTrackResponse {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
status: TrainScheduleStatus | string;
|
status: TrainScheduleStatus | string;
|
||||||
@@ -914,7 +927,7 @@ export interface TrainTrackResponse {
|
|||||||
checkpoints: TrainCheckpoint[];
|
checkpoints: TrainCheckpoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordCheckpointPayload {
|
export interface RecordCheckpointPayload extends CheckpointHandlingTimes {
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
kind?: TrainCheckpointKind;
|
kind?: TrainCheckpointKind;
|
||||||
/** When the train was at the station; defaults to now. Past OK, future rejected. */
|
/** 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. */
|
/** Edit an already-logged leg — pure correction, no side effects. */
|
||||||
export interface UpdateCheckpointPayload {
|
export interface UpdateCheckpointPayload extends CheckpointHandlingTimes {
|
||||||
occurredAt?: string;
|
occurredAt?: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ export interface ITrainCheckpointEvent extends BaseEntity {
|
|||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
kind: TrainCheckpointKind;
|
kind: TrainCheckpointKind;
|
||||||
occurredAt: string;
|
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;
|
note?: string | null;
|
||||||
recordedByUserId?: string | null;
|
recordedByUserId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user