mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Add window close hour to booking desk configuration
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,9 +120,17 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
|
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
|
||||||
ruleWindowOpenHour?: number | null;
|
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 })
|
@Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true })
|
||||||
ruleWindowDurationHours?: number | null;
|
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 })
|
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
|
||||||
ruleReopenDelayMinutes?: number | null;
|
ruleReopenDelayMinutes?: number | null;
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ describe('batch-window.util', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('batch-window board windows (config-driven booking cycles)', () => {
|
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 = {
|
const cfg: BoardWindowConfig = {
|
||||||
importWindowLeadDays: 3,
|
importWindowLeadDays: 3,
|
||||||
windowOpenHour: 8,
|
windowOpenHour: 8,
|
||||||
|
windowCloseHour: 17,
|
||||||
windowDurationHours: 3,
|
windowDurationHours: 3,
|
||||||
reopenDelayMinutes: 90,
|
reopenDelayMinutes: 90,
|
||||||
exportBookingLeadHours: 24,
|
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');
|
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 departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
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.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
|
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[1].date).toBe('2026-06-05');
|
||||||
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('export: single FCFS window exportBookingLeadHours before departure', () => {
|
||||||
|
|||||||
@@ -137,6 +137,62 @@ export function shiftEatDay(day: string, deltaDays: number): string {
|
|||||||
).padStart(2, '0')}`;
|
).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 {
|
export interface InitialWindowTimes {
|
||||||
windowOpensAt: Date;
|
windowOpensAt: Date;
|
||||||
windowClosesAt: Date;
|
windowClosesAt: Date;
|
||||||
@@ -269,7 +325,10 @@ export interface BoardWindow extends BatchWindow {
|
|||||||
export interface BoardWindowConfig {
|
export interface BoardWindowConfig {
|
||||||
importWindowLeadDays: number;
|
importWindowLeadDays: number;
|
||||||
windowOpenHour: number;
|
windowOpenHour: number;
|
||||||
|
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
|
||||||
|
windowCloseHour: number;
|
||||||
windowDurationHours: number;
|
windowDurationHours: number;
|
||||||
|
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
|
||||||
reopenDelayMinutes: number;
|
reopenDelayMinutes: number;
|
||||||
exportBookingLeadHours: number;
|
exportBookingLeadHours: number;
|
||||||
}
|
}
|
||||||
@@ -328,25 +387,27 @@ export function listConfigBookingWindows(
|
|||||||
|
|
||||||
const windows: BoardWindow[] = [];
|
const windows: BoardWindow[] = [];
|
||||||
const durationMs = cfg.windowDurationHours * 3_600_000;
|
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 reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||||
|
const officeHours: OfficeHours = {
|
||||||
|
windowOpenHour: cfg.windowOpenHour,
|
||||||
|
windowCloseHour: cfg.windowCloseHour,
|
||||||
|
};
|
||||||
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||||
|
|
||||||
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
|
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||||
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
|
// Reopen daily until the train departs; cap the projection so a tiny duration
|
||||||
for (let cycle = 0; cycle < 12; cycle += 1) {
|
// 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;
|
if (opensAt.getTime() >= departure.getTime()) break;
|
||||||
let closesAt = new Date(opensAt.getTime() + durationMs);
|
let closesAt = new Date(opensAt.getTime() + durationMs);
|
||||||
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
||||||
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
||||||
|
|
||||||
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
|
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
|
||||||
if (
|
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
|
||||||
nextOpensAt.getTime() >= departure.getTime() ||
|
if (opensAt == null) break;
|
||||||
eatDay(nextOpensAt) !== eatDay(opensAt)
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
opensAt = nextOpensAt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Degenerate config (no window before departure) — surface a single window
|
// Degenerate config (no window before departure) — surface a single window
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
importWindowLeadDays: 3,
|
importWindowLeadDays: 3,
|
||||||
exportBookingLeadHours: 24,
|
exportBookingLeadHours: 24,
|
||||||
windowOpenHour: 8,
|
windowOpenHour: 8,
|
||||||
|
windowCloseHour: 17,
|
||||||
windowDurationHours: 3,
|
windowDurationHours: 3,
|
||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
|
|||||||
@@ -736,6 +736,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
const windowCfg = {
|
const windowCfg = {
|
||||||
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
|
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
|
||||||
|
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
|
||||||
windowDurationHours: num(
|
windowDurationHours: num(
|
||||||
s.ruleWindowDurationHours,
|
s.ruleWindowDurationHours,
|
||||||
liveCfg.windowDurationHours,
|
liveCfg.windowDurationHours,
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ export interface BookingWindowConfig {
|
|||||||
importWindowLeadDays: number;
|
importWindowLeadDays: number;
|
||||||
/** Hours before departure an export booking becomes acceptable (FCFS). */
|
/** Hours before departure an export booking becomes acceptable (FCFS). */
|
||||||
exportBookingLeadHours: number;
|
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;
|
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;
|
windowDurationHours: number;
|
||||||
/** Max staff document-review time after the window closes. */
|
/** Max staff document-review time after the window closes. */
|
||||||
docReviewMinutes: number;
|
docReviewMinutes: number;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
|||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
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';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +64,9 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
).filter(
|
).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) {
|
for (const schedule of active) {
|
||||||
@@ -185,6 +187,14 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { windowPhase, windowOpensAt, windowClosesAt } = schedule;
|
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) {
|
if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) {
|
||||||
await this.setPhase(schedule, {
|
await this.setPhase(schedule, {
|
||||||
windowPhase: 'OPEN',
|
windowPhase: 'OPEN',
|
||||||
@@ -268,34 +278,46 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const closesAt = schedule.windowClosesAt ?? now;
|
// Doc review + payment have already run, so the desk is ready to reopen NOW —
|
||||||
const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000);
|
// office hours decide whether that is this afternoon or tomorrow morning. Past
|
||||||
const nextOpensAt = reopenAt > now ? reopenAt : now;
|
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
||||||
let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
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) {
|
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||||
nextClosesAt = schedule.scheduledDepartureDate;
|
nextClosesAt = schedule.scheduledDepartureDate;
|
||||||
}
|
}
|
||||||
|
// Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
|
||||||
const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt);
|
// whether that is later today or next morning after the office-hours break.
|
||||||
const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate;
|
await this.setPhase(schedule, {
|
||||||
if (sameBookingDay && beforeDeparture) {
|
windowPhase: 'PRE_WINDOW',
|
||||||
await this.setPhase(schedule, {
|
windowOpensAt: nextOpensAt,
|
||||||
windowPhase: 'PRE_WINDOW',
|
windowClosesAt: nextClosesAt,
|
||||||
windowOpensAt: nextOpensAt,
|
docReviewCompletedAt: null,
|
||||||
windowClosesAt: nextClosesAt,
|
docReviewEndsAt: null,
|
||||||
docReviewCompletedAt: null,
|
paymentPhaseEndsAt: null,
|
||||||
docReviewEndsAt: null,
|
});
|
||||||
paymentPhaseEndsAt: null,
|
const sameDay = eatDay(nextOpensAt) === eatDay(now);
|
||||||
});
|
this.logger.log(
|
||||||
this.logger.log(
|
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||||
`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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryAutoFinalize(scheduleId: string): Promise<void> {
|
private async tryAutoFinalize(scheduleId: string): Promise<void> {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
exportBookingLeadHours?: number;
|
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()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -60,6 +60,18 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
|||||||
@Max(23)
|
@Max(23)
|
||||||
windowOpenHour?: number;
|
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
|
// 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.
|
// hours before sending, so the floor is 1 minute (0.0166h) — not 15 min.
|
||||||
@ApiPropertyOptional({ example: 3 })
|
@ApiPropertyOptional({ example: 3 })
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
|||||||
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
|
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
|
||||||
windowOpenHour!: number;
|
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)
|
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
|
||||||
// are exact. See WidenWindowDurationHoursPrecision migration.
|
// are exact. See WidenWindowDurationHoursPrecision migration.
|
||||||
@Column({
|
@Column({
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ import {
|
|||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
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 =
|
export type BookingWagonAllocationStatus =
|
||||||
| 'NOT_ATTEMPTED'
|
| 'NOT_ATTEMPTED'
|
||||||
| 'ASSIGNED'
|
| 'ASSIGNED'
|
||||||
@@ -269,6 +287,7 @@ export class TrainSchedulingService {
|
|||||||
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||||||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||||||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
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.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||||
@@ -280,7 +299,10 @@ export class TrainSchedulingService {
|
|||||||
const windowTimingChanged =
|
const windowTimingChanged =
|
||||||
dto.importWindowLeadDays != null ||
|
dto.importWindowLeadDays != null ||
|
||||||
dto.windowOpenHour != null ||
|
dto.windowOpenHour != null ||
|
||||||
|
dto.windowCloseHour != null ||
|
||||||
dto.windowDurationHours != null ||
|
dto.windowDurationHours != null ||
|
||||||
|
dto.docReviewMinutes != null ||
|
||||||
|
dto.paymentWindowMinutes != null ||
|
||||||
dto.exportBookingLeadHours != null;
|
dto.exportBookingLeadHours != null;
|
||||||
|
|
||||||
const saved = await this.dataSource
|
const saved = await this.dataSource
|
||||||
@@ -329,11 +351,7 @@ export class TrainSchedulingService {
|
|||||||
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,
|
...windowRuleSnapshot(cfg),
|
||||||
ruleWindowDurationHours: cfg.windowDurationHours,
|
|
||||||
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
|
|
||||||
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
|
||||||
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
|
||||||
});
|
});
|
||||||
restamped += 1;
|
restamped += 1;
|
||||||
}
|
}
|
||||||
@@ -359,6 +377,7 @@ export class TrainSchedulingService {
|
|||||||
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
||||||
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
||||||
windowOpenHour: num(row?.windowOpenHour, 8),
|
windowOpenHour: num(row?.windowOpenHour, 8),
|
||||||
|
windowCloseHour: num(row?.windowCloseHour, 17),
|
||||||
windowDurationHours: num(row?.windowDurationHours, 3),
|
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||||
@@ -503,13 +522,7 @@ export class TrainSchedulingService {
|
|||||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||||
// windows from it rather than the live config.
|
// windows from it rather than the live config.
|
||||||
const ruleSnapshot = {
|
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||||
ruleWindowOpenHour: windowCfg.windowOpenHour,
|
|
||||||
ruleWindowDurationHours: windowCfg.windowDurationHours,
|
|
||||||
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
|
|
||||||
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
|
|
||||||
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
|
|
||||||
};
|
|
||||||
const windowFields =
|
const windowFields =
|
||||||
direction === 'EXPORT'
|
direction === 'EXPORT'
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user