mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +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 })
|
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
|
||||||
bookingCycleNo!: number;
|
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)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
scheduleBookings?: TrainScheduleBooking[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,14 +307,22 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
|||||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||||
* exact windows the engine runs.
|
* exact windows the engine runs.
|
||||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure.
|
* 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(
|
export function listConfigBookingWindows(
|
||||||
direction: string | null | undefined,
|
direction: string | null | undefined,
|
||||||
departure: Date,
|
departure: Date,
|
||||||
cfg: BoardWindowConfig,
|
cfg: BoardWindowConfig,
|
||||||
|
anchorOpensAt?: Date | null,
|
||||||
): BoardWindow[] {
|
): BoardWindow[] {
|
||||||
if (direction === 'EXPORT') {
|
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)];
|
return [boardWindowFromInterval(start, departure)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +331,7 @@ export function listConfigBookingWindows(
|
|||||||
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||||
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
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.
|
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
|
||||||
for (let cycle = 0; cycle < 12; cycle += 1) {
|
for (let cycle = 0; cycle < 12; cycle += 1) {
|
||||||
if (opensAt.getTime() >= departure.getTime()) break;
|
if (opensAt.getTime() >= departure.getTime()) break;
|
||||||
@@ -375,8 +383,9 @@ export function groupBookingsIntoBoardWindows<T>(
|
|||||||
departure: Date,
|
departure: Date,
|
||||||
cfg: BoardWindowConfig,
|
cfg: BoardWindowConfig,
|
||||||
pendingKey = 'pending-contract',
|
pendingKey = 'pending-contract',
|
||||||
|
anchorOpensAt?: Date | null,
|
||||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
): 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[] }>();
|
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||||
for (const w of windows) {
|
for (const w of windows) {
|
||||||
map.set(w.key, { window: w, items: [] });
|
map.set(w.key, { window: w, items: [] });
|
||||||
|
|||||||
@@ -719,11 +719,34 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const loco = s.trainSet?.locomotive ?? null;
|
const loco = s.trainSet?.locomotive ?? null;
|
||||||
|
|
||||||
// Display windows are the REAL booking-window cycles from the global-rules
|
// Display windows are the REAL booking-window cycles this schedule was FROZEN
|
||||||
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
|
// with at creation (import: opens at its stored window time, lasts its rule's
|
||||||
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
|
// duration, reopens per its rule's delay; export: single FCFS lead window) —
|
||||||
// export: single FCFS lead window) — not a fixed clock grid.
|
// NOT the live global config. A later global-rules edit only re-derives
|
||||||
const windowCfg = await this.trainSchedulingService.getWindowConfig();
|
// 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 departureDate = s.scheduledDepartureDate ?? new Date();
|
||||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||||
items,
|
items,
|
||||||
@@ -731,6 +754,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
s.direction ?? null,
|
s.direction ?? null,
|
||||||
departureDate,
|
departureDate,
|
||||||
windowCfg,
|
windowCfg,
|
||||||
|
undefined,
|
||||||
|
s.windowOpensAt ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyCounts = () => ({
|
const emptyCounts = () => ({
|
||||||
|
|||||||
@@ -321,9 +321,17 @@ export class TrainSchedulingService {
|
|||||||
s.direction === 'EXPORT'
|
s.direction === 'EXPORT'
|
||||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||||
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
|
: 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, {
|
await repo.update(s.id, {
|
||||||
windowOpensAt: times.windowOpensAt,
|
windowOpensAt: times.windowOpensAt,
|
||||||
windowClosesAt: times.windowClosesAt,
|
windowClosesAt: times.windowClosesAt,
|
||||||
|
ruleWindowOpenHour: cfg.windowOpenHour,
|
||||||
|
ruleWindowDurationHours: cfg.windowDurationHours,
|
||||||
|
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
|
||||||
|
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
||||||
|
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
||||||
});
|
});
|
||||||
restamped += 1;
|
restamped += 1;
|
||||||
}
|
}
|
||||||
@@ -489,17 +497,30 @@ export class TrainSchedulingService {
|
|||||||
`(earliest ${earliest.toISOString()})`,
|
`(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 =
|
const windowFields =
|
||||||
direction === 'EXPORT'
|
direction === 'EXPORT'
|
||||||
? {
|
? {
|
||||||
bookingWindowStatus: 'CLOSED',
|
bookingWindowStatus: 'CLOSED',
|
||||||
windowPhase: 'PRE_WINDOW',
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
...ruleSnapshot,
|
||||||
...computeExportWindowTimes(departure, windowCfg),
|
...computeExportWindowTimes(departure, windowCfg),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||||
bookingWindowStatus: 'CLOSED',
|
bookingWindowStatus: 'CLOSED',
|
||||||
windowPhase: 'PRE_WINDOW',
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
...ruleSnapshot,
|
||||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||||
};
|
};
|
||||||
const schedule = manager.getRepository(TrainSchedule).create({
|
const schedule = manager.getRepository(TrainSchedule).create({
|
||||||
|
|||||||
Reference in New Issue
Block a user