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

@@ -487,6 +487,12 @@ async function upsertTrain(ds: DataSource, code: string): Promise<string> {
return row.id;
}
/** The handling window a stop records — the loading/unloading report's input. */
interface Handling {
unloadingStartedAt: Date;
loadingCompletedAt: Date;
}
async function upsertCheckpoint(
ds: DataSource,
scheduleId: string,
@@ -495,7 +501,18 @@ async function upsertCheckpoint(
kind: 'ARRIVED' | 'DEPARTED',
occurredAt: Date,
note: string,
handling?: Handling,
): Promise<void> {
const params = [
scheduleId,
yardId,
sequenceNo,
kind,
occurredAt,
note,
handling?.unloadingStartedAt ?? null,
handling?.loadingCompletedAt ?? null,
];
const existing = await ds.query<Array<{ id: string }>>(
`SELECT id FROM freight.train_checkpoint_events
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) {
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`,
[existing[0].id, occurredAt, note],
[existing[0].id, occurredAt, note, params[6], params[7]],
);
return;
}
await ds.query(
`INSERT INTO freight.train_checkpoint_events
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note)
VALUES ($1, $2, $3, $4, $5, $6)`,
[scheduleId, yardId, sequenceNo, kind, occurredAt, note],
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note,
unloading_started_at, loading_completed_at)
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,
});
// The loading/unloading figure has nowhere of its own to live yet — no
// table records when handling starts and ends — so it rides on the stop's
// note, where the staying-time report surfaces it as the stop's reason.
// The measured handling window, on the arrival row the staying-time report
// builds the stop from: work starts when the train lands and ends when
// loading finishes, which is what the OCC figure measures. The rest of the
// stay reports as other activity.
const note =
`OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` +
`${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);
}
console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`);