mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
add overnight desk handling and effective window configuration for train schedules
This commit is contained in:
@@ -119,6 +119,34 @@ describe('computeImportWindowTimes — first-window open respects office hours',
|
|||||||
);
|
);
|
||||||
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
|
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)', () => {
|
describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
|||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { BookingBatchService } from './booking-batch.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 { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
@@ -54,7 +54,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
this.ticking = true;
|
this.ticking = true;
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const cfg = await this.trainSchedulingService.getWindowConfig();
|
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||||
|
|
||||||
const active = (
|
const active = (
|
||||||
await this.trainSchedulesRepository.findAll({
|
await this.trainSchedulesRepository.findAll({
|
||||||
@@ -71,7 +71,15 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
|
|
||||||
for (const schedule of active) {
|
for (const schedule of active) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
|
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
|
||||||
@@ -102,7 +110,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
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
|
// Stamp the whole route-day group so one staff action releases every train
|
||||||
// sharing this booking day's pool.
|
// sharing this booking day's pool.
|
||||||
const group = (
|
const group = (
|
||||||
@@ -123,7 +131,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(s.id, { docReviewCompletedAt: now });
|
.update(s.id, { docReviewCompletedAt: now });
|
||||||
s.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);
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
|
|||||||
@@ -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 =
|
export type BookingWagonAllocationStatus =
|
||||||
| 'NOT_ATTEMPTED'
|
| 'NOT_ATTEMPTED'
|
||||||
| 'ASSIGNED'
|
| 'ASSIGNED'
|
||||||
@@ -456,21 +498,7 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
// Re-derive the window from the schedule's own rule snapshot (falling back to
|
// 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.
|
// the live config where a legacy row has no snapshot) against the new date.
|
||||||
const merged: BookingWindowConfig = {
|
const merged = effectiveWindowConfig(schedule, windowCfg);
|
||||||
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 times =
|
const times =
|
||||||
schedule.direction === 'EXPORT'
|
schedule.direction === 'EXPORT'
|
||||||
|
|||||||
@@ -291,17 +291,18 @@ export default function BookingWindowSettingsModal({
|
|||||||
label="Run 24 hours a day (never pause overnight)"
|
label="Run 24 hours a day (never pause overnight)"
|
||||||
checked={is24h}
|
checked={is24h}
|
||||||
disabled={isExport}
|
disabled={isExport}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const checked = e.currentTarget.checked;
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
if (!f) return f;
|
if (!f) return f;
|
||||||
// On → close == open (24h desk). Off → restore a normal ~9h
|
// On → close == open (24h desk). Off → restore a normal ~9h
|
||||||
// day, always kept ≥ open hour so it never lands invalid.
|
// day, always kept ≥ open hour so it never lands invalid.
|
||||||
const close = e.currentTarget.checked
|
const close = checked
|
||||||
? f.windowOpenHour
|
? f.windowOpenHour
|
||||||
: Math.min(23, f.windowOpenHour + 9);
|
: Math.min(23, f.windowOpenHour + 9);
|
||||||
return { ...f, windowCloseHour: close };
|
return { ...f, windowCloseHour: close };
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
{!isExport ? (
|
{!isExport ? (
|
||||||
<Text size="xs" c="dimmed" mt={6}>
|
<Text size="xs" c="dimmed" mt={6}>
|
||||||
|
|||||||
Reference in New Issue
Block a user