mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add snapshot of booking-window rules to train schedules and update related logic
This commit is contained in:
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user