Add snapshot of booking-window rules to train schedules and update related logic

This commit is contained in:
Marshal
2026-07-03 18:45:45 +00:00
parent 0ac5adcee9
commit 8db19dfacf
5 changed files with 142 additions and 8 deletions

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Snapshot the booking-window rule onto each train schedule.
*
* A schedule's window (open time + reopen cycles) must be frozen to the rule it
* was created with: a later global-rules edit applies only to FUTURE schedules,
* while an already-open schedule keeps its base rule. Previously the batch board
* recomputed windows from the LIVE global config, so editing the rule redrew the
* board for open schedules (a synthetic grid that no longer matched the window
* the customer was shown). These columns give the board a per-schedule rule to
* derive its display windows from.
*
* Existing rows are backfilled from the current global-rules singleton — the best
* available base, since they never stored one. Their stamped windowOpensAt/
* windowClosesAt are still real, so only projected reopen cycles rely on the
* backfill.
*/
export class AddScheduleWindowRuleSnapshot1920000000000
implements MigrationInterface
{
name = "AddScheduleWindowRuleSnapshot1920000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_window_open_hour integer,
ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4),
ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer,
ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer,
ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer;
`);
// Backfill from the global-rules singleton so pre-existing schedules render.
await queryRunner.query(`
UPDATE freight.train_schedules ts
SET
rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour),
rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours),
rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes),
rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days),
rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours)
FROM freight.train_scheduling_global_rules r
WHERE ts.rule_window_open_hour IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_window_open_hour,
DROP COLUMN IF EXISTS rule_window_duration_hours,
DROP COLUMN IF EXISTS rule_reopen_delay_minutes,
DROP COLUMN IF EXISTS rule_import_window_lead_days,
DROP COLUMN IF EXISTS rule_export_booking_lead_hours;
`);
}
}

View File

@@ -111,6 +111,27 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
bookingCycleNo!: number;
// ── Booking-window rule snapshot ──────────────────────────────────────────
// The scheduling rule this train was created with, frozen at creation. A later
// global-rules edit applies only to FUTURE schedules — an already-open schedule
// keeps its base rule. The batch board derives its display windows (open time +
// reopen cycles) from THIS snapshot, never from the live global config. NULL on
// legacy rows created before the snapshot existed (board falls back to live cfg).
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
ruleWindowOpenHour?: number | null;
@Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true })
ruleWindowDurationHours?: number | null;
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
ruleReopenDelayMinutes?: number | null;
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
ruleImportWindowLeadDays?: number | null;
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
ruleExportBookingLeadHours?: number | null;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -307,14 +307,22 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
*
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
* shows the real frozen window (and reopen cycles projected from it) even after
* the global rule changed — the recomputed open time would otherwise drift.
*/
export function listConfigBookingWindows(
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
anchorOpensAt?: Date | null,
): BoardWindow[] {
if (direction === 'EXPORT') {
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
const start =
anchorOpensAt ??
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)];
}
@@ -323,7 +331,7 @@ export function listConfigBookingWindows(
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
@@ -375,8 +383,9 @@ export function groupBookingsIntoBoardWindows<T>(
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
anchorOpensAt?: Date | null,
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg);
const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
for (const w of windows) {
map.set(w.key, { window: w, items: [] });

View File

@@ -719,11 +719,34 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null;
// Display windows are the REAL booking-window cycles from the global-rules
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
// export: single FCFS lead window) — not a fixed clock grid.
const windowCfg = await this.trainSchedulingService.getWindowConfig();
// Display windows are the REAL booking-window cycles this schedule was FROZEN
// with at creation (import: opens at its stored window time, lasts its rule's
// duration, reopens per its rule's delay; export: single FCFS lead window) —
// NOT the live global config. A later global-rules edit only re-derives
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
// must keep drawing from its own snapshot, anchored on its stored open time.
// Legacy rows with no snapshot fall back to the live config.
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const windowCfg = {
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
windowDurationHours: num(
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
),
exportBookingLeadHours: num(
s.ruleExportBookingLeadHours,
liveCfg.exportBookingLeadHours,
),
};
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
@@ -731,6 +754,8 @@ export class BookingBatchService implements OnModuleInit {
s.direction ?? null,
departureDate,
windowCfg,
undefined,
s.windowOpensAt ?? null,
);
const emptyCounts = () => ({

View File

@@ -321,9 +321,17 @@ export class TrainSchedulingService {
s.direction === 'EXPORT'
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
// snapshot alongside the re-stamped times — the board then draws the new
// window from this same rule.
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
});
restamped += 1;
}
@@ -489,17 +497,30 @@ export class TrainSchedulingService {
`(earliest ${earliest.toISOString()})`,
);
}
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = {
ruleWindowOpenHour: windowCfg.windowOpenHour,
ruleWindowDurationHours: windowCfg.windowDurationHours,
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
};
const windowFields =
direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeExportWindowTimes(departure, windowCfg),
}
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const schedule = manager.getRepository(TrainSchedule).create({