feat: updateing the loading and unloading

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

View File

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