From 93b8b69464c5147a1ba67bf76fceb376a8e6e16b Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 08:53:27 +0000 Subject: [PATCH 01/12] 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' ? { From dfdf7a025d8be082a45c5b27d17b6bdcd60dc15f Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 09:07:21 +0000 Subject: [PATCH 02/12] fix(freight): guard booking-desk hours against overnight ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the window-close-hour feature: - Reject windowCloseHour < windowOpenHour when saving global rules. The reopen engine (nextCycleOpensAt) assumes the daily desk runs within one EAT day; an overnight range would misroute a ready-time inside the span to the next morning. openHour === closeHour stays valid (24-hour desk). - Derive the board projection's runaway cap from the real first-open → departure span over the minimum per-cycle advance, so a legitimate long-lead config is never silently truncated (was a flat 200). - Document the open <= close precondition on nextCycleOpensAt and clarify the ready-before-open comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../batch-window.util.spec.ts | 9 +++++---- .../train-scheduling/batch-window.util.ts | 19 +++++++++++++++---- .../train-scheduling.service.ts | 11 +++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) 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 797421fcc..25ee84b8c 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 @@ -110,10 +110,11 @@ describe('batch-window board windows (config-driven booking cycles)', () => { (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(), - ); + // Cycles run continuously from the window day up to departure — the last one + // reaches departure, proving the runaway cap did not truncate the projection. + expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime()); + // Spans the full lead (window day 05 Jun → departure 08 Jun). + expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3); }); 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 a38d9e75f..442c21f95 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 @@ -164,6 +164,10 @@ export function isRoundTheClock(hours: OfficeHours): boolean { * * Returns `null` when the next open would fall on/after `departure` — the train * leaves before another cycle could run, so the window is done. + * + * Precondition: `windowCloseHour >= windowOpenHour` — the desk runs within a + * single EAT day and never wraps past midnight (enforced when global rules are + * saved). openHour === closeHour is the 24-hour desk, handled first. */ export function nextCycleOpensAt( earliestNextOpen: Date, @@ -179,7 +183,7 @@ export function nextCycleOpensAt( const openMinutes = hours.windowOpenHour * 60; const closeMinutes = hours.windowCloseHour * 60; if (readyMinutes < openMinutes) { - // Desk not open yet today (ready before opening) → open this morning. + // Ready before the desk opens on its own EAT calendar day → open this morning. opensAt = eatDayToUtc(eatDay(earliestNextOpen), hours.windowOpenHour); } else if (readyMinutes < closeMinutes) { // Inside office hours → open as soon as ready. @@ -397,9 +401,16 @@ export function listConfigBookingWindows( const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); 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) { + // The loop terminates naturally: every cycle advances opensAt by at least + // (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would + // reach departure. maxCycles is a derived runaway backstop sized to the real + // span (first open → departure) over the smallest possible advance, so a + // legitimate config is never silently truncated — only a pathological + // zero-length one would hit it. + const spanMs = departure.getTime() - opensAt.getTime(); + const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000); + const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2; + for (let cycle = 0; cycle < maxCycles; cycle += 1) { if (opensAt.getTime() >= departure.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); if (closesAt.getTime() > departure.getTime()) closesAt = departure; 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 6be4cd966..1f676f0f3 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 @@ -293,6 +293,17 @@ export class TrainSchedulingService { if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; + // The daily booking desk runs [openHour, closeHour) within one EAT day, so + // the desk must not wrap past midnight. openHour === closeHour is the 24-hour + // desk; openHour > closeHour (an overnight range) is rejected — the reopen + // engine has no notion of a window that spans midnight. + if (row.windowCloseHour < row.windowOpenHour) { + throw new BadRequestException( + `Window close hour (${row.windowCloseHour}) must be on or after the open hour ` + + `(${row.windowOpenHour}); set them equal for a 24-hour desk.`, + ); + } + // Fields that change the STAMPED open/close times of a schedule. docReview/ // payment/reopen are read live by the cron each tick, so they need no // re-stamp; only the four below feed computeImport/ExportWindowTimes. From 3447394e3214830749e1b529a3131be0a1df436d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 10:22:58 +0000 Subject: [PATCH 03/12] add bookings management and settings --- .../dto/update-schedule-window-rule.dto.ts | 62 ++ .../train-scheduling.controller.ts | 15 + .../train-scheduling.service.ts | 100 ++ .../BookingWindowSettingsModal.tsx | 409 ++++++++ .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 26 +- .../TrainScheduleV2ListPage.tsx | 18 + .../TrainSchedulingGlobalRulesPage.tsx | 16 +- .../backoffice/src/services/api.ts | 13 + .../src/services/trainScheduling.service.ts | 12 + .../backoffice/src/types/trainScheduling.ts | 25 + apps/edr-freight-web/portal/src/App.tsx | 16 +- .../src/pages/bookings/BookingsListPage.tsx | 912 ++++++++++++++++++ 13 files changed, 1617 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts new file mode 100644 index 000000000..9232de29e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts @@ -0,0 +1,62 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; + +/** + * Per-schedule booking-window rule override (staff action on the ops board). + * Every field is optional — only the ones sent are changed; the rest keep the + * schedule's existing snapshot. Mirrors the window fields of the global rules DTO. + */ +export class UpdateScheduleWindowRuleDto { + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @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; + + @ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0166) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ + example: 3, + description: 'Days before departure the booking window starts (re-derives the window start)', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 773e4738a..38ebd7be5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -42,6 +42,7 @@ import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; +import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingWindowService } from "./booking-window.service"; @@ -519,6 +520,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/window-rule") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", + }) + async updateScheduleWindowRule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleWindowRuleDto, + ) { + await this.trainSchedulingService.updateScheduleWindowRule(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingManage() @ApiOperation({ 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 1f676f0f3..877d58786 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 @@ -64,6 +64,7 @@ import { UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, @@ -331,6 +332,87 @@ export class TrainSchedulingService { return saved; } + /** + * Override the booking-window rule for ONE schedule (staff action on the ops + * board). Only the fields provided are changed; the rest keep the schedule's + * existing snapshot (falling back to the live global config for legacy rows). + * The window must not have opened yet — an OPEN/past schedule stays frozen so + * customers keep the times they were shown. windowOpensAt/ClosesAt are + * re-derived from the merged rule, and the snapshot is updated so the board + * draws the new cycles. + */ + async updateScheduleWindowRule( + id: string, + dto: UpdateScheduleWindowRuleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (schedule.windowPhase !== 'PRE_WINDOW') { + throw new BadRequestException( + 'Booking window settings can only be changed before the window opens ' + + `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`, + ); + } + const now = new Date(); + if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) { + throw new BadRequestException( + 'This schedule has already departed or has no departure date.', + ); + } + + // Merge the override onto the schedule's current effective rule (its snapshot, + // or the live config where a legacy row has no snapshot). + const liveCfg = await this.getWindowConfig(); + const merged: BookingWindowConfig = { + importWindowLeadDays: + dto.importWindowLeadDays ?? + schedule.ruleImportWindowLeadDays ?? + liveCfg.importWindowLeadDays, + exportBookingLeadHours: + schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, + windowOpenHour: + dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, + windowCloseHour: + dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour, + windowDurationHours: + dto.windowDurationHours ?? + (schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : liveCfg.windowDurationHours), + // The reopen gap is doc review + payment; keep the config values unless the + // override changes them, so the derived snapshot delay stays consistent. + docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, + paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + reopenDelayMinutes: liveCfg.reopenDelayMinutes, + }; + + if (merged.windowCloseHour < merged.windowOpenHour) { + throw new BadRequestException( + `Window close hour (${merged.windowCloseHour}) must be on or after the open hour ` + + `(${merged.windowOpenHour}); set them equal for a 24-hour desk.`, + ); + } + + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) + : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); + + await this.dataSource.getRepository(TrainSchedule).update(id, { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + ...windowRuleSnapshot(merged), + }); + this.logger.log( + `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure @@ -3677,6 +3759,8 @@ export class TrainSchedulingService { .flatMap((w) => w.allocations ?? []) .map((a) => a.id); + const windowCfg = await this.getWindowConfig(); + const [containerItems, bulkLoads] = await Promise.all([ allocationIds.length ? this.wagonAllocationContainerItemsRepository.findAll({ @@ -3723,6 +3807,22 @@ export class TrainSchedulingService { paymentPhaseEndsAt: schedule.paymentPhaseEndsAt ? schedule.paymentPhaseEndsAt.toISOString() : null, + // Per-schedule booking-window rule snapshot — powers the "Booking window + // settings" editor on the ops board (prefill + save one schedule's + // override). docReview/payment are not snapshotted per schedule (only their + // sum, as reopenDelayMinutes), so the editor prefills them from live config. + windowRule: { + windowOpenHour: schedule.ruleWindowOpenHour ?? null, + windowCloseHour: schedule.ruleWindowCloseHour ?? null, + windowDurationHours: + schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : null, + reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, + importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, + docReviewMinutes: windowCfg.docReviewMinutes, + paymentWindowMinutes: windowCfg.paymentWindowMinutes, + }, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } : null, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx new file mode 100644 index 000000000..e31ad6a20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -0,0 +1,409 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Button, + Divider, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Switch, + Text, + ThemeIcon, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import { Clock, Info, Moon, Sun } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import DurationField from "@/components/trainScheduling/DurationField"; +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling"; + +/** Fallbacks matching the API's global-rules defaults (used when a field is null). */ +const DEFAULTS = { + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + importWindowLeadDays: 3, +}; + +/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ +function hourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12}:00 ${period}`; +} + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ + value: String(h), + label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`, +})); + +interface FormState { + windowOpenHour: number; + windowCloseHour: number; + windowDurationHours: number | ""; + docReviewMinutes: number | ""; + paymentWindowMinutes: number | ""; + importWindowLeadDays: number | ""; +} + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +export interface BookingWindowSettingsModalProps { + scheduleId: string | null; + opened: boolean; + onClose: () => void; + /** Called after a successful save (e.g. to refetch a list). */ + onSaved?: () => void; +} + +/** + * Per-schedule booking-window settings editor. Prefills from the schedule's own + * rule snapshot, lets staff tune the daily desk hours / durations for just that + * train, and saves an override. Only editable before the window opens. + */ +export default function BookingWindowSettingsModal({ + scheduleId, + opened, + onClose, + onSaved, +}: BookingWindowSettingsModalProps) { + const { toast } = useToast(); + + const detailQuery = useQuery({ + ...api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "" }, + }), + enabled: opened && Boolean(scheduleId), + }); + const schedule = detailQuery.data; + + const save = useMutation( + api.trainScheduling.updateScheduleWindowRule.mutationOptions(), + ); + + const [form, setForm] = useState(null); + + // Seed the form from the schedule's snapshot once it loads (or when reopened). + useEffect(() => { + if (!opened || !schedule) return; + const r = schedule.windowRule; + setForm({ + windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour, + windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour, + windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours, + docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes, + paymentWindowMinutes: + r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, + importWindowLeadDays: + r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, + }); + }, [opened, schedule]); + + const isExport = schedule?.direction === "EXPORT"; + const canEdit = schedule?.windowPhase === "PRE_WINDOW"; + const is24h = + form != null && form.windowOpenHour === form.windowCloseHour; + const closeBeforeOpen = + form != null && form.windowCloseHour < form.windowOpenHour; + + const reopenSummary = useMemo(() => { + if (!form) return ""; + const doc = Number(form.docReviewMinutes) || 0; + const pay = Number(form.paymentWindowMinutes) || 0; + const total = doc + pay; + const h = Math.floor(total / 60); + const m = total % 60; + const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean); + return parts.length ? parts.join(" ") : "0m"; + }, [form]); + + const handleSave = async () => { + if (!scheduleId || !form) return; + // Numeric fields must hold real values. + const duration = Number(form.windowDurationHours); + const doc = Number(form.docReviewMinutes); + const pay = Number(form.paymentWindowMinutes); + const lead = Number(form.importWindowLeadDays); + if ( + form.windowDurationHours === "" || + form.docReviewMinutes === "" || + form.paymentWindowMinutes === "" || + form.importWindowLeadDays === "" || + !Number.isFinite(duration) || + !Number.isFinite(doc) || + !Number.isFinite(pay) || + !Number.isFinite(lead) + ) { + toast({ + title: "Fill every field before saving", + variant: "destructive", + }); + return; + } + if (closeBeforeOpen) { + toast({ + title: "Close hour must be on or after the open hour", + description: "Set them equal for a 24-hour desk.", + variant: "destructive", + }); + return; + } + + const payload: UpdateScheduleWindowRulePayload = { + windowOpenHour: form.windowOpenHour, + windowCloseHour: form.windowCloseHour, + windowDurationHours: duration, + docReviewMinutes: doc, + paymentWindowMinutes: pay, + importWindowLeadDays: lead, + }; + + try { + await save.mutateAsync({ id: scheduleId, payload }); + toast({ title: "Booking window settings updated" }); + onSaved?.(); + onClose(); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update booking window"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Booking window settings + + + {schedule?.route?.name ?? "This schedule only"} + + + + } + > + {detailQuery.isLoading || !form ? ( + + + + ) : !canEdit ? ( + } + title="Window already open" + > + Booking window settings can only be changed before the window opens. + This schedule is currently{" "} + {String(schedule?.windowPhase ?? "not window-managed")}. + + ) : ( + + {isExport ? ( + }> + Export schedules use a single FCFS lead window — the daily desk + hours below don't apply, only the lead time does. + + ) : null} + + {/* ── Daily desk hours ─────────────────────────────────────────── */} + + + + Daily desk hours (EAT) + + {is24h ? ( + } + > + 24-hour desk + + ) : ( + } + > + {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} + + )} + + + + v != null && + setForm((f) => f && { ...f, windowCloseHour: Number(v) }) + } + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + error={closeBeforeOpen ? "Must be ≥ open hour" : undefined} + disabled={isExport} + /> + + + setForm((f) => { + if (!f) return f; + // On → close == open (24h desk). Off → restore a normal ~9h + // day, always kept ≥ open hour so it never lands invalid. + const close = e.currentTarget.checked + ? f.windowOpenHour + : Math.min(23, f.windowOpenHour + 9); + return { ...f, windowCloseHour: close }; + }) + } + /> + {!isExport ? ( + + A not-yet-full train pauses at the close hour and resumes the next + morning at the open hour, every day until it fills or departs. + + ) : null} + + + + + {/* ── Cycle timing ─────────────────────────────────────────────── */} + + + Cycle timing + + + + setForm((f) => f && { ...f, windowDurationHours: v }) + } + min={0.0166} + disabled={isExport} + /> + + + setForm((f) => f && { ...f, docReviewMinutes: v }) + } + min={0} + disabled={isExport} + /> + + setForm((f) => f && { ...f, paymentWindowMinutes: v }) + } + min={1} + disabled={isExport} + /> + + {!isExport ? ( + + Reopen gap after each cycle = document review + payment ={" "} + {reopenSummary}. + + ) : null} + + + + + + {/* ── Lead time ────────────────────────────────────────────────── */} + + setForm( + (f) => + f && { + ...f, + importWindowLeadDays: v === "" ? "" : Number(v), + }, + ) + } + min={0} + clampBehavior="none" + allowDecimal={false} + /> + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8112b4299..e1199b4ed 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -281,6 +281,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`, + WINDOW_RULE: (id: string) => + `/train-scheduling/schedules/${id}/window-rule`, CONTRACT_BOOKING_WINDOWS: (contractId: string) => `/train-scheduling/contracts/${contractId}/booking-windows`, MARK_BOOKING_PAID: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 958049a63..1c7110a5e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -20,6 +20,7 @@ import { ArrowLeft, CalendarClock, CheckCircle2, + Clock, Container as ContainerIcon, Eye, FileText, @@ -47,7 +48,8 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; -import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; +import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; +// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; @@ -95,6 +97,7 @@ export default function TrainScheduleV2DetailPage() { const [previewResult, setPreviewResult] = useState(null); const [containerPlacements, setContainerPlacements] = useState([]); const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [windowSettingsOpen, setWindowSettingsOpen] = useState(false); const [gatepassSecuredAt, setGatepassSecuredAt] = useState(""); const [gatepassReference, setGatepassReference] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState(""); @@ -886,6 +889,18 @@ export default function TrainScheduleV2DetailPage() { Track train ) : null} + {schedule.windowPhase === "PRE_WINDOW" ? ( + + ) : null} {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + ); + } + // CHANGES_REQUESTED + clearance/operation steps are handled in place by a + // modal (update & resubmit, upload clearance docs, schedule & proceed). + if (bookingHasInlineAction(booking)) { + return ; + } + const payableStatus = isGeneralContract + ? "FULLY_EXECUTED" + : "SELECTED_FOR_BATCH"; + if (status === payableStatus && booking.paymentStatus !== "PAID") { + return ; + } + return ( + + ); +} + +function ColHeader({ label }: { label: string }) { + return ( + + {label} + + ); +} + +const hMeta = { headerClassName: "bg-[#F4F7FA]" }; + +function fmtDate(iso?: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +// Lightweight count query for a single lifecycle filter (reads only `total`). +function useStatusCount(statuses: string | undefined): number | undefined { + const { data } = useQuery( + api.bookings.list.queryOptions({ + input: { statuses, page: 1, pageSize: 1 }, + staleTime: 30_000, + }), + ); + return data?.meta?.total; +} + +function StatCard({ + card, + active, + count, + onSelect, +}: { + card: (typeof STAT_CARDS)[number]; + active: boolean; + count: number | undefined; + onSelect: () => void; +}) { + const Icon = card.icon; + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + p="md" + radius="lg" + withBorder + style={{ + cursor: "pointer", + transition: "box-shadow 140ms ease, border-color 140ms ease", + borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)", + boxShadow: active ? "0 0 0 1px #F2A516" : "none", + }} + > + + + + + + + {count ?? "—"} + + + {card.label} + + + + + ); +} + +export default function BookingsListPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [statusFilter, setStatusFilter] = useState("all"); + const [query, setQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState(null); + const [freightFilter, setFreightFilter] = useState(null); + const [sort, setSort] = useState("createdAt:DESC"); + const [createdFrom, setCreatedFrom] = useState(""); + const [createdTo, setCreatedTo] = useState(""); + const [trackingBooking, setTrackingBooking] = + useState(null); + + const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; + const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"]; + + const resetPage = () => + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + + const selectFilter = (key: StatusFilterKey) => { + setStatusFilter(key); + resetPage(); + }; + + const hasExtraFilters = + !!typeFilter || !!freightFilter || !!createdFrom || !!createdTo; + const clearExtraFilters = () => { + setTypeFilter(null); + setFreightFilter(null); + setCreatedFrom(""); + setCreatedTo(""); + resetPage(); + }; + + const filter: BookingListFilter = useMemo( + () => ({ + statuses, + bookingType: typeFilter ?? undefined, + freightType: freightFilter ?? undefined, + createdFrom: createdFrom || undefined, + // include the whole selected end day + createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + sortBy, + sortOrder, + }), + [ + statuses, + typeFilter, + freightFilter, + createdFrom, + createdTo, + pagination.pageIndex, + pagination.pageSize, + sortBy, + sortOrder, + ], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + // Per-card lifecycle counts (one cheap query each, total-only). + const allCount = useStatusCount(undefined); + const activeCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "active")!.statuses, + ); + const paymentCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, + ); + const draftCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, + ); + const doneCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "done")!.statuses, + ); + const cardCounts: Record = { + all: allCount, + active: activeCount, + payment: paymentCount, + draft: draftCount, + done: doneCount, + transit: undefined, + closed: undefined, + }; + + const allItems = data?.items ?? []; + const total = data?.meta?.total ?? allItems.length; + + // Server handles status + pagination; reference search is applied on the page. + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return allItems; + return allItems.filter((b) => + [b.reference, b.originYard?.label, b.destinationYard?.label] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)), + ); + }, [allItems, query]); + + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const showEmpty = !isLoading && !isError && rows.length === 0; + + const columns: ColumnDef[] = [ + { + id: "booking", + size: 244, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const b = row.original; + const cargoLabel = + b.freightType === "BULK" ? "Bulk cargo" : "Container"; + return ( + + + + + + + {b.reference} + + + {cargoLabel} + + + + ); + }, + }, + { + id: "type", + size: 150, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "cargo", + size: 168, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "route", + size: 196, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const b = row.original; + const origin = b.originYard?.label ?? b.originYard?.code ?? "—"; + const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—"; + const sub = fmtDate(b.scheduledDate ?? b.createdAt); + return ( + + + {origin} → {dest} + + {sub && ( + + {sub} + + )} + + ); + }, + }, + { + id: "payment", + size: 130, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "scheduling", + size: 140, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "status", + size: 190, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "amount", + size: 140, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const b = row.original as Freight.IBooking & { + totalAmount?: number; + amount?: number; + }; + const amount = b.totalAmount ?? b.amount ?? null; + if (!amount) { + return ( + + — + + ); + } + return ( + + ETB {amount.toLocaleString()} + + ); + }, + }, + { + id: "actions", + meta: hMeta, + header: () => null, + cell: ({ row }) => { + const booking = row.original; + const trackable = TRACKABLE_STATUSES.has(booking.status); + return ( + e.stopPropagation()} + > + {trackable && ( + + )} + + + + + + + + + navigate(`/bookings/${booking.id}`)}> + View details + + {trackable && ( + } + onClick={() => setTrackingBooking(booking)} + > + Track shipment + + )} + + + + ); + }, + }, + ]; + + return ( + + + {/* ── Page header ─────────────────────────────────────────────── */} + + + + + Bookings + + + + Track every cargo booking — from draft to delivery. + + + + + + {/* ── Summary stat cards ──────────────────────────────────────── */} + + {STAT_CARDS.map((card) => ( + selectFilter(card.key)} + /> + ))} + + + {/* ── Bookings table card ──────────────────────────────────────── */} + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + radius="md" + style={{ flex: 1, minWidth: 200, maxWidth: 340 }} + /> + { + setTypeFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 170 }} + aria-label="Filter by booking type" + /> + ({ + value: o.value, + label: o.label, + }))} + value={sort} + onChange={(v) => { + setSort(v ?? "createdAt:DESC"); + resetPage(); + }} + allowDeselect={false} + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 160 }} + aria-label="Sort bookings" + /> + { + setCreatedFrom(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 150 }} + aria-label="Created from" + placeholder="From" + /> + { + setCreatedTo(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 150 }} + aria-label="Created to" + placeholder="To" + /> + {hasExtraFilters && ( + + )} + + + {total} booking{total !== 1 ? "s" : ""} + + + + {showEmpty ? ( + + + + + + {query + ? "No bookings match your search" + : "No bookings here yet"} + + + {query + ? "Try a different reference or clear the search." + : "Bookings are created against a contract. Open a contract to book a shipment."} + + {!query && ( + + )} + + ) : ( + + navigate(`/bookings/${(row as Freight.IBooking).id}`) + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none rounded-none" + footer={DataTableFooter} + /> + )} + + + + setTrackingBooking(null)} + bookingId={trackingBooking?.id ?? ""} + bookingReference={trackingBooking?.reference ?? ""} + originLabel={ + trackingBooking?.originYard?.label ?? + trackingBooking?.originYard?.code + } + destinationLabel={ + trackingBooking?.destinationYard?.label ?? + trackingBooking?.destinationYard?.code + } + /> + + ); +} From 57a504867b470db75f361dcebb53f7d1e1b3a769 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 16:41:11 +0000 Subject: [PATCH 04/12] add contract reference to bookings list and enhance search functionality --- apps/edr-freight-api/py/waafi.jpeg | Bin 0 -> 15518 bytes .../src/pages/bookings/BookingsListPage.tsx | 47 ++++++++++++++++-- packages/types/src/freight/index.ts | 2 + 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/py/waafi.jpeg diff --git a/apps/edr-freight-api/py/waafi.jpeg b/apps/edr-freight-api/py/waafi.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..392de36cc200cb347bf3c4689698d42e00230a5c GIT binary patch literal 15518 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 { const q = query.trim().toLowerCase(); if (!q) return allItems; return allItems.filter((b) => - [b.reference, b.originYard?.label, b.destinationYard?.label] + [ + b.reference, + b.contractReference, + b.originYard?.label, + b.destinationYard?.label, + ] .filter(Boolean) .some((v) => String(v).toLowerCase().includes(q)), ); @@ -506,6 +512,41 @@ export default function BookingsListPage() { ); }, }, + { + id: "contract", + size: 150, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const ref = row.original.contractReference; + const cid = row.original.contractId; + if (!ref) { + return ( + + — + + ); + } + return ( + { + e.stopPropagation(); + navigate(`/contracts/${cid}`); + } + : undefined + } + > + {ref} + + ); + }, + }, { id: "type", size: 150, @@ -708,7 +749,7 @@ export default function BookingsListPage() { > } value={query} onChange={(e) => setQuery(e.currentTarget.value)} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 705439904..f80f01e23 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -388,6 +388,8 @@ export interface IBooking extends BaseEntity { customerId: string; /** The contract this booking was created under (Path A / Path B). */ contractId?: string | null; + /** Human-readable reference of the parent contract, joined onto list rows. */ + contractReference?: string | null; trainId?: string | null; status: BookingStatus; /** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */ From 3e2299960a1b2dd8562cca62f9a41217169f34c4 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 18:06:24 +0000 Subject: [PATCH 05/12] add script to seed freight contracts across flow variants --- apps/edr-freight-api/py/README.md | 67 +++ .../create_contracts.cpython-312.pyc | Bin 0 -> 22013 bytes apps/edr-freight-api/py/create_contracts.py | 479 ++++++++++++++++++ apps/edr-freight-api/py/requirements.txt | 3 + .../modules/bookings/clearance.util.spec.ts | 9 +- .../src/modules/bookings/clearance.util.ts | 7 +- .../contracts/contract-clearance.util.ts | 6 +- .../batch-window.util.spec.ts | 67 +++ .../train-scheduling/batch-window.util.ts | 97 ++-- .../dto/update-schedule-date.dto.ts | 16 + .../train-scheduling.controller.ts | 15 + .../train-scheduling.service.ts | 88 ++++ .../src/seed/file-upload-settings.seeder.ts | 28 + .../trainScheduling/EditScheduleDateModal.tsx | 141 ++++++ .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 103 +--- .../TrainScheduleV2ListPage.tsx | 21 + .../backoffice/src/services/api.ts | 12 + .../src/services/trainScheduling.service.ts | 11 + 19 files changed, 1057 insertions(+), 115 deletions(-) create mode 100644 apps/edr-freight-api/py/README.md create mode 100644 apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc create mode 100644 apps/edr-freight-api/py/create_contracts.py create mode 100644 apps/edr-freight-api/py/requirements.txt create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx diff --git a/apps/edr-freight-api/py/README.md b/apps/edr-freight-api/py/README.md new file mode 100644 index 000000000..561b35fbc --- /dev/null +++ b/apps/edr-freight-api/py/README.md @@ -0,0 +1,67 @@ +# Contract seed driver + +Creates freight contracts across every flow variant by driving the freight API +over HTTP end-to-end — from DRAFT through **both signatures** (customer sign + +staff counter-sign). It stops right after the staff counter-sign; no clearance +or booking steps are run. + +## What it builds + +20 real flows (movement × kind × customs × freight), each created twice → **40 +contracts** on `all`. + +| Movement | Kind | Customs | Freight | Count | +| --- | --- | --- | --- | --- | +| intercity (DOMESTIC) | one-time / general | without only¹ | bulk / container | 4 | +| import (IMPORT) | one-time / general | with / without | bulk / container | 8 | +| export (EXPORT) | one-time / general | with / without | bulk / container | 8 | + +¹ intercity + customs is not a real combo — DOMESTIC has no clearance gate, so +the customs flag is ignored. Those four are skipped, leaving 20 (16 working + 4 +`with-customs + bulk`). + +The four `with-customs + bulk` flows are still built here: the known break is +downstream in clearance (customs output docs are container-only), which this +script does not reach, so all 20 reach a signed state. + +## Terminal status after both signatures (by dimension) + +- DOMESTIC one-time → `FULLY_EXECUTED` +- any GENERAL, and DOMESTIC general → `CONTRACT_ACTIVE` +- IMPORT/EXPORT one-time (customs or self-clearance) → `AWAITING_CLEARANCE_DOCUMENTS` + (fully signed; clearance not driven) + +## Setup + +```bash +cd apps/edr-freight-api/py +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # then fill it in +``` + +Fill `.env`: customer + admin IAM credentials (admin should be a **super_admin**), +`OTP_PHONE`, and the Postgres connection (used only to read the sign-OTP). + +## Run + +```bash +python create_contracts.py # all 20 flows +python create_contracts.py intercity # only DOMESTIC flows (4) +python create_contracts.py import # only IMPORT flows (8) +python create_contracts.py import export # IMPORT + EXPORT (16) +``` + +Filters are by movement: `intercity`, `import`, `export` (pass one or many); +no arg or `all` runs everything. + +## How auth + OTP work + +- **Login**: `POST /api/auth/login` with `{ email, password }` returns a JWT + (`token`), sent as `Authorization: Bearer `. MFA accounts are not + supported — the script errors out clearly if MFA is required. +- **Actors**: the customer token does create/submit/customer-sign; the admin + token does staff-accept/approve/generate/counter-sign. +- **Sign OTP**: customer sign needs a fresh 6-digit OTP. The script calls + `POST /api/otp/send { phone }`, reads the plaintext code from + `.otp_verifications` in Postgres, then signs within the 5-minute TTL. diff --git a/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc b/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c13fefb3cdd27a2f5eb6c8bbe635a03384324e7 GIT binary patch literal 22013 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 bxOAH51m}44wz2fCVEuM8_=r%(kn{fodcV|H literal 0 HcmV?d00001 diff --git a/apps/edr-freight-api/py/create_contracts.py b/apps/edr-freight-api/py/create_contracts.py new file mode 100644 index 000000000..086ecf993 --- /dev/null +++ b/apps/edr-freight-api/py/create_contracts.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Seed freight contracts across every flow variant, driven end-to-end over HTTP. + +For each flow the script logs in, creates a DRAFT contract, and pushes it through +the lifecycle up to and INCLUDING both signatures (customer sign + staff +counter-sign). It STOPS after the staff counter-sign — no clearance, no booking. + +Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built): + movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT) + kind : one-time(ONE_TIME) | general(GENERAL) + customs : without | with (customsClearingEnabled) + freight : bulk(BULK) | container(CONTAINER) + +intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which +removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose +break is downstream in clearance). Each is created twice → 40 contracts on `all`. + +CLI (filter by movement, pass one or many): + python create_contracts.py # all 20 flows + python create_contracts.py all # all 20 flows + python create_contracts.py intercity # only DOMESTIC flows + python create_contracts.py import # only IMPORT flows + python create_contracts.py import export # IMPORT + EXPORT flows + +Config comes from .env (see .env.example). Requires: requests, psycopg, +python-dotenv (see requirements.txt). +""" +from __future__ import annotations + +import base64 +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import psycopg +import requests +from dotenv import load_dotenv + +HERE = Path(__file__).resolve().parent +load_dotenv(HERE / ".env") + +# --------------------------------------------------------------------------- # +# Config +# --------------------------------------------------------------------------- # +API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/") + +CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "") +CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") + +# Phone the sign-OTP is sent to and read back from Postgres. Independent of the +# logged-in user — the sign endpoint keys the OTP purely on this number. +OTP_PHONE = os.getenv("OTP_PHONE", "") + +# DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications. +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "edr_dev") +DB_USER = os.getenv("DB_USER", "postgres") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") +DB_SCHEMA = os.getenv("DB_SCHEMA", "freight") + +WAAFI = HERE / "waafi.jpeg" + +VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365")) +CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2")) +REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60")) + + +# --------------------------------------------------------------------------- # +# Flow matrix — the 20 real flows +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Flow: + movement: str # intercity | import | export + trade_direction: str # DOMESTIC | IMPORT | EXPORT + kind: str # ONE_TIME | GENERAL + customs: bool # customsClearingEnabled + freight: str # BULK | CONTAINER + + @property + def label(self) -> str: + return ( + f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}" + f"+{'with' if self.customs else 'no'}-customs" + f"+{self.freight.lower()}" + ) + + +def build_flow_matrix() -> list[Flow]: + movements = [ + ("intercity", "DOMESTIC"), + ("import", "IMPORT"), + ("export", "EXPORT"), + ] + kinds = ["ONE_TIME", "GENERAL"] + freights = ["BULK", "CONTAINER"] + + flows: list[Flow] = [] + for movement, direction in movements: + # DOMESTIC ignores customs (no clearance gate) → customs=True is not a + # real combo. Only build without-customs for intercity. + customs_options = [False] if direction == "DOMESTIC" else [False, True] + for kind in kinds: + for customs in customs_options: + for freight in freights: + flows.append(Flow(movement, direction, kind, customs, freight)) + return flows + + +# --------------------------------------------------------------------------- # +# HTTP client +# --------------------------------------------------------------------------- # +class ApiError(RuntimeError): + def __init__(self, method: str, path: str, resp: requests.Response): + body = resp.text + try: + body = resp.json() + except Exception: + pass + super().__init__(f"{method} {path} -> {resp.status_code}: {body}") + self.status_code = resp.status_code + + +class Client: + """Thin wrapper that carries a bearer token.""" + + def __init__(self, name: str, token: str | None = None): + self.name = name + self.token = token + + def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + h: dict[str, str] = {} + if self.token: + h["Authorization"] = f"Bearer {self.token}" + if extra: + h.update(extra) + return h + + def get(self, path: str, params: dict | None = None) -> Any: + r = requests.get( + f"{API_URL}{path}", + headers=self._headers(), + params=params, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("GET", path, r) + return r.json() if r.content else None + + def post_json(self, path: str, body: dict | None = None) -> Any: + r = requests.post( + f"{API_URL}{path}", + headers=self._headers({"Content-Type": "application/json"}), + json=body or {}, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", path, r) + return r.json() if r.content else None + + def post_multipart( + self, path: str, data: dict[str, str], files: list[tuple] | None = None + ) -> Any: + r = requests.post( + f"{API_URL}{path}", + headers=self._headers(), # requests sets multipart Content-Type + data=data, + files=files or [], + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", path, r) + return r.json() if r.content else None + + +def login(email: str, password: str, who: str) -> Client: + r = requests.post( + f"{API_URL}/auth/login", + json={"email": email, "password": password}, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", "/auth/login", r) + payload = r.json() + if payload.get("mfaRequired"): + raise RuntimeError( + f"{who} login requires MFA — this script cannot complete an MFA login. " + "Disable MFA for the seed account or supply a non-MFA account." + ) + token = payload.get("token") + if not token: + raise RuntimeError(f"{who} login returned no token: {payload}") + return Client(who, token) + + +# --------------------------------------------------------------------------- # +# OTP — send + read from Postgres +# --------------------------------------------------------------------------- # +def send_otp(customer: Client, phone: str) -> None: + # POST /api/otp/send is @Public — no token needed, but sending one is harmless. + customer.post_json("/otp/send", {"phone": phone}) + + +def read_otp_from_db(phone: str) -> str: + """Read the freshest plaintext OTP for `phone` from freight.otp_verifications.""" + dsn = ( + f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} " + f"user={DB_USER} password={DB_PASSWORD}" + ) + with psycopg.connect(dsn) as conn: + with conn.cursor() as cur: + cur.execute( + f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications ' + "WHERE phone = %s ORDER BY updated_at DESC LIMIT 1", + (phone,), + ) + row = cur.fetchone() + if not row: + raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications") + return str(row[0]) + + +# --------------------------------------------------------------------------- # +# Reference-data lookups (yards / service types / cargo types) +# --------------------------------------------------------------------------- # +@dataclass +class RefData: + yards: list[dict] = field(default_factory=list) + service_types: list[dict] = field(default_factory=list) + cargo_types: list[dict] = field(default_factory=list) + + +def _as_items(resp: Any) -> list[dict]: + if isinstance(resp, list): + return resp + if isinstance(resp, dict): + return resp.get("items") or resp.get("data") or [] + return [] + + +def load_ref_data(client: Client) -> RefData: + ref = RefData( + yards=_as_items(client.get("/yards")), + service_types=_as_items(client.get("/service-types")), + cargo_types=_as_items(client.get("/cargo-types")), + ) + if len(ref.yards) < 2: + raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.") + if not ref.service_types: + raise RuntimeError("No service types found. Seed service types first.") + if not ref.cargo_types: + raise RuntimeError("No cargo types found. Seed cargo types first.") + return ref + + +def pick_service_type(ref: RefData, wants_customs: bool) -> str: + """Prefer a service type whose includesCustoms matches the flow's customs need.""" + for st in ref.service_types: + if bool(st.get("includesCustoms")) == wants_customs: + return st["id"] + # Fall back to any — customsClearingEnabled on the contract still drives the flow. + return ref.service_types[0]["id"] + + +# --------------------------------------------------------------------------- # +# Contract payload builder +# --------------------------------------------------------------------------- # +def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]: + """Return multipart form fields. Booleans as 'true'/'false' strings; nested + arrays as JSON strings (implicit conversion is off in the API).""" + import json + + origin = ref.yards[0]["id"] + destination = ref.yards[1]["id"] + service_type_id = pick_service_type(ref, flow.customs) + + # Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row. + if flow.freight == "CONTAINER": + cargo_scope = [{"containerSize": "20ft"}] + if flow.kind == "GENERAL": + cargo_scope[0]["quantityCap"] = 10 + else: # BULK + cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}] + if flow.kind == "GENERAL": + cargo_scope[0]["quantityCap"] = 1000 + + # Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine). + routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}] + + fields: dict[str, str] = { + "contractKind": flow.kind, + "tradeDirection": flow.trade_direction, + "freightType": flow.freight, + "serviceTypeId": service_type_id, + "paymentCurrency": "ETB", + "customsClearingEnabled": "true" if flow.customs else "false", + "contractType": "SPOT", + "cargoScope": json.dumps(cargo_scope), + "routes": json.dumps(routes), + } + if flow.customs: + fields["customsClearingAgent"] = "Seed Agent" + return fields + + +def signature_b64() -> str: + return base64.b64encode(WAAFI.read_bytes()).decode() + + +# --------------------------------------------------------------------------- # +# Lifecycle driver — create → submit → accept → approve → generate → sign x2 +# --------------------------------------------------------------------------- # +def waafi_file_tuple(field_name: str) -> tuple: + return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg")) + + +def drive_flow( + flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData +) -> dict[str, Any]: + result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None} + + # S1 — create (customer, multipart, waafi attached as intake doc) + fields = build_create_payload(flow, ref, idx) + contract = customer.post_multipart( + "/contracts", data=fields, files=[waafi_file_tuple("intake_document")] + ) + cid = contract["id"] + result["contractId"] = cid + result["reference"] = contract.get("reference") + + # S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm. + contract = customer.post_json(f"/contracts/{cid}/submit") + if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM": + contract = customer.post_json(f"/contracts/{cid}/confirm-submit") + + # S3 — staff accept (admin) → PENDING_APPROVAL + approval chain + admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS}) + + # S4 — approve every pending step IN ORDER with its exact requiredRole (admin) + approve_all_steps(admin, cid) + + # S5 — generate contract document (admin) → CONTRACT_READY + admin.post_json(f"/contracts/{cid}/contract/generate") + + # S6 — customer sign (needs OTP) → SIGNED_CUSTOMER + send_otp(customer, OTP_PHONE) + time.sleep(1.0) # let the OTP row land + otp = read_otp_from_db(OTP_PHONE) + customer.post_json( + f"/contracts/{cid}/contract/sign", + { + "role": "CUSTOMER", + "signatureImageBase64": signature_b64(), + "signerDisplayName": "Seed Customer", + "consentText": "I agree.", + "otp": otp, + "otpPhone": OTP_PHONE, + }, + ) + + # S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE / + # AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE. + signed = admin.post_json( + f"/contracts/{cid}/contract/sign", + { + "role": "STAFF", + "signatureImageBase64": signature_b64(), + "signerDisplayName": "Seed Staff", + "consentText": "Countersigned.", + }, + ) + result["status"] = (signed or {}).get("status") + return result + + +def approve_all_steps(admin: Client, cid: str) -> None: + """Read the contract, approve each PENDING approval step in order. Superadmin + can approve any role, but the endpoint still checks step.requiredRole == body, + so we echo the step's own requiredRole back.""" + guard = 0 + while True: + guard += 1 + if guard > 12: + raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}") + contract = admin.get(f"/contracts/{cid}") + steps = contract.get("approvalSteps") or [] + pending = [s for s in steps if s.get("status") == "PENDING"] + if not pending: + return + # findNextPendingApprovalStep orders by sequence; sort the same way. + pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0))) + step = pending[0] + admin.post_json( + f"/contracts/{cid}/approval-steps/{step['id']}/approve", + {"requiredRole": step["requiredRole"]}, + ) + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # +VALID_FILTERS = {"all", "intercity", "import", "export"} + + +def parse_filters(argv: list[str]) -> set[str]: + args = [a.lower() for a in argv[1:]] + if not args or "all" in args: + return {"intercity", "import", "export"} + unknown = set(args) - VALID_FILTERS + if unknown: + raise SystemExit( + f"Unknown filter(s): {', '.join(sorted(unknown))}. " + f"Valid: {', '.join(sorted(VALID_FILTERS))}" + ) + return set(args) + + +def require_config() -> None: + missing = [ + name + for name, val in [ + ("CUSTOMER_EMAIL", CUSTOMER_EMAIL), + ("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD), + ("ADMIN_EMAIL", ADMIN_EMAIL), + ("ADMIN_PASSWORD", ADMIN_PASSWORD), + ("OTP_PHONE", OTP_PHONE), + ] + if not val + ] + if missing: + raise SystemExit(f"Missing required .env keys: {', '.join(missing)}") + if not WAAFI.exists(): + raise SystemExit(f"Missing signature/upload image: {WAAFI}") + + +def main() -> None: + require_config() + wanted = parse_filters(sys.argv) + + flows = [f for f in build_flow_matrix() if f.movement in wanted] + total = len(flows) * CONTRACTS_PER_FLOW + print(f"API : {API_URL}") + print(f"Filters : {', '.join(sorted(wanted))}") + print(f"Flows : {len(flows)} x {CONTRACTS_PER_FLOW} = {total} contracts\n") + + print("Logging in...") + customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer") + admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin") + + print("Loading reference data...") + ref = load_ref_data(admin) + + results: list[dict] = [] + for flow in flows: + for n in range(1, CONTRACTS_PER_FLOW + 1): + tag = f"[{flow.label} #{n}]" + try: + res = drive_flow(flow, n, customer, admin, ref) + print(f" OK {tag} {res['reference']} -> {res['status']}") + results.append(res) + except Exception as exc: # noqa: BLE001 — report and continue + print(f" FAIL {tag} {exc}") + results.append({"flow": flow.label, "n": n, "error": str(exc)}) + + ok = [r for r in results if not r.get("error")] + bad = [r for r in results if r.get("error")] + print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.") + if bad: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/apps/edr-freight-api/py/requirements.txt b/apps/edr-freight-api/py/requirements.txt new file mode 100644 index 000000000..7dcde7140 --- /dev/null +++ b/apps/edr-freight-api/py/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31 +psycopg[binary]>=3.1 +python-dotenv>=1.0 diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index a7bd13c28..ac21b2dce 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -42,8 +42,13 @@ describe('clearance.util — clearanceOutputSettingCode', () => { expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull(); }); - it('returns null for bulk (no container output set) and domestic', () => { - expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull(); + it('resolves bulk output sets (mirrors container) and returns null for domestic', () => { + expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBe( + 'clearance_output_import_bulk', + ); + expect(clearanceOutputSettingCode('EXPORT', 'BULK', true)).toBe( + 'clearance_output_export_bulk', + ); expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 2e2865d8b..6d7b86c8f 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -33,7 +33,7 @@ export function clearanceSettingCode( return `clearance_${op}_${freight}_${customs}`; } -/** The GL-output (customs output) setting code; only container customs sets exist. */ +/** The GL-output (customs output) setting code, keyed on op + freight. */ export function clearanceOutputSettingCode( tradeDirection: string, freightType: string, @@ -42,9 +42,8 @@ export function clearanceOutputSettingCode( if (!includesCustoms) return null; const op = operationFor(tradeDirection); if (!op) return null; - // Only container customs output sets are seeded for this phase. - if (freightFor(freightType) !== 'container') return null; - return `clearance_output_${op}_container`; + const freight = freightFor(freightType); + return `clearance_output_${op}_${freight}`; } /** Convenience: resolve both codes for a loaded booking (with its serviceType). */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 9b108ce75..bc1b4ad49 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -45,7 +45,7 @@ export function contractClearanceSettingCode( return `contract_clearance_${op}_${freight}`; } -/** The GL-output (customs output) setting code; only container customs sets exist. */ +/** The GL-output (customs output) setting code, keyed on op + freight. */ export function contractClearanceOutputSettingCode( tradeDirection: string, freightType: string, @@ -54,8 +54,8 @@ export function contractClearanceOutputSettingCode( if (!includesCustoms) return null; const op = operationFor(tradeDirection); if (!op) return null; - if (freightFor(freightType) !== 'container') return null; - return `contract_clearance_output_${op}_container`; + const freight = freightFor(freightType); + return `contract_clearance_output_${op}_${freight}`; } /** Convenience: resolve both codes for a loaded contract. */ 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 25ee84b8c..bd6cd854f 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 @@ -5,6 +5,7 @@ import { BATCH_WINDOW_START_HOURS, listConfigBookingWindows, groupBookingsIntoBoardWindows, + computeImportWindowTimes, type BoardWindowConfig, } from './batch-window.util'; @@ -54,6 +55,72 @@ describe('batch-window.util', () => { }); }); +describe('computeImportWindowTimes — first-window open respects office hours', () => { + // Departs Mon 06 Jul 08:00 EAT (05:00 UTC). Lead 3 days → anchor 03 Jul 08:00 + // EAT (05:00 UTC). Bounded desk 08:00–17:00, 15h window. + const departure = new Date('2026-07-06T05:00:00.000Z'); + const bounded = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 15, + }; + + it('opens at the morning anchor when now is before the lead window', () => { + // Now = 02 Jul 06:00 EAT (before the 03 Jul anchor). + const now = new Date('2026-07-02T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + // Anchor: 03 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z'); + }); + + it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => { + // Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:00–17:00, + // anchor already passed → open immediately. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z'); + }); + + it('waits for next morning when now is after the desk closes', () => { + // Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure. + // Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT. + const lateDeparture = new Date('2026-07-06T11:00:00.000Z'); + const now = new Date('2026-07-05T15:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now); + // 06 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z'); + }); + + it('opens this morning when now is before the desk opens on a lead day', () => { + // Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today. + const now = new Date('2026-07-05T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z'); + }); + + it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => { + // Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC), + // deep night, past the anchor → open immediately. + const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 }; + const now = new Date('2026-07-05T00:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z'); + }); + + it('caps the close at departure', () => { + // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT, + // past the 06 Jul 08:00 departure → clamped to departure. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes( + departure, + { ...bounded, windowDurationHours: 24 }, + now, + ); + expect(windowClosesAt.toISOString()).toBe(departure.toISOString()); + }); +}); + describe('batch-window board windows (config-driven booking cycles)', () => { // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, // 3h long, reopen 90m later. 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 442c21f95..ec8b6826d 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 @@ -169,31 +169,44 @@ export function isRoundTheClock(hours: OfficeHours): boolean { * single EAT day and never wraps past midnight (enforced when global rules are * saved). openHour === closeHour is the 24-hour desk, handled first. */ +/** + * The EAT instant a booking cycle would open if it became ready at `readyAt`, + * honouring the daily office window but WITHOUT any departure bound: + * + * • round-the-clock desk → opens at `readyAt` (no day break) + * • ready before openHour → opens at openHour that EAT morning + * • ready inside office hours → opens at `readyAt` + * • ready at/after closeHour → opens at openHour the next morning + * + * `nextCycleOpensAt` layers the "before departure" gate on top of this; the first + * import window uses it directly and lets its own departure cap apply. + */ +export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date { + if (isRoundTheClock(hours)) { + return readyAt; + } + const { hour, minute } = eatParts(readyAt); + const readyMinutes = hour * 60 + minute; + const openMinutes = hours.windowOpenHour * 60; + const closeMinutes = hours.windowCloseHour * 60; + if (readyMinutes < openMinutes) { + // Ready before the desk opens on its own EAT calendar day → open this morning. + return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour); + } + if (readyMinutes < closeMinutes) { + // Inside office hours → open as soon as ready. + return readyAt; + } + // Desk shut for the day → open tomorrow morning. + return eatDayToUtc(shiftEatDay(eatDay(readyAt), 1), hours.windowOpenHour); +} + 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) { - // Ready before the desk opens on its own EAT calendar day → 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); - } - } + const opensAt = officeHoursOpen(earliestNextOpen, hours); return opensAt.getTime() < departure.getTime() ? opensAt : null; } @@ -203,27 +216,53 @@ export interface InitialWindowTimes { } /** - * Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus - * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its - * computed window has fully passed gets a same-day window starting now instead, - * capped at departure. + * Import booking-day window. The natural anchor is `windowOpenHour` EAT on + * departure-day minus `importWindowLeadDays`. When `now` is at/before that anchor + * (we're still before the lead window) the window opens at the anchor — the normal + * morning wait. + * + * Once `now` is PAST the anchor we're already inside the lead window, so the desk's + * office hours decide the open the same way a reopen cycle does (via + * `nextCycleOpensAt`): + * + * • 24-hour desk (open === close) → opens at `now`, any hour, day or night + * • `now` inside [openHour, closeHour) → opens at `now` (desk is open right now) + * • `now` before openHour that EAT day → opens at openHour that morning + * • `now` at/after closeHour → desk shut; opens openHour next morning + * + * `windowDurationHours` extends from that open, capped at departure. */ export function computeImportWindowTimes( departure: Date, cfg: { importWindowLeadDays: number; windowOpenHour: number; + windowCloseHour: number; windowDurationHours: number; }, now: Date, ): InitialWindowTimes { const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); - let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); - if (closesAt.getTime() <= now.getTime()) { - opensAt = now; - closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour); + + let opensAt: Date; + if (now.getTime() <= anchor.getTime()) { + // Before the lead window → normal morning wait at the anchor. + opensAt = anchor; + } else { + // Inside the lead window → the office-hours rule decides the open, exactly as a + // reopen cycle does: open now if the desk is open now (or round-the-clock), + // else at the next open hour. We use the same primitive as reopen cycles but + // WITHOUT its `< departure` null-gate — when the next open lands on/after + // departure the shared cap below clamps the (zero-length) window to departure, + // which is truthful, rather than masking it as "open now". + opensAt = officeHoursOpen(now, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); } + + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); if (closesAt.getTime() > departure.getTime()) { closesAt = departure; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts new file mode 100644 index 000000000..4792f7d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601 } from 'class-validator'; + +/** + * Reschedule a train's departure date (staff action on the ops board). Only + * allowed before the booking window opens; the new date must still leave room + * for the booking lead window before departure. + */ +export class UpdateScheduleDateDto { + @ApiProperty({ + example: '2026-07-20T05:00:00.000Z', + description: 'New scheduled departure date/time (ISO 8601)', + }) + @IsISO8601() + scheduleDate!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 38ebd7be5..770906320 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -43,6 +43,7 @@ import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; +import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingWindowService } from "./booking-window.service"; @@ -534,6 +535,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/schedule-date") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", + }) + async updateScheduleDate( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleDateDto, + ) { + await this.trainSchedulingService.updateScheduleDate(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingManage() @ApiOperation({ 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 877d58786..6f5fb5449 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 @@ -65,6 +65,7 @@ import { } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; +import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, @@ -413,6 +414,93 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Reschedule ONE train's departure date (staff action on the ops board). Only + * allowed while the booking window has not opened yet — an OPEN/past schedule + * stays frozen so customers keep the times they were shown. The new date must + * still leave room for the booking lead window before departure (same floor as + * schedule creation); INTERCITY/DOMESTIC uses the import lead. The window + * open/close times are re-derived from the schedule's existing rule snapshot. + */ + async updateScheduleDate( + id: string, + dto: UpdateScheduleDateDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (schedule.windowPhase !== 'PRE_WINDOW') { + throw new BadRequestException( + 'The departure date can only be changed before the booking window opens ' + + `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`, + ); + } + + const now = new Date(); + const departure = new Date(dto.scheduleDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days; + // EXPORT lead is in hours. Mirrors the create-schedule check. + const windowCfg = await this.getWindowConfig(); + const earliest = earliestSchedulableDeparture( + schedule.direction, + windowCfg, + now, + ); + if (departure.getTime() < earliest.getTime()) { + const detail = + schedule.direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()}).`, + ); + } + + // Re-derive the window from the schedule's own rule snapshot (falling back to + // the live config where a legacy row has no snapshot) against the new date. + const merged: BookingWindowConfig = { + importWindowLeadDays: + schedule.ruleImportWindowLeadDays ?? windowCfg.importWindowLeadDays, + exportBookingLeadHours: + schedule.ruleExportBookingLeadHours ?? windowCfg.exportBookingLeadHours, + windowOpenHour: schedule.ruleWindowOpenHour ?? windowCfg.windowOpenHour, + windowCloseHour: schedule.ruleWindowCloseHour ?? windowCfg.windowCloseHour, + windowDurationHours: + schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : windowCfg.windowDurationHours, + docReviewMinutes: windowCfg.docReviewMinutes, + paymentWindowMinutes: windowCfg.paymentWindowMinutes, + reopenDelayMinutes: windowCfg.reopenDelayMinutes, + }; + + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(departure, merged) + : computeImportWindowTimes(departure, merged, now); + + await this.dataSource.getRepository(TrainSchedule).update(id, { + scheduledDepartureDate: departure, + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }); + this.logger.log( + `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + + `(window reopens ${times.windowOpensAt.toISOString()})`, + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 3466e6653..e5e3bb139 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -350,6 +350,20 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ entity: CLEARANCE_ENTITY, fields: EXPORT_CONTAINER_OUTPUT_FIELDS, }, + // Bulk output sets mirror the container output docs so customs+bulk bookings + // can finalize (previously bulk had no output set and got stuck at finalize). + { + code: "clearance_output_import_bulk", + label: "Customs output documents (import bulk)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "clearance_output_export_bulk", + label: "Customs output documents (export bulk)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, ]; // ── Contract pre-booking clearance settings (Path B) ──────────────────────── @@ -398,6 +412,20 @@ const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ entity: CONTRACT_CLEARANCE_ENTITY, fields: EXPORT_CONTAINER_OUTPUT_FIELDS, }, + // Bulk output sets mirror the container output docs so customs+bulk contracts + // can finalize (previously bulk had no output set and got stuck at finalize). + { + code: "contract_clearance_output_import_bulk", + label: "Contract customs output documents (import bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "contract_clearance_output_export_bulk", + label: "Contract customs output documents (export bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, ]; // ── Path A self-clearance settings (no EDR customs service) ────────────────── diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx new file mode 100644 index 000000000..6ea7dfc5b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import { CalendarClock, Info } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +/** ISO → the `YYYY-MM-DDTHH:mm` value a datetime-local input expects (local time). */ +function toLocalInputValue(iso: string | null | undefined): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + +export interface EditScheduleDateModalProps { + scheduleId: string | null; + currentDate: string | null; + routeName?: string | null; + opened: boolean; + onClose: () => void; + /** Called after a successful save (e.g. to refetch a list). */ + onSaved?: () => void; +} + +/** + * Reschedule a train's departure date. Only shown for schedules whose booking + * window has not opened yet; the API rejects a date inside the booking lead + * window (import/intercity lead in days, export in hours). + */ +export default function EditScheduleDateModal({ + scheduleId, + currentDate, + routeName, + opened, + onClose, + onSaved, +}: EditScheduleDateModalProps) { + const { toast } = useToast(); + const save = useMutation( + api.trainScheduling.updateScheduleDate.mutationOptions(), + ); + + const [value, setValue] = useState(""); + + useEffect(() => { + if (opened) setValue(toLocalInputValue(currentDate)); + }, [opened, currentDate]); + + const handleSave = async () => { + if (!scheduleId || !value) { + toast({ title: "Pick a departure date", variant: "destructive" }); + return; + } + try { + await save.mutateAsync({ + id: scheduleId, + scheduleDate: new Date(value).toISOString(), + }); + toast({ title: "Departure date updated" }); + onSaved?.(); + onClose(); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update departure date"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Edit departure date + + + {routeName ?? "This schedule only"} + + + + } + > + + }> + The date can only be changed before the booking window opens, and must + still leave room for the booking lead window before departure. + + setValue(e.currentTarget.value)} + /> + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index e1199b4ed..03d1d2d7d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -283,6 +283,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/booking-window`, WINDOW_RULE: (id: string) => `/train-scheduling/schedules/${id}/window-rule`, + SCHEDULE_DATE: (id: string) => + `/train-scheduling/schedules/${id}/schedule-date`, CONTRACT_BOOKING_WINDOWS: (contractId: string) => `/train-scheduling/contracts/${contractId}/booking-windows`, MARK_BOOKING_PAID: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 1c7110a5e..efdb3d4d9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -128,6 +128,7 @@ export default function TrainScheduleV2DetailPage() { queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string), enabled: Boolean(scheduleId && gatepassApplies), }); + const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED"; const secureGatepass = useMutation({ mutationFn: () => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, { @@ -911,6 +912,31 @@ export default function TrainScheduleV2DetailPage() { Reschedule train ) : null} + {gatepassApplies ? ( + gatepassSecured ? ( + + ) : ( + + ) + ) : null} @@ -995,83 +1021,6 @@ export default function TrainScheduleV2DetailPage() { ) : null} - {gatepassApplies ? ( - - - - - - - - - - - Djibouti Port gate pass - - - {gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"} - - - - {schedule.direction === "IMPORT" - ? "Secure before dispatch from Djibouti." - : "Secure after dispatch before Djibouti Port entry / unloading."} - - - - {gatepassQuery.isLoading ? : null} - - - - setGatepassSecuredAt(event.currentTarget.value)} - /> - setGatepassReference(event.currentTarget.value)} - /> - setGatepassFileUrl(event.currentTarget.value)} - /> - -