Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
2026-08-02 10:26:36 +00:00

599 lines
24 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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsForAllowedTypes,
} from './train-capacity.util';
import {
sortBookingsForScheduling,
type BookingWagonShortage,
type DeferredBookingRow,
} from './fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
expandBookingContainerUnits,
roundTons,
tareTonsOf,
teuSlotsForSizeFt,
type SlotLoadType,
type WagonPlanSlot,
} from './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<string, WagonType[]>;
byCargoTypeId: Map<string, WagonType[]>;
};
/**
* 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<string, number>;
/** Wagon-type code per id, for human-readable shortfall messages. */
codesByTypeId: Map<string, string>;
};
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 };
};
/** 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 falls through to tonnage over the largest
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
// column is the item count, not tons.
bulkItemWagonsForAllowedTypes(
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 wagonsAvailable = candidates.reduce(
(sum, wt) => sum + availableOf(wt.id),
0,
);
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), bulk fills by weight and never shares a wagon
* with a different cargo type.
*/
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<string, BookingLeg>;
edgeCount?: number;
}): FlexPlanResult {
const { bookings, allowed, stock, legs } = params;
const edgeCount = Math.max(1, params.edgeCount ?? 1);
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
const configIssues = new Set<string>();
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}`;
// Wagons of a type in use per corridor edge. 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<string, number[]>();
const usedRow = (wagonTypeId: string): number[] => {
let row = usedPerEdge.get(wagonTypeId);
if (!row) {
row = new Array<number>(edgeCount).fill(0);
usedPerEdge.set(wagonTypeId, row);
}
return row;
};
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = usedPerEdge.get(wagonTypeId);
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[]): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
};
/** 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,
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
}
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
const chosen = [...inStock].sort((a, b) =>
kind === 'BULK'
? Number(b.capacityTons) - Number(a.capacityTons) ||
availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg),
)[0];
const row = usedRow(chosen.id);
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<number>(edgeCount).fill(0),
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
};
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 => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
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(open.slot.wagonTypeId);
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);
const fitsSlot = (open: OpenSlot): boolean =>
open.kind === 'CONTAINER' &&
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;
/** Whole items one wagon of this slot's type can still take. */
const itemRoomOf = (open: OpenSlot): number =>
Math.min(
open.freeItems ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
);
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
const itemBudgetOf = (open: OpenSlot): number => {
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
const byTonnage =
perItemTons > 0
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
: 1;
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
};
let placedAnywhere = false;
// Per-item: prefer the type carrying the most whole items per wagon.
// openSlot's own capacity sort is stable, so this order breaks its ties.
const itemBudgetOfType = (wt: WagonType): number =>
Math.min(
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
: 1,
);
const orderedCandidates = perItem
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
: candidates;
// Top off wagons already carrying THIS cargo type before opening new ones.
// ponytail: per-item cargo only shares wagons that were opened per-item
// (freeItems tracked); mixing itemized and loose loads of one cargo type
// on one wagon is not modeled — open a new wagon instead.
for (const open of openSlots) {
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue;
if (open.legKey !== legKey) continue;
if (open.cargoTypeId !== cargoTypeId) continue;
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
if (open.freeCapacityTons <= 0) continue;
if (perItem !== (open.freeItems !== undefined)) continue;
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
if (perItem && takeItems <= 0) continue;
const take = perItem
? roundTons(takeItems * perItemTons)
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
addAllocation(
open.slot,
booking.id,
booking.reference,
take,
AllocationLoadType.Bulk,
);
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
if (perItem) {
open.freeItems = (open.freeItems ?? 0) - takeItems;
remainingItems -= takeItems;
}
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,
);
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<string, number>();
const codesByTypeId = new Map<string, string>();
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 };
}