mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user