diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index 797421fcc..25ee84b8c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -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', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index a38d9e75f..442c21f95 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 6be4cd966..1f676f0f3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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.