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 bd6cd854f..c37a0d805 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 @@ -121,6 +121,44 @@ describe('computeImportWindowTimes — first-window open respects office hours', }); }); +describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => { + // Overnight desk 08:00 → 07:00 next morning: open across [08:00, 24:00) and + // [00:00, 07:00). Only the daytime gap [07:00, 08:00) is shut. + const overnight = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 7, + windowDurationHours: 6, + }; + + it('opens NOW in the evening side of the window (after open hour)', () => { + // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 20:00 EAT (17:00 UTC): + // ≥ 08:00 → desk open → open immediately. + const departure = new Date('2026-07-06T07:00:00.000Z'); + const now = new Date('2026-07-05T17:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T17:00:00.000Z'); + }); + + it('opens NOW after midnight (before close hour)', () => { + // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 06 Jul 02:00 EAT (05 Jul 23:00 + // UTC): < 07:00 → still inside the overnight window → open immediately. + const departure = new Date('2026-07-06T07:00:00.000Z'); + const now = new Date('2026-07-05T23:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T23:00:00.000Z'); + }); + + it('waits until open hour in the daytime gap [close, open)', () => { + // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 07:30 EAT (04:30 UTC): + // in the shut daytime gap → opens 05 Jul 08:00 EAT (05:00 UTC). + const departure = new Date('2026-07-06T07:00:00.000Z'); + const now = new Date('2026-07-05T04:30:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z'); + }); +}); + describe('batch-window board windows (config-driven booking cycles)', () => { // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, // 3h long, reopen 90m later. 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 ec8b6826d..dfbcfcb6d 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 @@ -165,9 +165,9 @@ 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. + * The desk may run within one EAT day (`closeHour > openHour`), round the clock + * (`openHour === closeHour`), or overnight across midnight (`openHour > + * closeHour`, e.g. 08:00 → 07:00). `officeHoursOpen` handles all three. */ /** * The EAT instant a booking cycle would open if it became ready at `readyAt`, @@ -189,6 +189,19 @@ export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date { const readyMinutes = hour * 60 + minute; const openMinutes = hours.windowOpenHour * 60; const closeMinutes = hours.windowCloseHour * 60; + + if (hours.windowOpenHour > hours.windowCloseHour) { + // Overnight desk, e.g. open 08:00 → close 07:00 next morning. The desk is + // open across midnight: [openHour, 24:00) on this EAT day and [00:00, + // closeHour) on the next. Only the daytime gap [closeHour, openHour) is shut. + if (readyMinutes >= openMinutes || readyMinutes < closeMinutes) { + // Inside the overnight window (either side of midnight) → open when ready. + return readyAt; + } + // In the daytime gap → the desk opens again at openHour this EAT morning. + return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour); + } + if (readyMinutes < openMinutes) { // Ready before the desk opens on its own EAT calendar day → open this morning. return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour); 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 a28df8520..5f69b99d5 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 @@ -295,16 +295,10 @@ 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.`, - ); - } + // The booking desk supports three shapes: a same-day range + // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an + // overnight range that wraps past midnight (openHour > closeHour, e.g. + // 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard. // 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 @@ -389,12 +383,8 @@ export class TrainSchedulingService { reopenDelayMinutes: liveCfg.reopenDelayMinutes, }; - if (merged.windowCloseHour < merged.windowOpenHour) { - throw new BadRequestException( - `Window close hour (${merged.windowCloseHour}) must be on or after the open hour ` + - `(${merged.windowOpenHour}); set them equal for a 24-hour desk.`, - ); - } + // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid + // — officeHoursOpen resolves each, so no close-vs-open ordering guard here. const times = schedule.direction === 'EXPORT' @@ -1527,9 +1517,21 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } + // Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt. if (this.isImportDjiboutiSchedule(schedule)) { await this.confirmImportLoadedOnTrain(scheduleId, dto); } + // Confirming loading also marks every wagon-assigned booking LOADED, so the + // per-booking loading flag and the dispatch gate agree (otherwise the + // dispatch pre-check keeps reporting these bookings as unloaded). + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + if (wagonAssignedIds.size) { + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + [...wagonAssignedIds], + LoadingStatus.Loaded, + ); + } return this.getTrainScheduleById(scheduleId); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index 07eae83da..87a8993ee 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -120,7 +120,7 @@ export function GlClearanceUploadModal({ /> ) : ( setVesselDate(v ? new Date(v) : null)} size="sm" 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 e31ad6a20..910a85521 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -119,7 +119,9 @@ export default function BookingWindowSettingsModal({ const canEdit = schedule?.windowPhase === "PRE_WINDOW"; const is24h = form != null && form.windowOpenHour === form.windowCloseHour; - const closeBeforeOpen = + // Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning), + // not an error — the engine wraps it across midnight. + const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour; const reopenSummary = useMemo(() => { @@ -156,15 +158,6 @@ export default function BookingWindowSettingsModal({ }); return; } - if (closeBeforeOpen) { - toast({ - title: "Close hour must be on or after the open hour", - description: "Set them equal for a 24-hour desk.", - variant: "destructive", - }); - return; - } - const payload: UpdateScheduleWindowRulePayload = { windowOpenHour: form.windowOpenHour, windowCloseHour: form.windowCloseHour, @@ -282,10 +275,15 @@ export default function BookingWindowSettingsModal({ } allowDeselect={false} comboboxProps={{ withinPortal: true }} - error={closeBeforeOpen ? "Must be ≥ open hour" : undefined} disabled={isExport} /> + {isOvernight && !is24h ? ( + + Overnight desk — opens {form.windowOpenHour}:00 and runs past + midnight, closing {form.windowCloseHour}:00 the next morning. + + ) : null} Cancel -