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

@@ -0,0 +1,69 @@
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 });
});
});

View File

@@ -35,6 +35,31 @@ export class RecordCheckpointDto {
@IsISO8601()
occurredAt?: string;
/**
* Station work during the stay this stop opens — what the OCC report calls
* loading and unloading time. All optional: a stop logged without them still
* records its staying time.
*/
@ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' })
@IsOptional()
@IsISO8601()
unloadingStartedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
unloadingCompletedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
loadingStartedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
loadingCompletedAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@@ -52,6 +77,27 @@ export class UpdateCheckpointDto {
@IsISO8601()
occurredAt?: string;
/** Null clears a mis-entered stamp; undefined leaves it as it is. */
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
unloadingStartedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
unloadingCompletedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
loadingStartedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsISO8601()
loadingCompletedAt?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()

View File

@@ -37,6 +37,28 @@ export class TrainCheckpointEvent extends BaseEntity {
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
/**
* Station work, on the stop it happened at. Null where nobody logged it —
* an unlogged stop still reports its staying time, with the handling split
* empty rather than zero.
*
* The stay these belong to opens with THIS arrival and closes with the next
* departure, which is a different schedule when the train turns around. That
* is why they ride the arrival row: it is the row the staying-time report
* builds a stop from.
*/
@Column({ name: 'unloading_started_at', type: 'timestamptz', nullable: true })
unloadingStartedAt?: Date | null;
@Column({ name: 'unloading_completed_at', type: 'timestamptz', nullable: true })
unloadingCompletedAt?: Date | null;
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
loadingStartedAt?: Date | null;
@Column({ name: 'loading_completed_at', type: 'timestamptz', nullable: true })
loadingCompletedAt?: Date | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;

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);
}