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:
Nathnael
2026-08-24 12:09:04 +00:00
parent 2286135228
commit 17c2dca3cc
16 changed files with 763 additions and 106 deletions

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';
@@ -471,6 +473,119 @@ 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`;
/**
* 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(
qb: SelectQueryBuilder<ObjectLiteral>,
params: Record<string, unknown>,