import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, bulkTonsPerWagon, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { sortBookingsForScheduling, type BookingWagonShortage, type DeferredBookingRow, } from './utils/fleet-plan.util'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, expandBookingContainerUnits, roundTons, tareTonsOf, teuSlotsForSizeFt, type SlotLoadType, type WagonPlanSlot, } from './utils/wagon-plan.util'; /** * Wagon types allowed to carry each container type / bulk cargo type — the * many-to-many configuration lists, resolved once per validation run. */ export type AllowedWagonTypeMap = { byContainerTypeId: Map; byCargoTypeId: Map; }; /** * Plannable wagon inventory. TRAIN mode is the built train's own consist — * a hard cap, the plan never reaches for loose yard wagons. YARD mode is the * AVAILABLE pool at the boarding yards (legacy schedules). */ export type WagonStock = { mode: 'TRAIN' | 'YARD'; /** Remaining plannable wagons per wagon type id. Missing type = 0. */ remainingByTypeId: Map; /** Wagon-type code per id, for human-readable shortfall messages. */ codesByTypeId: Map; /** * Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons * standing at that yard. A train whose wagons are split across yards can * only offer, at each boarding yard, the wagons physically standing there — * a wagon waiting in Mojo is not bookable from Dire, and one picked up at * Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits * in one yard, which keeps single-yard trains on the original whole-train * math. */ byYardId?: Map>; }; export type FlexPlanResult = { plan: WagonPlanSlot[]; fitting: Booking[]; deferred: DeferredBookingRow[]; /** * Misconfiguration (a scheduled type with no wagon types configured) — * a hard violation, unlike stock shortfalls which merely defer bookings. */ configIssues: string[]; }; type OpenSlot = { slot: WagonPlanSlot; /** * TEU occupied PER CORRIDOR EDGE. Containers on different legs share the * same physical wagon as long as no single edge exceeds the wagon's TEU * geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft * boarding there, and two overlapping-leg 20fts coexist while both ride. */ teuPerEdge: number[]; kind: SlotLoadType; /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; /** * Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only — * bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined * for weight-only (PER_TON) bulk and container wagons. */ freeItems?: number; /** * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers * prefer a same-leg slot but may extend onto a different-leg one (span * grows to the union); bulk still shares only on an identical leg. */ legKey: string; /** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */ covered: { from: number; to: number }; /** * Boarding-yard pool this wagon was opened from — the yard the consist plans * it at (`''` when the consist is not split across yards). A Mojo wagon * cannot later be stretched back to board at Gelan. */ pool: string; }; /** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ export type BookingLeg = { from: number; to: number }; type PlacementProblem = { kind: 'config' | 'stock'; message: string; /** Wagon types the failing placement could have used (stock problems only). */ candidates?: WagonType[]; }; const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({ sequenceNo: 0, // stamped at the end wagonTypeId: wagonType.id, wagonTypeCode: wagonType.code, capacityTons: Number(wagonType.capacityTons), lengthMeters: Number(wagonType.lengthMeters), tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], slotLoadType: kind, }); /** * Booking-level shortage against the wagon types the failing placement could * use: wagons the whole booking needs vs stock left for those types. Container * counts are TEU-packed per booking; bulk divides by the largest candidate. */ const shortageFor = ( booking: Booking, candidates: WagonType[], availableOf: (wagonTypeId: string) => number, ): BookingWagonShortage => { const wagonsNeeded = booking.freightType === 'BULK' ? Math.max( 1, // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map // respected); PER_TON divides by its per-wagon tonnage cap where one // is configured, else the largest candidate's rating. // bookingCargoTons, not raw VGM — for PER_ITEM that column is the // item count, not tons. bulkWagonsForAllowedTypes( booking, booking.cargoType, Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), ) || Math.ceil( bookingCargoTons(booking) / Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), ), ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) })); const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0); // PER_TON bulk: a bare wagon COUNT lies when the types carry different // tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free" // against a 24-wagon need, yet only 620T of the 695T booking fits — which // is how a deferral could read "needs 24, 24 available (short 1)". Size the // shortfall in the wagons the cargo's OWN caps require: how many more // wagons of the best remaining type would carry the leftover tonnage. const tons = bookingCargoTons(booking); const perItem = Number(booking.bulkTotalWeightTons ?? 0) > 0 && Number(booking.cargoTotalWeightVgm ?? 0) > 0; if (booking.freightType === 'BULK' && !perItem && tons > 0) { let seatable = 0; let usedWagons = 0; for (const { wt, free } of freeByType) { const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)); if (!(perWagon > 0) || free <= 0) continue; seatable += free * perWagon; usedWagons += free; } if (seatable < tons) { const bestPerWagon = Math.max( 1, ...candidates.map((wt) => bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)), ), ); return { wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), wagonsNeeded, wagonsAvailable: usedWagons, // Wagons of the best type still missing to carry the leftover tonnage. wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)), }; } } return { wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), wagonsNeeded, wagonsAvailable, wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable), }; }; const addAllocation = ( slot: WagonPlanSlot, bookingId: string, bookingReference: string, weightTons: number, loadType: AllocationLoadType, ) => { let allocation = slot.allocations.find((a) => a.bookingId === bookingId); if (!allocation) { allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType }; slot.allocations.push(allocation); } allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons); slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons); }; /** * Build the wagon plan against a wagon-type inventory, mixing wagon types * within one consist. Each booking is atomic: it either fits entirely (its * containers/tonnage placed on wagons whose type is allowed for its container * or cargo type) or is deferred with the shortfall reason. Wagon purity rules: * a wagon carries one kind at a time — containers pack by TEU (one 40ft, or * two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only, * filled to the cargo type's per-wagon cap. Type choice is scarcity-aware: * least-shareable wagon type first, so bulk with a PW2 alternative leaves the * container-capable NW5s to the containers. */ export function planWagonsWithStock(params: { bookings: Booking[]; allowed: AllowedWagonTypeMap; stock: WagonStock; /** * Leg-aware stock: booking id → the stop-index range it rides. When given * (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so * the same physical wagon can serve an intercity booking on Gelan→Adama and * an export booking on Adama→Doraleh — disjoint legs never compete for * stock. Omitted → one edge, byte-identical to the old whole-route behavior. */ legs?: Map; edgeCount?: number; /** * Ordered corridor stop ids, parallel to the edges. Required for a consist * split across yards (`stock.byYardId`): a booking then draws ONLY from the * wagons planned at the yard it boards from (`stops[leg.from]`) — the * whole-train count would happily plan 17 Mojo wagons on a train that has * 15 there and 31 in Gelan, and the physical pin then fails after the * customer has paid. */ stops?: readonly string[]; }): FlexPlanResult { const { bookings, allowed, stock, legs } = params; const edgeCount = Math.max(1, params.edgeCount ?? 1); const stops = params.stops ?? []; const openSlots: OpenSlot[] = []; const fitting: Booking[] = []; const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); // Scarcity rank: how many distinct demand groups (container types / bulk // cargo types) among THESE bookings can ride each wagon type. When a cargo // can choose, it takes the least-shareable type first, keeping versatile // types (e.g. container-capable NW5) free for the cargo that has no // alternative. A type nobody else wants ranks 1; unranked types rank 1 too // (nothing competes for them). const demandGroups = new Map(); for (const b of bookings) { if (b.freightType === 'CONTAINER') { for (const line of b.bookingContainers ?? []) { const containerTypeId = line.containerTypeId ?? line.containerType?.id; if (!containerTypeId) continue; demandGroups.set( `C:${containerTypeId}`, allowed.byContainerTypeId.get(containerTypeId) ?? [], ); } } else { const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id; if (cargoTypeId) { demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []); } } } const scarcityRank = new Map(); for (const types of demandGroups.values()) { for (const wt of types) { scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1); } } const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1; const legFor = (booking: Booking): BookingLeg => { const leg = legs?.get(booking.id); if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { return { from: 0, to: edgeCount }; } return leg; }; const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`; // Split consist: each boarding yard is its own pool of steel (mirrors // WagonStockLedger). Single-yard consist / loose yard pool: one pool ''. const poolOf = (leg: BookingLeg): string => stock.byYardId ? (stops[leg.from] ?? '') : ''; const rowKeyFor = (wagonTypeId: string, pool: string): string => pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId; const totalFor = (wagonTypeId: string, pool: string): number => pool ? (stock.byYardId?.get(pool)?.get(wagonTypeId) ?? 0) : (stock.remainingByTypeId.get(wagonTypeId) ?? 0); // Wagons of a type in use per corridor edge, per pool. A type is available // for a leg when its busiest edge WITHIN that leg still has stock spare — the // max over edges is the number of physical wagons the type needs simultaneously. const usedPerEdge = new Map(); const usedRow = (key: string): number[] => { let row = usedPerEdge.get(key); if (!row) { row = new Array(edgeCount).fill(0); usedPerEdge.set(key, row); } return row; }; const availableFor = (wagonTypeId: string, leg: BookingLeg): number => { const pool = poolOf(leg); const total = totalFor(wagonTypeId, pool); const row = usedPerEdge.get(rowKeyFor(wagonTypeId, pool)); if (!row) return total; let busiest = 0; for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0); return total - busiest; }; const noStockMessage = (candidates: WagonType[], leg: BookingLeg): string => { const codes = candidates.map((wt) => wt.code).join('/'); if (stock.mode !== 'TRAIN') return `No available ${codes} wagon at the yard`; return poolOf(leg) ? `Train has no free ${codes} wagon planned at the boarding yard` : `Train has no free ${codes} wagon left`; }; /** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */ const openSlot = ( candidates: WagonType[], kind: SlotLoadType, cargoTypeId: string | null, leg: BookingLeg, /** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */ cargoType?: Booking['cargoType'], ): OpenSlot | PlacementProblem => { const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { return { kind: 'stock', message: noStockMessage(candidates, leg), candidates }; } // Least-shareable type first (see scarcityRank) so cargo with alternatives // never starves cargo without one. Bulk then favors the biggest per-wagon // take for THIS cargo (its configured cap, not the raw rating); containers // favor the deepest stock so the consist drains evenly. Ties keep config order. const bulkTakeOf = (wt: WagonType): number => bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)); const chosen = [...inStock].sort((a, b) => kind === 'BULK' ? rankOf(a) - rankOf(b) || bulkTakeOf(b) - bulkTakeOf(a) || availableFor(b.id, leg) - availableFor(a.id, leg) : rankOf(a) - rankOf(b) || availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; const pool = poolOf(leg); const row = usedRow(rowKeyFor(chosen.id, pool)); for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1; const open: OpenSlot = { slot: slotFromWagonType(chosen, kind), teuPerEdge: new Array(edgeCount).fill(0), kind, cargoTypeId, // A bulk wagon fills to the cargo type's configured per-wagon cap // (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating. freeCapacityTons: kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons), legKey: legKeyOf(leg), covered: { ...leg }, pool, }; openSlots.push(open); return open; }; /** TEU room on every edge of the unit's leg. */ const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => { for (let e = leg.from; e < leg.to; e += 1) { if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false; } return true; }; /** * Whether the slot's ridden span can grow to include this leg: every NEW * edge (outside the current span) must still have a physical wagon of the * slot's type spare — extending the span puts this wagon on those edges. */ const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => { // A pooled wagon boards where its yard is; it cannot be stretched back to // an EARLIER stop (the steel is not there), only ridden further. if (open.pool && leg.from < open.covered.from) return false; const total = totalFor(open.slot.wagonTypeId, open.pool); const row = usedPerEdge.get(rowKeyFor(open.slot.wagonTypeId, open.pool)); const from = Math.min(open.covered.from, leg.from); const to = Math.max(open.covered.to, leg.to); for (let e = from; e < to; e += 1) { if (e >= open.covered.from && e < open.covered.to) continue; if (total - (row?.[e] ?? 0) <= 0) return false; } return true; }; /** Grow the slot's span onto the leg's new edges, consuming stock there. */ const extendSpan = (open: OpenSlot, leg: BookingLeg): void => { const row = usedRow(rowKeyFor(open.slot.wagonTypeId, open.pool)); const from = Math.min(open.covered.from, leg.from); const to = Math.max(open.covered.to, leg.to); for (let e = from; e < to; e += 1) { if (e >= open.covered.from && e < open.covered.to) continue; row[e] = (row[e] ?? 0) + 1; } open.covered = { from, to }; }; const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { const leg = legFor(booking); const legKey = legKeyOf(leg); if (booking.freightType === 'CONTAINER') { const units = expandBookingContainerUnits([booking]); if (!units.length) { // Degenerate container booking with no lines still reserves one wagon // (legacy behavior) — but there is no container type to resolve against. return { kind: 'config', message: `Booking ${booking.reference} has no container lines to plan`, }; } for (const unit of units) { const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? []; if (!candidates.length) { return { kind: 'config', message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`, }; } const allowedIds = new Set(candidates.map((wt) => wt.id)); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); // A BULK wagon whose cargo alights before this unit boards is empty // steel again and may carry containers on the later leg (and vice // versa — see the bulk reuse pass). While both ride together, the // kinds never mix. const disjointFrom = (open: OpenSlot): boolean => open.covered.to <= leg.from || leg.to <= open.covered.from; const fitsSlot = (open: OpenSlot): boolean => (open.kind === 'CONTAINER' || disjointFrom(open)) && allowedIds.has(open.slot.wagonTypeId) && teuFits(open, leg, teu) && canExtendSpan(open, leg); // Same-leg slots first (keeps legacy packing byte-identical), then any // open wagon with per-edge TEU room — an intercity 20ft rides an // export wagon's spare slot instead of appending a new wagon. let target = openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ?? openSlots.find(fitsSlot); if (!target) { const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; } else { extendSpan(target, leg); } addAllocation( target.slot, unit.bookingId, unit.bookingReference, unit.grossWeightTons, AllocationLoadType.Container, ); for (let e = leg.from; e < leg.to; e += 1) { target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu; } } return null; } // BULK — weight-based, one cargo type per wagon. const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null; const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; if (!candidates.length) { return { kind: 'config', message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, }; } const allowedIds = new Set(candidates.map((wt) => wt.id)); // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves // it either way. Items are indivisible, so a wagon takes whole items only, // bounded by tonnage AND by the cargo type's items-per-wagon fit. const quantity = Number(booking.cargoTotalWeightVgm ?? 0); const perItem = Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0; let remainingWeight = roundTons(bookingCargoTons(booking)); const perItemTons = perItem ? remainingWeight / quantity : 0; let remainingItems = perItem ? quantity : 0; /** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */ const itemBudgetOf = (open: OpenSlot): number => { const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); const byTonnage = perItemTons > 0 ? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons)) : 1; return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); }; let placedAnywhere = false; // Per-item: least-shareable type first (same scarcity rule as openSlot), // then the type carrying the most whole items per wagon. const itemBudgetOfType = (wt: WagonType): number => Math.min( bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, perItemTons > 0 ? Math.max( 1, Math.floor( bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) / perItemTons, ), ) : 1, ); const orderedCandidates = perItem ? [...candidates].sort( (a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a), ) : candidates; // One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one // booking's cargo for as long as it rides — never topped up from another // booking on the same edges, even of the same cargo type. // // A wagon whose cargo ALIGHTS before this booking boards is free steel // again, though: an import container uncoupled at Dire Dawa leaves its // wagon empty for bulk loading there. Reuse those disjoint-leg slots // before opening new stock — containers already do this, and without it a // train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of // leg-2 cargo. const disjoint = (open: OpenSlot): boolean => open.covered.to <= leg.from || leg.to <= open.covered.from; const reusable = openSlots.filter( (open) => disjoint(open) && allowedIds.has(open.slot.wagonTypeId) && // A pooled wagon boards at its own yard; it cannot ride backwards. !(open.pool && leg.from < open.covered.from), ); for (const open of reusable) { if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId); if (!wagonType) continue; const room = bulkTonsPerWagon( booking.cargoType, open.slot.wagonTypeId, Number(open.slot.capacityTons), ); if (!(room > 0)) continue; let take: number; if (perItem) { const budget = Math.min( bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ?? Number.MAX_SAFE_INTEGER, perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1, ); const takeItems = Math.max(1, Math.min(budget, remainingItems)); take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); remainingItems -= takeItems; } else { take = roundTons(Math.min(room, remainingWeight)); } addAllocation( open.slot, booking.id, booking.reference, take, AllocationLoadType.Bulk, ); // The wagon now rides this leg too — it is the same physical steel, so // no extra stock is consumed beyond extending its span. extendSpan(open, leg); remainingWeight = roundTons(remainingWeight - take); placedAnywhere = true; } while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { // Per-item: openSlot's stock-depth tie-break would override the fit // preference, so hand it exactly the best in-stock type (full candidate // list only when none has stock, for the proper shortfall message). const inStockBest = perItem ? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0) : undefined; const openedSlot = openSlot( inStockBest ? [inStockBest] : orderedCandidates, 'BULK', cargoTypeId, leg, booking.cargoType, ); if ('message' in openedSlot) return openedSlot; let take: number; if (perItem) { // An item heavier than a whole wagon still charges 1 wagon per item // (creation-time validation owns rejecting that case). const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems)); take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; remainingItems -= takeItems; } else { take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); } addAllocation( openedSlot.slot, booking.id, booking.reference, take, AllocationLoadType.Bulk, ); openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take); remainingWeight = roundTons(remainingWeight - take); placedAnywhere = true; } return null; }; for (const booking of sortBookingsForScheduling(bookings)) { // Snapshot so a booking that doesn't fully fit leaves no half-placed wagons. const usedSnapshot = new Map( [...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]), ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ teuPerEdge: [...open.teuPerEdge], covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, freeItems: open.freeItems, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons), })); const problem = tryPlaceBooking(booking); if (!problem) { fitting.push(booking); continue; } // Roll back this booking's partial placements. usedPerEdge.clear(); for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value); openSlots.length = slotCountSnapshot; openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; if (!snap) return; open.teuPerEdge = [...snap.teuPerEdge]; open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; open.freeItems = snap.freeItems; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; snap.allocationWeights.forEach((weight, allocationIndex) => { open.slot.allocations[allocationIndex].allocatedWeightTons = weight; }); }); if (problem.kind === 'config') configIssues.add(problem.message); // Usage is rolled back here, so the shortage counts the stock this // booking actually saw — not what its own partial placement consumed. const bookingLeg = legFor(booking); const shortage = problem.kind === 'stock' && problem.candidates?.length ? shortageFor(booking, problem.candidates, (wagonTypeId) => Math.max(0, availableFor(wagonTypeId, bookingLeg)), ) : null; deferred.push({ id: booking.id, reference: booking.reference, reason: shortage ? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})` : problem.message, shortage, }); } return { plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })), fitting, deferred, configIssues: [...configIssues], }; } /** * Reverse the wagon ORDER of a built plan when a schedule opts in. * * The plan comes out of planWagonsWithStock ordered by booking scheduling order * (first slot opened = sequenceNo 1). When `reverse` is set, the physically-last * wagon becomes wagon #1: the slot objects — and the bookings already allocated * into each — travel WITH their slot, so only the position numbers flip. The * physical composition, which booking is in which wagon, and every per-slot * field are untouched; sequenceNo is renumbered 1..N over the reversed array. * * This single flip is the whole feature: persistTrainSetWagons writes these * sequenceNos, the snapshot re-sorts by them, and the board/allocation views all * read them — so the stored train order and the schedule order stay identical, * just reversed. A false/absent flag returns the plan unchanged. * * Only the NUMBERS flip — the array itself stays in packing order. Container * placements are generated by walking the container units in booking order * against getContainerSlotSequenceNos(plan) in array order, then matched back to * their allocation by `sequenceNo:bookingId`. Reordering the array here broke * that pairing on every reversed schedule: unit 1 was handed the number of the * slot holding the LAST booking, the match missed, and persistAllocationsAndLoads * silently dropped every container item — which is why a reversed export train * printed a marshalling doc with no container numbers and 0/0 container counts. */ export function applyWagonOrderReversal( plan: WagonPlanSlot[], reverse: boolean | null | undefined, ): WagonPlanSlot[] { if (!reverse) return plan; return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index })); } /** Unbounded stock — used to compute pure demand for availability reporting. */ export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock { const remainingByTypeId = new Map(); const codesByTypeId = new Map(); for (const list of [ ...allowed.byContainerTypeId.values(), ...allowed.byCargoTypeId.values(), ]) { for (const wagonType of list) { remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER); codesByTypeId.set(wagonType.id, wagonType.code); } } return { mode: 'YARD', remainingByTypeId, codesByTypeId }; }