fix(freight): guard booking-desk hours against overnight ranges

Review follow-ups on the window-close-hour feature:

- Reject windowCloseHour < windowOpenHour when saving global rules. The
  reopen engine (nextCycleOpensAt) assumes the daily desk runs within one
  EAT day; an overnight range would misroute a ready-time inside the span
  to the next morning. openHour === closeHour stays valid (24-hour desk).
- Derive the board projection's runaway cap from the real first-open →
  departure span over the minimum per-cycle advance, so a legitimate
  long-lead config is never silently truncated (was a flat 200).
- Document the open <= close precondition on nextCycleOpensAt and clarify
  the ready-before-open comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-07-05 09:07:21 +00:00
parent 93b8b69464
commit dfdf7a025d
3 changed files with 31 additions and 8 deletions

View File

@@ -110,10 +110,11 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
(w, i) => i > 0 && windows[i - 1].date !== w.date,
);
expect(crossesNight).toBe(true);
// Cycles run continuously from the window day up to departure.
expect(windows[windows.length - 1].end.getTime()).toBeLessThanOrEqual(
departure.getTime(),
);
// Cycles run continuously from the window day up to departure — the last one
// reaches departure, proving the runaway cap did not truncate the projection.
expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime());
// Spans the full lead (window day 05 Jun → departure 08 Jun).
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
it('export: single FCFS window exportBookingLeadHours before departure', () => {

View File

@@ -164,6 +164,10 @@ export function isRoundTheClock(hours: OfficeHours): boolean {
*
* Returns `null` when the next open would fall on/after `departure` — the train
* leaves before another cycle could run, so the window is done.
*
* Precondition: `windowCloseHour >= windowOpenHour` — the desk runs within a
* single EAT day and never wraps past midnight (enforced when global rules are
* saved). openHour === closeHour is the 24-hour desk, handled first.
*/
export function nextCycleOpensAt(
earliestNextOpen: Date,
@@ -179,7 +183,7 @@ export function nextCycleOpensAt(
const openMinutes = hours.windowOpenHour * 60;
const closeMinutes = hours.windowCloseHour * 60;
if (readyMinutes < openMinutes) {
// Desk not open yet today (ready before opening) → open this morning.
// Ready before the desk opens on its own EAT calendar day → open this morning.
opensAt = eatDayToUtc(eatDay(earliestNextOpen), hours.windowOpenHour);
} else if (readyMinutes < closeMinutes) {
// Inside office hours → open as soon as ready.
@@ -397,9 +401,16 @@ export function listConfigBookingWindows(
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen daily until the train departs; cap the projection so a tiny duration
// can't run away (spanning up to importWindowLeadDays of office days).
for (let cycle = 0; cycle < 200; cycle += 1) {
// The loop terminates naturally: every cycle advances opensAt by at least
// (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
// reach departure. maxCycles is a derived runaway backstop sized to the real
// span (first open → departure) over the smallest possible advance, so a
// legitimate config is never silently truncated — only a pathological
// zero-length one would hit it.
const spanMs = departure.getTime() - opensAt.getTime();
const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;

View File

@@ -293,6 +293,17 @@ export class TrainSchedulingService {
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
// The daily booking desk runs [openHour, closeHour) within one EAT day, so
// the desk must not wrap past midnight. openHour === closeHour is the 24-hour
// desk; openHour > closeHour (an overnight range) is rejected — the reopen
// engine has no notion of a window that spans midnight.
if (row.windowCloseHour < row.windowOpenHour) {
throw new BadRequestException(
`Window close hour (${row.windowCloseHour}) must be on or after the open hour ` +
`(${row.windowOpenHour}); set them equal for a 24-hour desk.`,
);
}
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.