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

@@ -211,6 +211,16 @@ import {
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
/** The station-work stamps a stop can carry, with the label an error names. */
const HANDLING_FIELDS = [
['unloadingStartedAt', 'Unloading start'],
['unloadingCompletedAt', 'Unloading completion'],
['loadingStartedAt', 'Loading start'],
['loadingCompletedAt', 'Loading completion'],
] as const;
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
@@ -4372,11 +4382,65 @@ export class TrainSchedulingService {
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
occurredAt: e.occurredAt.toISOString(),
unloadingStartedAt: e.unloadingStartedAt?.toISOString() ?? null,
unloadingCompletedAt: e.unloadingCompletedAt?.toISOString() ?? null,
loadingStartedAt: e.loadingStartedAt?.toISOString() ?? null,
loadingCompletedAt: e.loadingCompletedAt?.toISOString() ?? null,
note: e.note ?? null,
})),
};
}
/**
* The station-work stamps off a record/update body, validated as two windows.
*
* Staff enter these after the fact, so the past is allowed and the future is
* not — the same rule the checkpoint's own time follows. Neither window may
* run backwards, and loading may not finish before unloading began: the OCC
* figure is `loading end unloading start`, and a crossed pair would publish
* negative handling and negative other activity.
*
* `undefined` leaves a stamp untouched; `null` clears a mis-entered one.
*/
private handlingPatch(
dto: Partial<Record<HandlingField, string | null>>,
existing?: TrainCheckpointEvent | null,
): Partial<Record<HandlingField, Date | null>> {
const patch: Partial<Record<HandlingField, Date | null>> = {};
for (const [field, label] of HANDLING_FIELDS) {
const raw = dto[field];
if (raw === undefined) continue;
if (raw === null) {
patch[field] = null;
continue;
}
const at = new Date(raw);
this.assertNotFuture(at, label);
patch[field] = at;
}
if (!Object.keys(patch).length) return patch;
// The stop as it will stand after the patch — a body that moves only one
// end of a window is still checked against the end already stored.
const merged = (field: HandlingField): Date | null =>
field in patch ? (patch[field] ?? null) : (existing?.[field] ?? null);
const inOrder = (from: HandlingField, to: HandlingField, message: string): void => {
const start = merged(from);
const end = merged(to);
if (start && end && end.getTime() < start.getTime()) {
throw new BadRequestException(message);
}
};
inOrder('unloadingStartedAt', 'unloadingCompletedAt', 'Unloading cannot finish before it started');
inOrder('loadingStartedAt', 'loadingCompletedAt', 'Loading cannot finish before it started');
inOrder(
'unloadingStartedAt',
'loadingCompletedAt',
'Loading cannot finish before unloading started',
);
return patch;
}
/** Log the train passing a station. Logging the destination station triggers arrival. */
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
// Slim graph: checkpoint logging reads stops, locomotives, the built
@@ -4411,12 +4475,14 @@ export class TrainSchedulingService {
const [existing] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
});
const handling = this.handlingPatch(dto, existing);
if (existing) {
await this.trainCheckpointEventsRepository.update(existing.id, {
kind,
occurredAt,
note: dto.note ?? null,
yardId: station.yardId,
...handling,
});
} else {
await this.trainCheckpointEventsRepository.create({
@@ -4426,6 +4492,7 @@ export class TrainSchedulingService {
kind,
occurredAt,
note: dto.note ?? null,
...handling,
});
}
@@ -4686,6 +4753,7 @@ export class TrainSchedulingService {
patch.occurredAt = occurredAt;
}
if (dto.note !== undefined) patch.note = dto.note;
Object.assign(patch, this.handlingPatch(dto, existing));
if (Object.keys(patch).length) {
await this.trainCheckpointEventsRepository.update(existing.id, patch);
}