add lashing surcharge for cargo types with hasLashing flag

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
Marshal
2026-07-17 23:25:53 +00:00
parent 6467173c76
commit 3a1a08b1e1
59 changed files with 1919 additions and 140 deletions

View File

@@ -266,6 +266,29 @@ export function clampCloseToOfficeHours(
return closesAt;
}
/**
* The instant a schedule stops accepting bookings. By default that is departure,
* but a configured close offset (import/export, minutes) pulls it earlier:
* `departure offset`. This is the single bound every window close, reopen
* cycle and export FCFS close is capped at — swap it in wherever the logic used
* to cap at departure. A non-positive/absent offset yields departure unchanged.
*/
export function bookingCloseCutoff(
departure: Date,
direction: string | null | undefined,
cfg: {
importCloseOffsetMinutes?: number | null;
exportCloseOffsetMinutes?: number | null;
},
): Date {
const offsetMinutes =
direction === 'EXPORT'
? cfg.exportCloseOffsetMinutes
: cfg.importCloseOffsetMinutes;
if (offsetMinutes == null || !(offsetMinutes > 0)) return departure;
return new Date(departure.getTime() - offsetMinutes * 60_000);
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
@@ -296,9 +319,13 @@ export function computeImportWindowTimes(
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number;
importCloseOffsetMinutes?: number | null;
},
now: Date,
): InitialWindowTimes {
// The window opens off the REAL departure (open day = departure leadDays),
// but shuts at the configured cutoff (departure closeOffset, or departure).
const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg);
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour);
@@ -324,8 +351,8 @@ export function computeImportWindowTimes(
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
if (closesAt.getTime() > cutoff.getTime()) {
closesAt = cutoff;
}
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
@@ -344,8 +371,12 @@ export function computeExportWindowTimes(
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
exportCloseOffsetMinutes?: number | null;
},
): InitialWindowTimes {
// Opens off the real departure (lead hours), shuts at the cutoff
// (departure closeOffset, or departure when no offset is set).
const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg);
const rawOpen = new Date(
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
);
@@ -353,10 +384,12 @@ export function computeExportWindowTimes(
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
// Open can't outlive the cutoff (a huge offset would otherwise leave a
// negative-length window); clamp to a zero-length window at the cutoff.
if (opensAt.getTime() > cutoff.getTime()) {
opensAt = cutoff;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
return { windowOpensAt: opensAt, windowClosesAt: cutoff };
}
/**
@@ -457,6 +490,10 @@ export interface BoardWindowConfig {
*/
reopenGapMinutes: number;
exportBookingLeadHours: number;
/** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */
importCloseOffsetMinutes?: number | null;
/** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */
exportCloseOffsetMinutes?: number | null;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
@@ -507,10 +544,15 @@ export function listConfigBookingWindows(
cfg: BoardWindowConfig,
anchorOpensAt?: Date | null,
): BoardWindow[] {
// Bookings shut at the cutoff (departure closeOffset), not departure. The
// window opens still key off the real departure below; only closes are capped
// here, so the board draws the exact windows the engine runs.
const cutoff = bookingCloseCutoff(departure, direction, cfg);
if (direction === 'EXPORT') {
const start =
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
return [boardWindowFromInterval(start, cutoff)];
}
const windows: BoardWindow[] = [];
@@ -527,29 +569,29 @@ export function listConfigBookingWindows(
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// 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
// reach the cutoff. maxCycles is a derived runaway backstop sized to the real
// span (first open → cutoff) 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 spanMs = cutoff.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;
if (opensAt.getTime() >= cutoff.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff);
if (opensAt == null) break;
}
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
// Degenerate config (no window before the cutoff) — surface a single window
// clamped to the cutoff so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff));
}
return windows;
}