mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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