mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
fix issues
This commit is contained in:
@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
|
||||
return new Date(departure.getTime() - offsetMinutes * 60_000);
|
||||
}
|
||||
|
||||
/** The schedule fields the close-offset reopen guard reads. */
|
||||
export interface CloseOffsetReopenSchedule {
|
||||
status: string;
|
||||
direction?: string | null;
|
||||
windowPhase?: string | null;
|
||||
bookingWindowStatus?: string | null;
|
||||
scheduledDepartureDate?: Date | null;
|
||||
}
|
||||
|
||||
export interface CloseOffsetReopenCheck {
|
||||
/** True when shortening the close offset is the one thing that reopens booking. */
|
||||
eligible: boolean;
|
||||
/** Why the schedule is not eligible; null when it is. */
|
||||
reason: string | null;
|
||||
/** Minutes before departure this schedule currently stops taking bookings. */
|
||||
offsetMinutes: number | null;
|
||||
/** The cutoff that shut booking (departure − offset); null without an offset. */
|
||||
cutoffAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this schedule's booking shut ONLY because of its close offset? That is the
|
||||
* one case staff may fix from the board by shortening the offset (3 days → 1
|
||||
* day, 2 hours, …) so the desk reopens before departure. Every other way a
|
||||
* window ends stays closed: the train departed, it is full, it never had an
|
||||
* offset (booking ran until departure), or the window is still mid-cycle.
|
||||
*
|
||||
* The last guard — "a cycle would fit before departure with no offset at all" —
|
||||
* is what makes the offset the ONLY problem: when the desk's next opening lands
|
||||
* after the train leaves, no offset change can help.
|
||||
*/
|
||||
export function closeOffsetReopenCheck(
|
||||
schedule: CloseOffsetReopenSchedule,
|
||||
cfg: {
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
},
|
||||
now: Date,
|
||||
): CloseOffsetReopenCheck {
|
||||
const departure = schedule.scheduledDepartureDate ?? null;
|
||||
const offsetRaw =
|
||||
schedule.direction === 'EXPORT'
|
||||
? cfg.exportCloseOffsetMinutes
|
||||
: cfg.importCloseOffsetMinutes;
|
||||
const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null;
|
||||
const cutoffAt =
|
||||
departure && offsetMinutes != null
|
||||
? bookingCloseCutoff(departure, schedule.direction, cfg)
|
||||
: null;
|
||||
const no = (reason: string): CloseOffsetReopenCheck => ({
|
||||
eligible: false,
|
||||
reason,
|
||||
offsetMinutes,
|
||||
cutoffAt,
|
||||
});
|
||||
|
||||
if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') {
|
||||
return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`);
|
||||
}
|
||||
if (!departure || departure.getTime() <= now.getTime()) {
|
||||
return no('This train has already departed (or has no departure date).');
|
||||
}
|
||||
if (offsetMinutes == null) {
|
||||
return no(
|
||||
'This train has no close offset — booking ran until departure, so there is nothing to shorten.',
|
||||
);
|
||||
}
|
||||
if (schedule.windowPhase !== 'DONE') {
|
||||
return no(
|
||||
schedule.windowPhase == null
|
||||
? 'This train does not run a managed booking window.'
|
||||
: `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`,
|
||||
);
|
||||
}
|
||||
if (schedule.bookingWindowStatus === 'FULL') {
|
||||
return no(
|
||||
'Booking closed because the train is full, not because of the close offset.',
|
||||
);
|
||||
}
|
||||
const hours: OfficeHours = {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
};
|
||||
if (nextCycleOpensAt(now, hours, departure) == null) {
|
||||
return no(
|
||||
'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.',
|
||||
);
|
||||
}
|
||||
return { eligible: true, reason: null, offsetMinutes, cutoffAt };
|
||||
}
|
||||
|
||||
export interface InitialWindowTimes {
|
||||
windowOpensAt: Date;
|
||||
windowClosesAt: Date;
|
||||
|
||||
@@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
|
||||
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
||||
import { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto";
|
||||
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
||||
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
||||
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
||||
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/close-offset")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure",
|
||||
})
|
||||
async reduceScheduleCloseOffset(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReduceScheduleCloseOffsetDto,
|
||||
) {
|
||||
await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/schedule-date")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Shorten the booking-close offset of ONE schedule whose booking shut only
|
||||
* because of that offset (staff action on the ops board). The value replaces the
|
||||
* schedule's frozen offset; 0 means "close at departure".
|
||||
*/
|
||||
export class ReduceScheduleCloseOffsetDto {
|
||||
@ApiProperty({
|
||||
example: 120,
|
||||
description:
|
||||
'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.',
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
closeOffsetMinutes!: number;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { closeOffsetReopenCheck } from './batch-window.util';
|
||||
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||
|
||||
/**
|
||||
* "Reopen booking by shortening the close offset": a train whose booking shut
|
||||
* ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its
|
||||
* window re-armed. Every other closed state is refused. Pure guard first, then
|
||||
* the service against stub repositories.
|
||||
*/
|
||||
describe('close-offset reopen', () => {
|
||||
// Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT.
|
||||
const DEPARTURE = new Date('2026-09-09T07:00:00.000Z');
|
||||
// Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff.
|
||||
const NOW = new Date('2026-09-07T06:00:00.000Z');
|
||||
const THREE_DAYS = 3 * 1_440;
|
||||
|
||||
const cfg = {
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
exportPaymentWindowMinutes: 60,
|
||||
importCloseOffsetMinutes: THREE_DAYS,
|
||||
exportCloseOffsetMinutes: THREE_DAYS,
|
||||
};
|
||||
|
||||
const closedByOffset = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'S1',
|
||||
reference: 'S-2026-00001',
|
||||
status: 'SCHEDULED',
|
||||
direction: 'IMPORT',
|
||||
windowPhase: 'DONE',
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
scheduledDepartureDate: DEPARTURE,
|
||||
originStationId: 'Y-ADD',
|
||||
destinationStationId: 'Y-DJ',
|
||||
ruleWindowOpenHour: 8,
|
||||
ruleWindowCloseHour: 17,
|
||||
ruleWindowDurationHours: 3,
|
||||
ruleImportWindowLeadDays: 3,
|
||||
ruleExportBookingLeadHours: 24,
|
||||
ruleImportCloseOffsetMinutes: THREE_DAYS,
|
||||
ruleExportCloseOffsetMinutes: THREE_DAYS,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('closeOffsetReopenCheck', () => {
|
||||
it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => {
|
||||
const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW);
|
||||
expect(check.eligible).toBe(true);
|
||||
expect(check.offsetMinutes).toBe(THREE_DAYS);
|
||||
expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dispatched train', { status: 'DISPATCHED' }, /dispatched/i],
|
||||
['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i],
|
||||
['full train', { bookingWindowStatus: 'FULL' }, /full/i],
|
||||
['window still open', { windowPhase: 'OPEN' }, /not closed yet/i],
|
||||
['legacy row with no window', { windowPhase: null }, /managed booking window/i],
|
||||
])('refuses a %s', (_label, over, reason) => {
|
||||
const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW);
|
||||
expect(check.eligible).toBe(false);
|
||||
expect(check.reason).toMatch(reason);
|
||||
});
|
||||
|
||||
it('refuses when the schedule never had an offset (booking ran to departure)', () => {
|
||||
const check = closeOffsetReopenCheck(
|
||||
closedByOffset(),
|
||||
{ ...cfg, importCloseOffsetMinutes: null },
|
||||
NOW,
|
||||
);
|
||||
expect(check.eligible).toBe(false);
|
||||
expect(check.reason).toMatch(/no close offset/i);
|
||||
expect(check.cutoffAt).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses when the desk could not reopen before departure even with no offset', () => {
|
||||
// Tuesday 18:00 EAT, desk 8–17: next opening is Wednesday 08:00, but the
|
||||
// train departs Wednesday 07:00 EAT — the offset is not the blocker.
|
||||
const lateNow = new Date('2026-09-08T15:00:00.000Z');
|
||||
const earlyDeparture = new Date('2026-09-09T04:00:00.000Z');
|
||||
const check = closeOffsetReopenCheck(
|
||||
closedByOffset({ scheduledDepartureDate: earlyDeparture }),
|
||||
cfg,
|
||||
lateNow,
|
||||
);
|
||||
expect(check.eligible).toBe(false);
|
||||
expect(check.reason).toMatch(/would not reopen before departure/i);
|
||||
});
|
||||
|
||||
it('reads the export offset for an EXPORT schedule', () => {
|
||||
const check = closeOffsetReopenCheck(
|
||||
closedByOffset({ direction: 'EXPORT' }),
|
||||
{ ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 },
|
||||
NOW,
|
||||
);
|
||||
expect(check.eligible).toBe(true);
|
||||
expect(check.offsetMinutes).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TrainSchedulingService.reduceScheduleCloseOffset', () => {
|
||||
type Fixture = {
|
||||
schedule: Record<string, unknown> | null;
|
||||
siblings?: Record<string, unknown>[];
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
const makeService = (fx: Fixture) => {
|
||||
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const repo = {
|
||||
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
|
||||
updates.push({ id, patch });
|
||||
}),
|
||||
};
|
||||
const siblingsQb = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(fx.siblings ?? []),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(repo),
|
||||
manager: {
|
||||
getRepository: jest
|
||||
.fn()
|
||||
.mockReturnValue({ createQueryBuilder: () => siblingsQb }),
|
||||
},
|
||||
};
|
||||
const service = Object.create(
|
||||
TrainSchedulingService.prototype,
|
||||
) as TrainSchedulingService;
|
||||
const emitted: string[] = [];
|
||||
Object.assign(service, {
|
||||
dataSource,
|
||||
trainSchedulesRepository: {
|
||||
findById: jest.fn().mockResolvedValue(fx.schedule),
|
||||
},
|
||||
getWindowConfig: jest.fn().mockResolvedValue(cfg),
|
||||
emitWindowState: jest.fn().mockImplementation(async (id: string) => {
|
||||
emitted.push(id);
|
||||
}),
|
||||
logger: { log: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
jest.useFakeTimers().setSystemTime(fx.now ?? NOW);
|
||||
return { service, updates, emitted };
|
||||
};
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('404s on an unknown schedule', async () => {
|
||||
const { service } = makeService({ schedule: null });
|
||||
await expect(
|
||||
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('refuses a train whose window is not shut by its offset', async () => {
|
||||
const { service, updates } = makeService({
|
||||
schedule: closedByOffset({ bookingWindowStatus: 'FULL' }),
|
||||
});
|
||||
await expect(
|
||||
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
|
||||
).rejects.toThrow(/full/i);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an offset that is not shorter than the current one', async () => {
|
||||
const { service, updates } = makeService({ schedule: closedByOffset() });
|
||||
await expect(
|
||||
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }),
|
||||
).rejects.toThrow(/shorter than the current 3 days/i);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an offset whose new cutoff is still before the next desk opening', async () => {
|
||||
// 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a
|
||||
// cycle fits… but 2 days 1 hour (Mon 09:00) does not.
|
||||
const { service } = makeService({ schedule: closedByOffset() });
|
||||
await expect(
|
||||
service.reduceScheduleCloseOffset('S1', {
|
||||
closeOffsetMinutes: 2 * 1_440 + 60,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => {
|
||||
const { service, updates, emitted } = makeService({ schedule: closedByOffset() });
|
||||
|
||||
// 1 day before departure → new cutoff Tuesday 10:00 EAT.
|
||||
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
const [{ id, patch }] = updates;
|
||||
expect(id).toBe('S1');
|
||||
expect(patch).toMatchObject({
|
||||
ruleImportCloseOffsetMinutes: 1_440,
|
||||
windowRuleCustom: true,
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
docReviewCompletedAt: null,
|
||||
docReviewEndsAt: null,
|
||||
paymentPhaseEndsAt: null,
|
||||
});
|
||||
// Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z).
|
||||
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
|
||||
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z');
|
||||
expect(emitted).toEqual(['S1']);
|
||||
});
|
||||
|
||||
it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => {
|
||||
// Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close.
|
||||
const tueAfternoon = new Date('2026-09-08T13:00:00.000Z');
|
||||
const { service, updates } = makeService({
|
||||
schedule: closedByOffset(),
|
||||
now: tueAfternoon,
|
||||
});
|
||||
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 });
|
||||
const [{ patch }] = updates;
|
||||
expect(patch.ruleImportCloseOffsetMinutes).toBeNull();
|
||||
expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString());
|
||||
// Desk close (17:00 EAT = 14:00Z) ends the cycle before departure.
|
||||
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z');
|
||||
});
|
||||
|
||||
it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => {
|
||||
const { service, updates } = makeService({
|
||||
schedule: closedByOffset(),
|
||||
siblings: [
|
||||
closedByOffset({ id: 'S2' }),
|
||||
// Already full — booking did not close because of the offset.
|
||||
closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }),
|
||||
// Still mid-cycle — must keep the state its customers see.
|
||||
closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }),
|
||||
],
|
||||
});
|
||||
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
|
||||
expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']);
|
||||
expect(updates[1].patch).toMatchObject({
|
||||
ruleImportCloseOffsetMinutes: 1_440,
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
});
|
||||
});
|
||||
|
||||
it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => {
|
||||
const { service, updates } = makeService({
|
||||
schedule: closedByOffset({ direction: 'EXPORT' }),
|
||||
siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })],
|
||||
});
|
||||
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 });
|
||||
expect(updates).toHaveLength(1);
|
||||
const [{ patch }] = updates;
|
||||
expect(patch).toMatchObject({
|
||||
ruleExportCloseOffsetMinutes: 120,
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
});
|
||||
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
|
||||
// Departure 07:00Z − 2h.
|
||||
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from '../dto/import-djibouti-operation.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
||||
import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto';
|
||||
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
||||
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
||||
@@ -197,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
clampCloseToOfficeHours,
|
||||
closeOffsetReopenCheck,
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
eatDay,
|
||||
eatDayToUtc,
|
||||
nextCycleOpensAt,
|
||||
shiftEatDay,
|
||||
type OfficeHours,
|
||||
} from '../batch-window.util';
|
||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from '../booking-journey.service';
|
||||
@@ -239,6 +243,19 @@ const HANDLING_FIELDS = [
|
||||
|
||||
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
|
||||
|
||||
/** "3 days" / "2 hours" / "45 minutes" for an error message. */
|
||||
function describeMinutes(minutes: number): string {
|
||||
if (minutes % 1_440 === 0) {
|
||||
const d = minutes / 1_440;
|
||||
return `${d} day${d === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
const h = minutes / 60;
|
||||
return `${h} hour${h === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
@@ -266,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Wire shape of a close-offset reopen check (dates as ISO strings). */
|
||||
function toCloseOffsetReopenInfo(check: ReturnType<typeof closeOffsetReopenCheck>) {
|
||||
return {
|
||||
eligible: check.eligible,
|
||||
reason: check.reason,
|
||||
offsetMinutes: check.offsetMinutes,
|
||||
cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking-window config a specific schedule runs under: its frozen rule
|
||||
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
||||
@@ -1024,6 +1051,152 @@ export class TrainSchedulingService {
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten the booking-close offset of ONE schedule whose booking shut ONLY
|
||||
* because of that offset, and re-arm its window so the desk reopens. A 3-day
|
||||
* offset that closed booking with the train still days away can be cut to a
|
||||
* day or a couple of hours; the window then opens at the next desk opening
|
||||
* (now, if the desk is open) and runs its normal cycles until the new cutoff.
|
||||
*
|
||||
* Refused for every other kind of closed window (departed, full, no offset,
|
||||
* still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be
|
||||
* shorter than the current one and must leave room for a cycle before the
|
||||
* new cutoff. The offset is frozen onto the schedule (the global value is
|
||||
* untouched) and the row is marked custom so a later global-rules save does
|
||||
* not re-stamp it.
|
||||
*
|
||||
* IMPORT/DOMESTIC: the same shorter offset is applied to every route+day
|
||||
* sibling that is likewise shut only by its offset, so the group keeps its
|
||||
* single shared timeline (each capped at its own new cutoff). EXPORT windows
|
||||
* are per-train, so an export change touches only this schedule.
|
||||
*/
|
||||
async reduceScheduleCloseOffset(
|
||||
id: string,
|
||||
dto: ReduceScheduleCloseOffsetDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
const now = new Date();
|
||||
const liveCfg = await this.getWindowConfig();
|
||||
const cfg = effectiveWindowConfig(schedule, liveCfg);
|
||||
const check = closeOffsetReopenCheck(schedule, cfg, now);
|
||||
if (!check.eligible || check.offsetMinutes == null) {
|
||||
throw new BadRequestException(
|
||||
check.reason ?? 'This schedule cannot reopen by shortening its close offset.',
|
||||
);
|
||||
}
|
||||
|
||||
const newOffset = dto.closeOffsetMinutes;
|
||||
if (newOffset >= check.offsetMinutes) {
|
||||
throw new BadRequestException(
|
||||
`The new close offset must be shorter than the current ${describeMinutes(
|
||||
check.offsetMinutes,
|
||||
)} before departure.`,
|
||||
);
|
||||
}
|
||||
|
||||
const isExport = schedule.direction === 'EXPORT';
|
||||
// 0 is stored as null so "no offset" keeps its single canonical value.
|
||||
const offsetPatch = isExport
|
||||
? { ruleExportCloseOffsetMinutes: newOffset || null }
|
||||
: { ruleImportCloseOffsetMinutes: newOffset || null };
|
||||
const merged: BookingWindowConfig = {
|
||||
...cfg,
|
||||
...(isExport
|
||||
? { exportCloseOffsetMinutes: newOffset || null }
|
||||
: { importCloseOffsetMinutes: newOffset || null }),
|
||||
};
|
||||
const hours: OfficeHours = {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
};
|
||||
const cutoff = bookingCloseCutoff(
|
||||
schedule.scheduledDepartureDate,
|
||||
schedule.direction,
|
||||
merged,
|
||||
);
|
||||
// The desk reopens at the next office-hours opening (now, when it is open),
|
||||
// exactly as a reopen cycle would — and only if that lands before the cutoff.
|
||||
const opensAt = nextCycleOpensAt(now, hours, cutoff);
|
||||
if (opensAt == null) {
|
||||
throw new BadRequestException(
|
||||
'Even with this offset the desk would not reopen before booking closes again ' +
|
||||
`(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`,
|
||||
);
|
||||
}
|
||||
let closesAt: Date;
|
||||
if (isExport) {
|
||||
// Export runs one FCFS window: from the reopen until the cutoff.
|
||||
closesAt = cutoff;
|
||||
} else {
|
||||
closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||
closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours);
|
||||
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
|
||||
}
|
||||
|
||||
const cap = (d: Date, bound: Date): Date =>
|
||||
d.getTime() > bound.getTime() ? bound : d;
|
||||
const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }];
|
||||
if (!isExport) {
|
||||
const siblings = await this.findGroupSiblings(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
schedule.scheduledDepartureDate,
|
||||
id,
|
||||
);
|
||||
for (const sib of siblings) {
|
||||
const sibCfg = effectiveWindowConfig(sib, liveCfg);
|
||||
const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now);
|
||||
// Only a sibling that is ALSO shut purely by an offset longer than the
|
||||
// new one joins in; anything else keeps the state its customers saw.
|
||||
if (
|
||||
!sibCheck.eligible ||
|
||||
sibCheck.offsetMinutes == null ||
|
||||
sibCheck.offsetMinutes <= newOffset ||
|
||||
!sib.scheduledDepartureDate
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, {
|
||||
...sibCfg,
|
||||
importCloseOffsetMinutes: newOffset || null,
|
||||
});
|
||||
if (opensAt.getTime() >= sibCutoff.getTime()) continue;
|
||||
targets.push({ id: sib.id, cutoff: sibCutoff });
|
||||
}
|
||||
}
|
||||
|
||||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||||
for (const t of targets) {
|
||||
await repo.update(t.id, {
|
||||
...offsetPatch,
|
||||
// Staff-set — exempt from the global re-stamp.
|
||||
windowRuleCustom: true,
|
||||
// Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs
|
||||
// the normal cycle from there (bookingWindowStatus flips OPEN then).
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
windowOpensAt: cap(opensAt, t.cutoff),
|
||||
windowClosesAt: cap(closesAt, t.cutoff),
|
||||
docReviewCompletedAt: null,
|
||||
docReviewEndsAt: null,
|
||||
paymentPhaseEndsAt: null,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
`Close offset of schedule ${schedule.reference ?? id} shortened ` +
|
||||
`${check.offsetMinutes} → ${newOffset} min before departure` +
|
||||
` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` +
|
||||
`${opensAt.toISOString()}, closes ${closesAt.toISOString()}`,
|
||||
);
|
||||
for (const t of targets) void this.emitWindowState(t.id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct a departure's operational run identifiers — the train number and
|
||||
* voyage number yards and customs quote.
|
||||
@@ -6313,8 +6486,11 @@ export class TrainSchedulingService {
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
// Live window config: each row's frozen rule overlays it to decide whether
|
||||
// the "shorten close offset" action applies (see closeOffsetReopenCheck).
|
||||
const liveCfg = await this.getWindowConfig();
|
||||
return {
|
||||
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
||||
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
|
||||
meta: buildPaginationMeta(total, page, pageSize),
|
||||
};
|
||||
}
|
||||
@@ -8857,7 +9033,11 @@ export class TrainSchedulingService {
|
||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
||||
}
|
||||
|
||||
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||
private mapScheduleListItem(
|
||||
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
/** Live window config; when given, the row carries its close-offset reopen state. */
|
||||
liveCfg?: BookingWindowConfig,
|
||||
) {
|
||||
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
||||
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
||||
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
||||
@@ -8921,6 +9101,18 @@ export class TrainSchedulingService {
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
status: schedule.status,
|
||||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
||||
windowPhase: schedule.windowPhase ?? null,
|
||||
// Whether booking shut ONLY because of the close offset — the board offers
|
||||
// "shorten close offset" on exactly these rows.
|
||||
closeOffsetReopen: liveCfg
|
||||
? toCloseOffsetReopenInfo(
|
||||
closeOffsetReopenCheck(
|
||||
schedule,
|
||||
effectiveWindowConfig(schedule, liveCfg),
|
||||
new Date(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
cancellationReason: schedule.cancellationReason ?? null,
|
||||
cancelledAt: schedule.cancelledAt ?? null,
|
||||
maxWagons: schedule.maxWagons ?? 0,
|
||||
@@ -10965,6 +11157,14 @@ export class TrainSchedulingService {
|
||||
// settings" editor on the ops board (prefill + save one schedule's
|
||||
// override). docReview/payment are not snapshotted per schedule (only their
|
||||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
||||
// Shut only by its close offset? Drives the "shorten close offset" action.
|
||||
closeOffsetReopen: toCloseOffsetReopenInfo(
|
||||
closeOffsetReopenCheck(
|
||||
schedule,
|
||||
effectiveWindowConfig(schedule, windowCfg),
|
||||
new Date(),
|
||||
),
|
||||
),
|
||||
windowRule: {
|
||||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||||
@@ -10974,6 +11174,12 @@ export class TrainSchedulingService {
|
||||
: null,
|
||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||
// The offsets this train actually runs under (its frozen snapshot, or
|
||||
// the live global for a legacy row) — null = booking runs to departure.
|
||||
importCloseOffsetMinutes:
|
||||
effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null,
|
||||
exportCloseOffsetMinutes:
|
||||
effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null,
|
||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||
// Editor prefill: this schedule's own override when staff set one,
|
||||
// else the live global for the schedule's direction (import/export
|
||||
|
||||
Reference in New Issue
Block a user