From 8db19dfacff9dc4a62ccf25926578095ed6f6c45 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 18:45:45 +0000 Subject: [PATCH] Add snapshot of booking-window rules to train schedules and update related logic --- ...000000000-AddScheduleWindowRuleSnapshot.ts | 58 +++++++++++++++++++ .../entities/train-schedule.entity.ts | 21 +++++++ .../train-scheduling/batch-window.util.ts | 15 ++++- .../train-scheduling/booking-batch.service.ts | 35 +++++++++-- .../train-scheduling.service.ts | 21 +++++++ 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts diff --git a/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts new file mode 100644 index 000000000..d48e127c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d67d58811..0cc07dacc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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[]; } 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 c60c316ac..b30b3f454 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 @@ -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( departure: Date, cfg: BoardWindowConfig, pendingKey = 'pending-contract', + anchorOpensAt?: Date | null, ): Map { - const windows = listConfigBookingWindows(direction, departure, cfg); + const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt); const map = new Map(); for (const w of windows) { map.set(w.key, { window: w, items: [] }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index efa6b3259..46175c7ef 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 = () => ({ 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 53ebc445b..fbb2ed4cd 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 @@ -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({