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; 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) => // 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, 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, limits?: TrainLimitConfig, /** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */ legs?: Map, 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>, limits?: TrainLimitConfig, legs?: Map, 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>, 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[] { 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(); 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, ): 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, ): { 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, ): 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(); 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, 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(); 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}`); } } // 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(); 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 leg = legOf(unit.bookingId); const teuRow = slotTeuUsed.get(placement.sequenceNo) ?? new Array(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(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; }