From ac7bff8cc8c95cea8ffa1f6cfca3700ec155c5d8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 22:22:04 +0000 Subject: [PATCH] add overnight desk handling and effective window configuration for train schedules --- .../batch-window.util.spec.ts | 28 +++++++++ .../booking-window.service.ts | 18 ++++-- .../train-scheduling.service.ts | 58 ++++++++++++++----- .../BookingWindowSettingsModal.tsx | 9 +-- 4 files changed, 89 insertions(+), 24 deletions(-) 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 c37a0d805..4e1770dfb 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 @@ -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)', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 87e410a61..449c67219 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -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; 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 5f69b99d5..5c993d38a 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 @@ -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' diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index 910a85521..2e32dcf45 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -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 ? (