mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat: updateing the loading and unloading
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user