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:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user