enhance booking window logic to support overnight desk configurations and update related UI components

This commit is contained in:
Marshal
2026-07-05 21:29:09 +00:00
parent 3485a7d63d
commit caa8397563
5 changed files with 83 additions and 36 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -120,7 +120,7 @@ export function GlClearanceUploadModal({
/>
) : (
<DateInput
label="Vessel departure date (optional)"
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"

View File

@@ -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}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past
midnight, closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
@@ -394,11 +392,7 @@ export default function BookingWindowSettingsModal({
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
onClick={handleSave}
loading={save.isPending}
disabled={closeBeforeOpen}
>
<Button onClick={handleSave} loading={save.isPending}>
Save settings
</Button>
</Group>