From 93b8b69464c5147a1ba67bf76fceb376a8e6e16b Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 08:53:27 +0000 Subject: [PATCH] Add window close hour to booking desk configuration --- .../1950000000000-AddWindowCloseHour.ts | 52 ++++++++++++ .../entities/train-schedule.entity.ts | 8 ++ .../batch-window.util.spec.ts | 40 +++++++-- .../train-scheduling/batch-window.util.ts | 83 ++++++++++++++++--- .../booking-batch.service.spec.ts | 1 + .../train-scheduling/booking-batch.service.ts | 1 + .../train-scheduling/booking-window.config.ts | 8 +- .../booking-window.service.ts | 76 +++++++++++------ ...pdate-train-scheduling-global-rules.dto.ts | 14 +++- .../train-scheduling-global-rules.entity.ts | 8 ++ .../train-scheduling.service.ts | 37 ++++++--- 11 files changed, 271 insertions(+), 57 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts new file mode 100644 index 000000000..6ff9bdc12 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the daily booking-desk close hour. + * + * The import booking window used to reopen only within the same EAT calendar day + * as its close; a cycle whose reopen crossed midnight died at CLOSED_FOR_DAY with + * capacity still free. The window now runs a daily office range [openHour, + * closeHour): a not-yet-full train pauses at closeHour and resumes the next + * morning at openHour, every day until it fills or departs. openHour === closeHour + * means a 24-hour desk. + * + * `window_close_hour` on the global-rules singleton is the live config; the + * matching `rule_window_close_hour` snapshot on each schedule freezes it at + * creation so the batch board keeps drawing the window the customer was shown. + * Both default/backfill to 17:00 (5 PM), the previous implicit office close. + */ +export class AddWindowCloseHour1950000000000 implements MigrationInterface { + name = "AddWindowCloseHour1950000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS window_close_hour integer NOT NULL DEFAULT 17; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_window_close_hour integer; + `); + + // Backfill the snapshot from the global-rules singleton so pre-existing + // schedules keep projecting reopen cycles. + await queryRunner.query(` + UPDATE freight.train_schedules ts + SET rule_window_close_hour = COALESCE(ts.rule_window_close_hour, r.window_close_hour) + FROM freight.train_scheduling_global_rules r + WHERE ts.rule_window_close_hour IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_window_close_hour; + `); + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS window_close_hour; + `); + } +} 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 0cc07dacc..f82d9696d 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 @@ -120,9 +120,17 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_window_open_hour', type: 'int', nullable: true }) ruleWindowOpenHour?: number | null; + /** EAT hour the daily booking desk shuts (equals open hour for a 24h desk). */ + @Column({ name: 'rule_window_close_hour', type: 'int', nullable: true }) + ruleWindowCloseHour?: number | null; + @Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true }) ruleWindowDurationHours?: number | null; + /** + * Frozen reopen gap = doc-review + payment minutes at creation. The board + * projects each next cycle at close + this delay, then snaps it into office hours. + */ @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) ruleReopenDelayMinutes?: number | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index 764589b73..797421fcc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -55,10 +55,12 @@ describe('batch-window.util', () => { }); describe('batch-window board windows (config-driven booking cycles)', () => { - // Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later. + // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, + // 3h long, reopen 90m later. const cfg: BoardWindowConfig = { importWindowLeadDays: 3, windowOpenHour: 8, + windowCloseHour: 17, windowDurationHours: 3, reopenDelayMinutes: 90, exportBookingLeadHours: 24, @@ -76,14 +78,42 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('import: reopens reopenDelayMinutes after close, same booking day', () => { + it('import: reopens reopenDelayMinutes after close while inside office hours', () => { const departure = new Date('2026-06-08T11:00:00.000Z'); const windows = listConfigBookingWindows('IMPORT', departure, cfg); - // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT + // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day expect(windows.length).toBeGreaterThanOrEqual(2); expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT - // all cycles stay on the same EAT booking day - expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); + expect(windows[1].date).toBe('2026-06-05'); + }); + + it('import: pauses at close hour and resumes next morning at open hour', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // Day 05 Jun: 08:00, 12:30, 17:00-clamped… the cycle whose reopen lands + // at/after 17:00 EAT rolls to 06 Jun 08:00 EAT (05:00 UTC). + const day6First = windows.find((w) => w.date === '2026-06-06'); + expect(day6First).toBeDefined(); + expect(day6First!.start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); // 08:00 EAT + // Cycles span the office days between the window day and departure. + const days = new Set(windows.map((w) => w.date)); + expect(days.has('2026-06-05')).toBe(true); + expect(days.has('2026-06-06')).toBe(true); + }); + + it('import: 24-hour desk (open hour === close hour) never breaks for the day', () => { + const roundClock: BoardWindowConfig = { ...cfg, windowOpenHour: 8, windowCloseHour: 8 }; + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, roundClock); + // Reopen chains straight through midnight: an overnight cycle exists. + const crossesNight = windows.some( + (w, i) => i > 0 && windows[i - 1].date !== w.date, + ); + expect(crossesNight).toBe(true); + // Cycles run continuously from the window day up to departure. + expect(windows[windows.length - 1].end.getTime()).toBeLessThanOrEqual( + departure.getTime(), + ); }); it('export: single FCFS window exportBookingLeadHours before departure', () => { 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 b30b3f454..a38d9e75f 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 @@ -137,6 +137,62 @@ export function shiftEatDay(day: string, deltaDays: number): string { ).padStart(2, '0')}`; } +/** + * The daily office window `[openHour, closeHour)` in EAT: after `closeHour` the + * booking desk is shut and reopens `openHour` the next morning. `openHour === + * closeHour` means a 24-hour desk that never breaks for the day. + */ +export interface OfficeHours { + windowOpenHour: number; + windowCloseHour: number; +} + +/** True when the desk runs round the clock (open hour equals close hour). */ +export function isRoundTheClock(hours: OfficeHours): boolean { + return hours.windowOpenHour === hours.windowCloseHour; +} + +/** + * Where the NEXT booking cycle opens after a cycle closes at `closedAt`, given a + * not-yet-full train and a daily office window. `earliestNextOpen` is the raw + * ready time (close + doc-review + payment); the desk honours it only while + * inside office hours: + * + * • round-the-clock desk → opens at `earliestNextOpen` (no day break) + * • ready time before closeHour → opens at `earliestNextOpen`, same day + * • ready time at/after closeHour → desk shut; opens next morning at openHour + * + * Returns `null` when the next open would fall on/after `departure` — the train + * leaves before another cycle could run, so the window is done. + */ +export function nextCycleOpensAt( + earliestNextOpen: Date, + hours: OfficeHours, + departure: Date, +): Date | null { + let opensAt: Date; + if (isRoundTheClock(hours)) { + opensAt = earliestNextOpen; + } else { + const { hour, minute } = eatParts(earliestNextOpen); + const readyMinutes = hour * 60 + minute; + const openMinutes = hours.windowOpenHour * 60; + const closeMinutes = hours.windowCloseHour * 60; + if (readyMinutes < openMinutes) { + // Desk not open yet today (ready before opening) → open this morning. + opensAt = eatDayToUtc(eatDay(earliestNextOpen), hours.windowOpenHour); + } else if (readyMinutes < closeMinutes) { + // Inside office hours → open as soon as ready. + opensAt = earliestNextOpen; + } else { + // Desk shut for the day → open tomorrow morning. + const tomorrow = shiftEatDay(eatDay(earliestNextOpen), 1); + opensAt = eatDayToUtc(tomorrow, hours.windowOpenHour); + } + } + return opensAt.getTime() < departure.getTime() ? opensAt : null; +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; @@ -269,7 +325,10 @@ export interface BoardWindow extends BatchWindow { export interface BoardWindowConfig { importWindowLeadDays: number; windowOpenHour: number; + /** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */ + windowCloseHour: number; windowDurationHours: number; + /** Gap between a cycle's close and its reopen (doc review + payment minutes). */ reopenDelayMinutes: number; exportBookingLeadHours: number; } @@ -328,25 +387,27 @@ export function listConfigBookingWindows( const windows: BoardWindow[] = []; const durationMs = cfg.windowDurationHours * 3_600_000; + // Post-close gap before the next cycle opens (doc review + payment), subject + // to office hours below. const reopenMs = cfg.reopenDelayMinutes * 60_000; + const officeHours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - 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) { + let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); + // Reopen daily until the train departs; cap the projection so a tiny duration + // can't run away (spanning up to importWindowLeadDays of office days). + for (let cycle = 0; cycle < 200; cycle += 1) { if (opensAt.getTime() >= departure.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); if (closesAt.getTime() > departure.getTime()) closesAt = departure; windows.push(boardWindowFromInterval(opensAt, closesAt)); - const nextOpensAt = new Date(closesAt.getTime() + reopenMs); - if ( - nextOpensAt.getTime() >= departure.getTime() || - eatDay(nextOpensAt) !== eatDay(opensAt) - ) { - break; - } - opensAt = nextOpensAt; + const earliestNextOpen = new Date(closesAt.getTime() + reopenMs); + opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure); + if (opensAt == null) break; } // Degenerate config (no window before departure) — surface a single window diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index f112d16d7..4282e045a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => { importWindowLeadDays: 3, exportBookingLeadHours: 24, windowOpenHour: 8, + windowCloseHour: 17, windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, 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 0b239990b..e6af6c4a0 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 @@ -736,6 +736,7 @@ export class BookingBatchService implements OnModuleInit { }; const windowCfg = { windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), + windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), windowDurationHours: num( s.ruleWindowDurationHours, liveCfg.windowDurationHours, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index c694eed11..25d569171 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -7,8 +7,14 @@ export interface BookingWindowConfig { importWindowLeadDays: number; /** Hours before departure an export booking becomes acceptable (FCFS). */ exportBookingLeadHours: number; - /** Local (Africa/Addis_Ababa) hour at which the import window opens. */ + /** Local (Africa/Addis_Ababa) hour at which the import window opens each day. */ windowOpenHour: number; + /** + * Local (Africa/Addis_Ababa) hour the booking desk shuts for the day: once a + * cycle's reopen would fall at/after this hour, the window pauses and resumes + * next morning at windowOpenHour. Equal to windowOpenHour ⇒ 24-hour desk. + */ + windowCloseHour: number; windowDurationHours: number; /** Max staff document-review time after the window closes. */ docReviewMinutes: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index da235c562..87e410a61 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -11,7 +11,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { BookingBatchService } from './booking-batch.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; /** @@ -64,7 +64,9 @@ export class BookingWindowService implements OnModuleInit { ], }) ).filter( - (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', + // CLOSED_FOR_DAY is legacy (the daily desk now reopens via PRE_WINDOW): + // still pick those rows up so advanceImport can revive them next morning. + (s) => s.windowPhase != null && s.windowPhase !== 'DONE', ); for (const schedule of active) { @@ -185,6 +187,14 @@ export class BookingWindowService implements OnModuleInit { ): Promise { const { windowPhase, windowOpensAt, windowClosesAt } = schedule; + // Legacy rows parked at CLOSED_FOR_DAY predate the daily-desk reopen: revive + // them through the same not-full conclude path so they resume next morning + // (or finalize as DONE if no cycle fits before departure). + if (windowPhase === 'CLOSED_FOR_DAY') { + await this.concludeCycle(schedule, cfg, now); + return true; + } + if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { await this.setPhase(schedule, { windowPhase: 'OPEN', @@ -268,34 +278,46 @@ export class BookingWindowService implements OnModuleInit { return; } - const closesAt = schedule.windowClosesAt ?? now; - const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); - const nextOpensAt = reopenAt > now ? reopenAt : now; - let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); + // Doc review + payment have already run, so the desk is ready to reopen NOW — + // office hours decide whether that is this afternoon or tomorrow morning. Past + // the last cycle before departure, nextCycleOpensAt returns null and we finish. + const officeHours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + const nextOpensAt = nextCycleOpensAt( + now, + officeHours, + schedule.scheduledDepartureDate, + ); + if (nextOpensAt == null) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + this.logger.log( + `Schedule ${schedule.id} not full but no cycle fits before departure — window done`, + ); + return; + } + + let nextClosesAt = new Date( + nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000, + ); if (nextClosesAt > schedule.scheduledDepartureDate) { nextClosesAt = schedule.scheduledDepartureDate; } - - const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); - const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; - if (sameBookingDay && beforeDeparture) { - await this.setPhase(schedule, { - windowPhase: 'PRE_WINDOW', - windowOpensAt: nextOpensAt, - windowClosesAt: nextClosesAt, - docReviewCompletedAt: null, - docReviewEndsAt: null, - paymentPhaseEndsAt: null, - }); - this.logger.log( - `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, - ); - } else { - await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); - this.logger.log( - `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, - ); - } + // Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt, + // whether that is later today or next morning after the office-hours break. + await this.setPhase(schedule, { + windowPhase: 'PRE_WINDOW', + windowOpensAt: nextOpensAt, + windowClosesAt: nextClosesAt, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + const sameDay = eatDay(nextOpensAt) === eatDay(now); + this.logger.log( + `Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, + ); } private async tryAutoFinalize(scheduleId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 2e82feb6a..0e252240b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -52,7 +52,7 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) exportBookingLeadHours?: number; - @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' }) + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens each day' }) @IsOptional() @Type(() => Number) @IsInt() @@ -60,6 +60,18 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Max(23) windowOpenHour?: number; + @ApiPropertyOptional({ + example: 17, + description: + 'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowCloseHour?: number; + // Stored in hours. The UI enters this in minutes/hours/days and converts to // hours before sending, so the floor is 1 minute (0.0166h) — not 15 min. @ApiPropertyOptional({ example: 3 }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 1a67bb791..ffa42fd7e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -54,6 +54,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'window_open_hour', type: 'int', default: 8 }) windowOpenHour!: number; + /** + * Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full + * train whose next cycle would reopen at/after this hour pauses until the next + * morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk. + */ + @Column({ name: 'window_close_hour', type: 'int', default: 17 }) + windowCloseHour!: number; + // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) // are exact. See WidenWindowDurationHoursPrecision migration. @Column({ 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 265f9e676..6be4cd966 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 @@ -124,6 +124,24 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** + * The booking-window rule fields frozen onto a train schedule at creation (and + * refreshed by restampPendingWindows for not-yet-open schedules). The board draws + * its display cycles from this snapshot, so a later global-rules edit never redraws + * an already-open schedule's windows. The reopen gap is derived here — doc review + + * payment — because that is the real delay between a cycle closing and reopening. + */ +function windowRuleSnapshot(cfg: BookingWindowConfig) { + return { + ruleWindowOpenHour: cfg.windowOpenHour, + ruleWindowCloseHour: cfg.windowCloseHour, + ruleWindowDurationHours: cfg.windowDurationHours, + ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes, + ruleImportWindowLeadDays: cfg.importWindowLeadDays, + ruleExportBookingLeadHours: cfg.exportBookingLeadHours, + }; +} + export type BookingWagonAllocationStatus = | 'NOT_ATTEMPTED' | 'ASSIGNED' @@ -269,6 +287,7 @@ export class TrainSchedulingService { if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; + if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour; if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; @@ -280,7 +299,10 @@ export class TrainSchedulingService { const windowTimingChanged = dto.importWindowLeadDays != null || dto.windowOpenHour != null || + dto.windowCloseHour != null || dto.windowDurationHours != null || + dto.docReviewMinutes != null || + dto.paymentWindowMinutes != null || dto.exportBookingLeadHours != null; const saved = await this.dataSource @@ -329,11 +351,7 @@ export class TrainSchedulingService { 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, + ...windowRuleSnapshot(cfg), }); restamped += 1; } @@ -359,6 +377,7 @@ export class TrainSchedulingService { importWindowLeadDays: num(row?.importWindowLeadDays, 3), exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), windowOpenHour: num(row?.windowOpenHour, 8), + windowCloseHour: num(row?.windowCloseHour, 17), windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), @@ -503,13 +522,7 @@ export class TrainSchedulingService { // 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 ruleSnapshot = windowRuleSnapshot(windowCfg); const windowFields = direction === 'EXPORT' ? {