Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts
Nathnael 17c2dca3cc 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
2026-08-24 12:09:04 +00:00

70 lines
2.6 KiB
TypeScript

import { TrainSchedulingService } from './services/train-scheduling.service';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
/**
* Loading and unloading stamps decide two published figures — total handling
* (unloading start to loading end) and the other activity left over from the
* stay. A crossed or future pair would publish negative hours, so the guard is
* the thing worth pinning down.
*/
const svc = Object.create(TrainSchedulingService.prototype) as {
handlingPatch: (
dto: Record<string, string | null | undefined>,
existing?: TrainCheckpointEvent | null,
) => Record<string, Date | null>;
};
const iso = (h: number): string => new Date(Date.UTC(2026, 6, 3, h)).toISOString();
const stop = (fields: Partial<TrainCheckpointEvent>) => fields as TrainCheckpointEvent;
describe('checkpoint handling times', () => {
it('takes a sane handling window', () => {
const patch = svc.handlingPatch({
unloadingStartedAt: iso(4),
loadingCompletedAt: iso(9),
});
expect(patch.unloadingStartedAt).toEqual(new Date(iso(4)));
expect(patch.loadingCompletedAt).toEqual(new Date(iso(9)));
});
it('rejects loading finishing before unloading started', () => {
expect(() =>
svc.handlingPatch({ unloadingStartedAt: iso(9), loadingCompletedAt: iso(4) }),
).toThrow('Loading cannot finish before unloading started');
});
it('rejects a window that runs backwards', () => {
expect(() =>
svc.handlingPatch({ loadingStartedAt: iso(9), loadingCompletedAt: iso(8) }),
).toThrow('Loading cannot finish before it started');
});
it('rejects a stamp in the future', () => {
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
expect(() => svc.handlingPatch({ unloadingStartedAt: tomorrow })).toThrow(
'Unloading start cannot be in the future',
);
});
// A body that moves one end of a window is still checked against the end
// already stored, or a two-step edit could walk the stop into a crossed pair.
it('checks a one-sided edit against the stored stop', () => {
expect(() =>
svc.handlingPatch(
{ loadingCompletedAt: iso(4) },
stop({ unloadingStartedAt: new Date(iso(9)) }),
),
).toThrow('Loading cannot finish before unloading started');
});
it('clears a stamp on null and leaves an untouched one alone', () => {
const patch = svc.handlingPatch(
{ unloadingStartedAt: null },
stop({ unloadingStartedAt: new Date(iso(4)), loadingCompletedAt: new Date(iso(9)) }),
);
expect(patch).toEqual({ unloadingStartedAt: null });
});
});