mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
add script to seed freight contracts across flow variants
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user