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): 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, 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>, 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>, limits: TrainLimitConfig | undefined, stops: string[], ): string[] { if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); const spans = slotSpans(wagonPlan, stops); const violations = new Set(); 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]; } /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ function slotSpans( wagonPlan: WagonPlanSlot[], 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: WagonPlanSlot[], stops: string[], ): { grossWeightTons: number; lengthMeters: number } { const totals = (slots: WagonPlanSlot[]) => ({ 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), }); if (stops.length <= 2) return totals(wagonPlan); const spans = slotSpans(wagonPlan, stops); const usage = { grossWeightTons: 0, lengthMeters: 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); } 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(); 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, ): 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(); const containerNumbers = new Set(); 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}`); } } const slotTeuUsed = new Map(); const slotWeightUsed = new Map(); 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 usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { violations.push( `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`, ); } else { slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); } const slot = slotBySeq.get(placement.sequenceNo); if (slot) { const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; slotWeightUsed.set(placement.sequenceNo, weight); if (weight > slot.capacityTons) { violations.push( `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, ); } } } violations.push(...validate20ftContainerRules(units, placements, rules)); return violations; }