Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts
marshal 75b75e3d4e feat: add contract extension request functionality
- Implemented  method in  to allow customers to request an extension for expired contracts.
- Added  component in  for users to initiate extension requests.
- Updated  to include logic for handling extension requests for expired contracts.
- Enhanced  to display extension request options and status.
- Created migration to add  and  columns to the contracts table.
- Added unit tests for contract extension request and handling in .
- Defined DTOs for request and extension in .
- Updated types in  to include new fields related to contract extensions.
2026-09-06 12:33:40 +00:00

1017 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AllocationLoadType } from '@edr/types';
import { Booking } from '../../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../../rule-engine/container-type.util';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagonFor,
bulkTonWagonsRequired,
consistViolations,
} from '../train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;
export const MAX_TEU_SLOTS_PER_WAGON = 2;
export type TrainLimitConfig = {
maxWeightTons?: number;
maxLengthMeters?: number;
maxWagonsPerTrain?: number;
max20ftPairWeightDiffTons?: number;
};
export type ContainerPlacementRules = {
/**
* Hard per-box weight ceiling keyed by booking container LINE id, resolved
* from the rule engine's weight limit rule (`max_capacity_tons`) for the
* line's container type and the booking's trade direction. A line with no
* entry has no ceiling — the rule's capacity is optional.
*/
maxContainerWeightTonsByLineId?: Record<string, number>;
max20ftPairWeightDiffTons?: number;
};
export type WagonAllocationRecord = {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
loadType: AllocationLoadType;
};
export type SlotLoadType = 'CONTAINER' | 'BULK';
export type WagonPlanSlot = {
sequenceNo: number;
wagonTypeId: string;
wagonTypeCode: string;
capacityTons: number;
lengthMeters: number;
/** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */
tareWeightTons: number;
/** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */
assignedWeightTons: number;
allocations: WagonAllocationRecord[];
slotLoadType?: SlotLoadType;
/**
* Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards
* at boardYardId and alights at alightYardId. Null = the schedule's own
* endpoint (whole-route slot, legacy behavior).
*/
boardYardId?: string | null;
alightYardId?: string | null;
};
export type ContainerUnitRow = {
bookingId: string;
bookingReference: string;
bookingContainerId: string;
unitIndex: number;
containerTypeId: string;
containerTypeCode: string;
label: string;
grossWeightTons: number;
sizeFt?: number;
containersPerWagon?: number;
teuSlots?: number;
containerNumber?: string | null;
};
export type ContainerPlacementInput = {
bookingContainerId: string;
unitIndex: number;
sequenceNo: number;
containerId?: string;
containerNumber?: string;
sealNumber?: string;
};
export function roundTons(value: number | string | null | undefined): number {
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
if (!Number.isFinite(numericValue)) return 0;
return Number(numericValue.toFixed(3));
}
/**
* Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill;
* a missing tare must read as 0 rather than silently inventing dead weight.
*/
export function tareTonsOf(wagonType: Pick<WagonType, 'tareWeightTons'>): number {
return roundTons(wagonType.tareWeightTons ?? 0);
}
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
export function teuSlotsForSizeFt(sizeFt: number): number {
return sizeFt >= 40 ? 2 : 1;
}
type ContainerLine = {
quantity?: number | null;
wagonsRequired?: number | null;
containerType?: { sizeFt?: number | null } | null;
};
/**
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
* wagons, not 4).
*/
function lineWagonsRaw(line: ContainerLine): number {
const qty = Number(line.quantity ?? 0);
if (qty <= 0) return 0;
const sizeFt = Number(line.containerType?.sizeFt);
if (Number.isFinite(sizeFt) && sizeFt > 0) {
return qty * wagonsPerUnitForSize(sizeFt);
}
// No size on the type: fall back to the line's stored fraction, else treat
// the whole line as one wagon.
const stored = Number(line.wagonsRequired);
return Number.isFinite(stored) && stored > 0 ? stored : 1;
}
/**
* Whole wagons a set of container lines needs: ceil the summed RAW fraction so a
* half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0.
*/
export function containerWagonsForLines(lines: ContainerLine[]): number {
const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0);
return raw > 0 ? Math.ceil(raw) : 0;
}
/**
* Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required.
*/
export function buildContainerWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
// Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit
// can share a wagon with another 20ft of the SAME booking, never across
// bookings), then sum. Ceiling per line instead would over-count a booking
// that splits its 20ft units across several lines.
const totalSlots = bookings.reduce((sum, booking) => {
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
return sum + Math.max(bookingSlots, 1);
}, 0);
const slots = Math.max(1, totalSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: Number(wagonType.capacityTons),
lengthMeters: Number(wagonType.lengthMeters),
tareWeightTons: tareTonsOf(wagonType),
assignedWeightTons: 0,
allocations: [],
}));
return allocateContainersToSlots(bookings, basePlan).map((slot) => ({
...slot,
slotLoadType: 'CONTAINER' as SlotLoadType,
}));
}
/**
* Build weight-based wagon plan for BULK bookings.
*/
export function buildBulkWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
const capacity = Number(wagonType.capacityTons);
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
// their tonnage must NOT pool with PER_TON cargo (an item can't split
// across wagons the way loose tonnage can).
const itemSlotsByBooking = bookings.map((b) =>
// The plan fixed THIS wagon type, so its configured items-fit binds — not
// the best fit across the cargo's allowed types.
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
);
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
// PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
// A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested),
// so it plans exactly the requested count.
const cappedTonSlotsByBooking = bookings.map((b, i) =>
itemSlotsByBooking[i] > 0 ||
bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity
? 0
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
);
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
// One bulk booking per wagon — bookings never pool tonnage on a shared
// wagon, so each uncapped booking sizes its own wagons (ceil per booking,
// not over the pooled total).
const tonSlots = bookings.reduce((sum, b, i) => {
if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum;
const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0));
return weight > 0 ? sum + Math.ceil(weight / capacity) : sum;
}, 0);
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: capacity,
lengthMeters: Number(wagonType.lengthMeters),
tareWeightTons: tareTonsOf(wagonType),
assignedWeightTons: 0,
allocations: [],
}));
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({
...slot,
slotLoadType: 'BULK' as SlotLoadType,
}));
}
/**
* Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers.
*/
export function buildMixedWagonPlan(
containerBookings: Booking[],
bulkBookings: Booking[],
containerWagonType: WagonType,
bulkWagonType: WagonType,
): WagonPlanSlot[] {
const containerPlan = containerBookings.length
? buildContainerWagonPlan(containerBookings, containerWagonType)
: [];
const bulkPlan = bulkBookings.length
? buildBulkWagonPlan(bulkBookings, bulkWagonType)
: [];
const tagged: WagonPlanSlot[] = [
...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })),
...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })),
];
if (!tagged.length) {
return [
{
sequenceNo: 1,
wagonTypeId: containerWagonType.id,
wagonTypeCode: containerWagonType.code,
capacityTons: Number(containerWagonType.capacityTons),
lengthMeters: Number(containerWagonType.lengthMeters),
tareWeightTons: tareTonsOf(containerWagonType),
assignedWeightTons: 0,
allocations: [],
slotLoadType: 'CONTAINER',
},
];
}
return tagged.map((slot, index) => ({
...slot,
sequenceNo: index + 1,
}));
}
export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] {
const rows: ContainerUnitRow[] = [];
for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) {
for (const line of booking.bookingContainers ?? []) {
const qty = Number(line.quantity ?? 0);
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
const perWagon = containersPerWagonForSize(sizeFt);
const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
// a legacy fallback — never invent numbers here.
const units = [...(line.units ?? [])].sort(
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
);
for (let i = 0; i < qty; i += 1) {
const unit = units[i];
rows.push({
bookingId: booking.id,
bookingReference: booking.reference,
bookingContainerId: line.id,
unitIndex: i,
containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt,
containersPerWagon: perWagon,
teuSlots,
containerNumber:
unit?.containerNumber?.trim() || line.containerNumber || null,
});
}
}
}
return rows;
}
export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] {
return wagonPlan
.filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some(
(a) => a.loadType === AllocationLoadType.Container,
))
.map((slot) => slot.sequenceNo);
}
function allocateBookingsToSlots(
bookings: Booking[],
basePlan: WagonPlanSlot[],
loadType: AllocationLoadType,
): WagonPlanSlot[] {
const remaining = bookings.map((booking) => ({
bookingId: booking.id,
bookingReference: booking.reference,
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
// bookings that column is an item COUNT, not tons.
remainingWeightTons: roundTons(bookingCargoTons(booking)),
cargoType: booking.cargoType,
booking,
}));
let bookingIndex = 0;
return basePlan.map((slot) => {
let wagonRemaining = roundTons(slot.capacityTons);
const allocations: WagonAllocationRecord[] = [];
let assignedWeightTons = 0;
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
// as the wagon count — the plan reserved a wagon per capped chunk, so
// pouring rated capacity into it would leave the last wagon empty. A
// NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷
// requested) for the same reason.
const takeCap = Math.min(
wagonRemaining,
bulkTonsPerWagonFor(
booking.booking,
booking.cargoType,
slot.wagonTypeId,
slot.capacityTons,
),
);
const allocatedWeightTons = roundTons(
Math.min(takeCap, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
bookingIndex += 1;
continue;
}
allocations.push({
bookingId: booking.bookingId,
bookingReference: booking.bookingReference,
allocatedWeightTons,
loadType,
});
booking.remainingWeightTons = roundTons(
booking.remainingWeightTons - allocatedWeightTons,
);
wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons);
assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons);
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
}
// One bulk booking per wagon: a wagon carrying bulk takes nothing else —
// never a second booking's cargo. `buildBulkWagonPlan` sized the slots
// per booking, so leftover room on this wagon is not free capacity.
// Close the wagon after its single allocation.
break;
}
return { ...slot, assignedWeightTons, allocations };
});
}
/**
* Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most
* 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU
* each) — a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container
* maps to a real wagon allocation, and this mirrors the frontend auto-fill packing
* exactly so a placement's sequenceNo always lands on a slot that holds an allocation
* for its booking.
*
* Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses
* several light containers into the first wagons by tonnage and leaves later container
* units without an allocation slot, which silently drops their container items on assign.
*/
function allocateContainersToSlots(
bookings: Booking[],
basePlan: WagonPlanSlot[],
): WagonPlanSlot[] {
const slots = basePlan.map((slot) => ({
...slot,
assignedWeightTons: 0,
allocations: [] as WagonAllocationRecord[],
}));
if (!slots.length) return slots;
const units = expandBookingContainerUnits(bookings);
let currentSlotIndex = 0;
let teuInCurrentSlot = 0;
for (const unit of units) {
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
// Move to the next wagon once this one can't fit the container's TEU. This keeps a
// 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft.
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) {
currentSlotIndex += 1;
teuInCurrentSlot = 0;
}
const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!;
let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId);
if (!allocation) {
allocation = {
bookingId: unit.bookingId,
bookingReference: unit.bookingReference,
allocatedWeightTons: 0,
loadType: AllocationLoadType.Container,
};
slot.allocations.push(allocation);
}
allocation.allocatedWeightTons = roundTons(
allocation.allocatedWeightTons + unit.grossWeightTons,
);
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons);
teuInCurrentSlot += teu;
}
return slots;
}
export function expandContainerItems(
booking: Booking,
allocationId: string,
): Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string;
grossWeightTons: number;
positionOnWagon: number | null;
}> {
const items: Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string;
grossWeightTons: number;
positionOnWagon: number | null;
}> = [];
for (const line of booking.bookingContainers ?? []) {
const qty = Number(line.quantity ?? 0);
for (let i = 0; i < qty; i += 1) {
items.push({
wagonBookingAllocationId: allocationId,
bookingContainerId: line.id,
containerTypeId: line.containerTypeId ?? '',
grossWeightTons: Number(line.vgmPerUnitTons),
positionOnWagon: qty > 1 ? i + 1 : null,
});
}
}
return items;
}
/**
* Wagons a booking actually occupies. Prefer counting the built wagon plan's
* slots that carry one of the booking's allocations — for BULK that is its
* tonnage spread over real wagons (a 700T booking on 70T wagons rides 10
* wagons, and downstream gross-weight math charges 10 tares, not 1). Without
* a plan there is no capacity to divide by, so fall back to the pre-plan
* estimates: 1 for bulk, the lines' stored counts for containers.
*/
export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]): number {
const occupiedSlots = (wagonPlan ?? []).filter((slot) =>
slot.allocations.some((allocation) => allocation.bookingId === booking.id),
).length;
if (occupiedSlots > 0) {
return occupiedSlots;
}
if (booking.freightType === 'BULK') {
return 1;
}
return (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
}
/**
* One wagon carries one kind of cargo AT A TIME: while a bulk load rides, the
* wagon holds nothing else — no container beside it and no second bulk
* booking. Container allocations may share a wagon with each other (TEU rules
* apply).
*
* "At a time" is the whole rule: a wagon whose cargo alights at Dire Dawa is
* empty steel for whatever boards there, so an import container on
* Doraleh→Dire and bulk on Dire→Kality legitimately share one wagon. Pass
* `legs` (booking id → stop-index span) to check per corridor edge; without
* it every allocation is treated as riding the whole route, which is the
* correct reading for a single-leg train.
*/
export function validateWagonCargoExclusivity(
wagonPlan: WagonPlanSlot[],
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
): string[] {
const violations: string[] = [];
const edges = Math.max(1, edgeCount);
const spanOf = (bookingId: string) => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const slot of wagonPlan) {
if (slot.allocations.length < 2) continue;
// Per edge: who is on this wagon while it rides that edge?
for (let edge = 0; edge < edges; edge += 1) {
const riding = slot.allocations.filter((a) => {
const span = spanOf(a.bookingId);
return span.from <= edge && edge < span.to;
});
if (riding.length < 2) continue;
if (riding.some((a) => a.loadType === AllocationLoadType.Bulk)) {
violations.push(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
);
break;
}
}
}
return violations;
}
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
const violations: string[] = [];
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
if (slot.assignedWeightTons > slot.capacityTons) {
violations.push(
`Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`,
);
}
}
return violations;
}
/**
* Check a consist against its train's three limits. Weight is GROSS — every slot
* contributes its own tare plus the cargo assigned to it — because the locomotive
* pull limit governs what it drags, not what was sold. Length and tare are summed
* per slot, so a mixed consist is measured as it actually stands rather than
* through one representative wagon type.
*
* `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain`
* is absent; slot dimensions always win over it.
*/
export function validateTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig,
/** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const wagonLength = Number(wagonType.lengthMeters) || 14;
const maxWagonSlots =
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
const violations = consistViolations(
wagonPlan.map((slot) => ({
lengthMeters: Number(slot.lengthMeters),
tareWeightTons: Number(slot.tareWeightTons ?? 0),
cargoTons: Number(slot.assignedWeightTons),
})),
{ maxWeightTons, maxLengthMeters, maxWagonSlots },
);
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount));
return violations;
}
/**
* Mixed consist: the wagon-count fallback uses the shortest type present, since
* that is the most wagons that could ever fit. Weight and length still come from
* the slots themselves.
*/
export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig,
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const minWagonLength = Math.min(
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
14,
);
const maxWagonsPerTrain =
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
return validateTrainLimits(
wagonPlan,
{ lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain },
legs,
edgeCount,
);
}
/**
* Leg-aware limit check: with a real stop list, a slot only counts on the
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
* own endpoint). Each edge is validated as its own consist, so an intercity
* wagon on Gelan→Adama never counts against a train that is full only on
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
*/
export function validateMixedTrainLimitsPerEdge(
wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits: TrainLimitConfig | undefined,
stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[],
/** Booking id → stop-index span, so cargo exclusivity is judged per edge. */
legs?: Map<string, { from: number; to: number }>,
): string[] {
const edges = Math.max(1, stops.length - 1);
if (stops.length <= 2) {
return validateMixedTrainLimits(wagonPlan, wagonTypes, limits, legs, edges);
}
const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
// A shared slot rides the UNION of its cargo legs, but only carries each
// booking's cargo on that booking's own edges — weigh the edge with the
// cargo actually aboard there, not the slot's whole-route scalar, or a
// container boarding at Dire Dawa reads as hauled from Djibouti.
const active = wagonPlan
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
.map((slot) => ({
...slot,
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
}));
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(
active,
wagonTypes,
limits,
legs,
edges,
)) {
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
}
}
return [...violations];
}
/**
* The slot fields per-edge usage math actually reads — lets callers feed
* persisted TrainSetWagon rows (or any structural subset), not only plan slots.
*/
export type EdgeUsageSlot = Pick<
WagonPlanSlot,
'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons'
> & {
boardYardId?: string | null;
alightYardId?: string | null;
allocations?: unknown[];
};
/**
* Cargo tons a slot actually carries on one edge. With a legs map and readable
* allocation records, each booking's cargo counts only on the edges that
* booking rides (an unmapped booking stays on the slot's whole span). Without
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
* `assignedWeightTons`, the pre-existing reading.
*/
function slotCargoOnEdge(
slot: EdgeUsageSlot,
edge: number,
edgeCount: number,
legs?: Map<string, { from: number; to: number }>,
): number {
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
const allocations = (slot.allocations ?? []) as Array<{
bookingId?: string;
allocatedWeightTons?: number | string;
}>;
if (!legs?.size || !allocations.length) return wholeSpanCargo;
let cargo = 0;
for (const allocation of allocations) {
const weight = Number(allocation?.allocatedWeightTons);
if (!Number.isFinite(weight)) return wholeSpanCargo;
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
const rides =
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
? true
: leg.from <= edge && edge < leg.to;
if (rides) cargo += weight;
}
return cargo;
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: EdgeUsageSlot[],
stops: string[],
): Array<{ from: number; to: number }> {
const lastIdx = stops.length - 1;
return wagonPlan.map((slot) => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
});
}
/**
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
* summed over only the slots riding each edge, maxed across edges. This is the
* figure a locomotive pull/length limit must be compared against — a train is
* never heavier than its heaviest single leg, so summing disjoint legs
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
* Two stops or fewer degrade to the whole-train totals.
*/
export function maxEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/** Booking id → stop-index span; cargo then weighs only its own edges. */
legs?: Map<string, { from: number; to: number }>,
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
(max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount),
}),
{ grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 },
);
}
/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */
export type EdgeConsistUsage = {
edge: number;
grossWeightTons: number;
lengthMeters: number;
loadedWagonCount: number;
wagonCount: number;
};
/**
* Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own
* consist totals, so callers can name WHICH leg breaks a limit instead of
* only reporting the heaviest figure. Two stops or fewer collapse to a
* single whole-route edge.
*/
export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/**
* Booking id → stop-index span. When given, a shared slot's cargo weighs
* only the edges its booking rides (tare still rides the slot's whole
* span) — without it a slot's full cargo counts on every edge it spans.
*/
legs?: Map<string, { from: number; to: number }>,
): EdgeConsistUsage[] {
const edgeCount = Math.max(1, stops.length - 1);
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge,
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
wagonCount: slots.length,
});
if (stops.length <= 2) return [totals(0, wagonPlan)];
const spans = slotSpans(wagonPlan, stops);
return Array.from({ length: stops.length - 1 }, (_, edge) =>
totals(
edge,
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
),
);
}
/**
* Per-box weight rules for a container plan:
* - every unit is checked against its line's weight-limit-rule capacity
* ceiling (`maxContainerWeightTonsByLineId`, any size);
* - 20ft pairs sharing a wagon are checked for weight imbalance.
*/
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
const maxDiff = rules?.max20ftPairWeightDiffTons;
if (capacityByLine == null && maxDiff == null) return violations;
const placementByUnit = new Map(
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
);
const weightsBySlot = new Map<number, number[]>();
for (const unit of units) {
const maxEach = capacityByLine?.[unit.bookingContainerId];
if (maxEach != null && unit.grossWeightTons > maxEach) {
violations.push(
`${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`,
);
}
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
if (!placement?.sequenceNo) continue;
const list = weightsBySlot.get(placement.sequenceNo) ?? [];
list.push(unit.grossWeightTons);
weightsBySlot.set(placement.sequenceNo, list);
}
if (maxDiff != null) {
for (const [sequenceNo, weights] of weightsBySlot.entries()) {
if (weights.length < 2) continue;
const diff = Math.abs(weights[0]! - weights[1]!);
if (diff > maxDiff) {
violations.push(
`Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`,
);
}
}
}
return violations;
}
export function validateContainerPlacements(
containerBookings: Booking[],
wagonPlan: WagonPlanSlot[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
/**
* Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg.
* With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity
* 20ft and an export 20ft coexist on one wagon when their edges allow it.
* Omitted → one edge, byte-identical to the whole-route check.
*/
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const violations: string[] = [];
const units = expandBookingContainerUnits(containerBookings);
if (!units.length) return violations;
const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan));
const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`));
const placementKeys = new Set<string>();
const containerNumbers = new Set<string>();
if (!placements.length) {
violations.push('Container placements are required for container bookings');
return violations;
}
for (const placement of placements) {
const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`;
if (!unitKeys.has(unitKey)) {
violations.push(
`Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`,
);
continue;
}
if (placementKeys.has(unitKey)) {
violations.push(`Duplicate placement for container unit ${unitKey}`);
}
placementKeys.add(unitKey);
if (!containerSlots.has(placement.sequenceNo)) {
violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`);
}
const hasInventory = Boolean(placement.containerId);
const hasManual = Boolean(placement.containerNumber?.trim());
if (!hasInventory && !hasManual) {
violations.push(
`Container unit ${unitKey} requires an existing container or a new container number`,
);
}
if (hasManual) {
const normalized = placement.containerNumber!.trim().toUpperCase();
if (containerNumbers.has(normalized)) {
violations.push(`Duplicate container number ${normalized}`);
}
containerNumbers.add(normalized);
}
}
for (const unit of units) {
const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`;
if (!placementKeys.has(unitKey)) {
violations.push(`Missing placement for ${unit.label}`);
}
}
// TEU and weight are tracked PER EDGE of a unit's leg; without legs there is
// a single edge and this is exactly the old whole-route accounting.
const edges = Math.max(1, edgeCount ?? 1);
const legOf = (bookingId: string): { from: number; to: number } => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
const slotTeuUsed = new Map<number, number[]>();
const slotWeightUsed = new Map<number, number[]>();
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
for (const placement of placements) {
const unit = units.find(
(u) =>
u.bookingContainerId === placement.bookingContainerId &&
u.unitIndex === placement.unitIndex,
);
if (!unit) continue;
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
const leg = legOf(unit.bookingId);
const teuRow =
slotTeuUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
let teuFits = true;
for (let e = leg.from; e < leg.to; e += 1) {
if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) {
teuFits = false;
break;
}
}
if (!teuFits) {
violations.push(
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
);
} else {
for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu;
slotTeuUsed.set(placement.sequenceNo, teuRow);
}
const slot = slotBySeq.get(placement.sequenceNo);
if (slot) {
const weightRow =
slotWeightUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
let heaviestEdge = 0;
for (let e = leg.from; e < leg.to; e += 1) {
weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons);
heaviestEdge = Math.max(heaviestEdge, weightRow[e]);
}
slotWeightUsed.set(placement.sequenceNo, weightRow);
if (heaviestEdge > slot.capacityTons) {
violations.push(
`Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`,
);
}
}
}
violations.push(...validate20ftContainerRules(units, placements, rules));
return violations;
}