mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user