add script to seed freight contracts across flow variants

This commit is contained in:
Marshal
2026-07-05 18:06:24 +00:00
parent 57a504867b
commit 3e2299960a
19 changed files with 1057 additions and 115 deletions

View File

@@ -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();
});
});

View File

@@ -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). */

View File

@@ -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. */

View File

@@ -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:0017: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:0017: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.

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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<TrainSchedule> {
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