add overnight desk handling and effective window configuration for train schedules

This commit is contained in:
Marshal
2026-07-05 22:22:04 +00:00
parent caa8397563
commit ac7bff8cc8
4 changed files with 89 additions and 24 deletions

View File

@@ -119,6 +119,34 @@ describe('computeImportWindowTimes — first-window open respects office hours',
);
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
});
describe('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
it('opens NOW at 00:00 (deep night is INSIDE the overnight window)', () => {
// Now = 05 Jul 00:00 EAT (04 Jul 21:00 UTC): after midnight, before 05:00 →
// inside the overnight desk → open immediately. This is the reported bug.
const now = new Date('2026-07-04T21:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-04T21:00:00.000Z');
});
it('opens NOW at 22:00 (evening is INSIDE the overnight window)', () => {
// Now = 05 Jul 22:00 EAT (19:00 UTC): after 08:00 open → inside → open now.
const now = new Date('2026-07-05T19:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T19:00:00.000Z');
});
it('waits to 08:00 when now is in the daytime gap [05:00, 08:00)', () => {
// Now = 05 Jul 06:00 EAT (03:00 UTC): desk shut (gap) → open 08:00 today.
const now = new Date('2026-07-05T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
// 05 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
});
});
describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => {

View File

@@ -9,7 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
@@ -54,7 +54,7 @@ export class BookingWindowService implements OnModuleInit {
this.ticking = true;
try {
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const active = (
await this.trainSchedulesRepository.findAll({
@@ -71,7 +71,15 @@ export class BookingWindowService implements OnModuleInit {
for (const schedule of active) {
try {
await this.advanceSchedule(schedule, cfg, now);
// Each train runs under its OWN frozen rule snapshot, not the live global
// config — a later global-rules edit must not retro-change the window an
// existing train already advertised, and the reopen cycles must match the
// board (which is drawn from the same snapshot).
await this.advanceSchedule(
schedule,
effectiveWindowConfig(schedule, liveCfg),
now,
);
} catch (err) {
this.logger.error(
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
@@ -102,7 +110,7 @@ export class BookingWindowService implements OnModuleInit {
return schedule;
}
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const liveCfg = await this.trainSchedulingService.getWindowConfig();
// Stamp the whole route-day group so one staff action releases every train
// sharing this booking day's pool.
const group = (
@@ -123,7 +131,7 @@ export class BookingWindowService implements OnModuleInit {
.getRepository(TrainSchedule)
.update(s.id, { docReviewCompletedAt: now });
s.docReviewCompletedAt = now;
await this.advanceSchedule(s, cfg, now);
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
}
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;

View File

@@ -144,6 +144,48 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
* config, with the live config filling any snapshot field a legacy row lacks.
*
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
* the runtime cycle engine matches exactly what the board drew and the customer
* saw — a later global-rule edit must not retro-change an existing train. The
* doc-review / payment split is an internal process timing (not part of the
* window the customer sees) and is not stored split in the snapshot, so it always
* takes the live values; their sum is only used as a fallback reopen gap when the
* row predates `ruleReopenDelayMinutes`.
*/
export function effectiveWindowConfig(
schedule: {
ruleWindowOpenHour?: number | null;
ruleWindowCloseHour?: number | null;
ruleWindowDurationHours?: number | null;
ruleReopenDelayMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
return {
importWindowLeadDays:
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
reopenDelayMinutes:
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
};
}
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
@@ -456,21 +498,7 @@ export class TrainSchedulingService {
// Re-derive the window from the schedule's own rule snapshot (falling back to
// the live config where a legacy row has no snapshot) against the new date.
const merged: BookingWindowConfig = {
importWindowLeadDays:
schedule.ruleImportWindowLeadDays ?? windowCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? windowCfg.exportBookingLeadHours,
windowOpenHour: schedule.ruleWindowOpenHour ?? windowCfg.windowOpenHour,
windowCloseHour: schedule.ruleWindowCloseHour ?? windowCfg.windowCloseHour,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: windowCfg.windowDurationHours,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
reopenDelayMinutes: windowCfg.reopenDelayMinutes,
};
const merged = effectiveWindowConfig(schedule, windowCfg);
const times =
schedule.direction === 'EXPORT'

View File

@@ -291,17 +291,18 @@ export default function BookingWindowSettingsModal({
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
disabled={isExport}
onChange={(e) =>
onChange={(e) => {
const checked = e.currentTarget.checked;
setForm((f) => {
if (!f) return f;
// On → close == open (24h desk). Off → restore a normal ~9h
// day, always kept ≥ open hour so it never lands invalid.
const close = e.currentTarget.checked
const close = checked
? f.windowOpenHour
: Math.min(23, f.windowOpenHour + 9);
return { ...f, windowCloseHour: close };
})
}
});
}}
/>
{!isExport ? (
<Text size="xs" c="dimmed" mt={6}>