Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts
2026-07-30 19:08:06 +00:00

811 lines
28 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, bulkItemWagonsRequired, 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;
max20ftContainerWeightTons?: number;
max20ftPairWeightDiffTons?: number;
};
export type ContainerPlacementRules = {
max20ftContainerWeightTons?: 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) => bulkItemWagonsRequired(b, capacity));
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(
(sum, b, i) =>
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
0,
),
);
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
const slots = Math.max(1, tonSlots + itemSlots);
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)),
}));
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];
const allocatedWeightTons = roundTons(
Math.min(wagonRemaining, 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;
}
}
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,
);
}
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,
): 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));
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,
): 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 },
);
}
/**
* 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[],
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const spans = slotSpans(wagonPlan, stops);
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
violations.add(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[];
};
/** 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[],
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
const totals = (slots: EdgeUsageSlot[]) => ({
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
});
if (stops.length <= 2) return totals(wagonPlan);
const spans = slotSpans(wagonPlan, stops);
const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 };
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = totals(
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
);
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount);
}
return usage;
}
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const maxEach = rules?.max20ftContainerWeightTons;
const maxDiff = rules?.max20ftPairWeightDiffTons;
if (maxEach == 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 sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
if (maxEach != null && unit.grossWeightTons > maxEach) {
violations.push(
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
);
}
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;
}