leg-aware capacity and wagon sharing

This commit is contained in:
Marshal
2026-07-30 17:21:44 +00:00
parent 8f25fb3e8b
commit acef6870e9
15 changed files with 653 additions and 119 deletions

View File

@@ -665,6 +665,14 @@ export function validateContainerPlacements(
wagonPlan: WagonPlanSlot[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
/**
* Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg.
* With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity
* 20ft and an export 20ft coexist on one wagon when their edges allow it.
* Omitted → one edge, byte-identical to the whole-route check.
*/
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const violations: string[] = [];
const units = expandBookingContainerUnits(containerBookings);
@@ -721,8 +729,18 @@ export function validateContainerPlacements(
}
}
const slotTeuUsed = new Map<number, number>();
const slotWeightUsed = new Map<number, number>();
// TEU and weight are tracked PER EDGE of a unit's leg; without legs there is
// a single edge and this is exactly the old whole-route accounting.
const edges = Math.max(1, edgeCount ?? 1);
const legOf = (bookingId: string): { from: number; to: number } => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
const slotTeuUsed = new Map<number, number[]>();
const slotWeightUsed = new Map<number, number[]>();
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
for (const placement of placements) {
@@ -734,22 +752,38 @@ export function validateContainerPlacements(
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) {
const leg = legOf(unit.bookingId);
const teuRow =
slotTeuUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
let teuFits = true;
for (let e = leg.from; e < leg.to; e += 1) {
if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) {
teuFits = false;
break;
}
}
if (!teuFits) {
violations.push(
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
);
} else {
slotTeuUsed.set(placement.sequenceNo, usedTeu + teu);
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 weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons;
slotWeightUsed.set(placement.sequenceNo, weight);
if (weight > slot.capacityTons) {
const weightRow =
slotWeightUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
let heaviestEdge = 0;
for (let e = leg.from; e < leg.to; e += 1) {
weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons);
heaviestEdge = Math.max(heaviestEdge, weightRow[e]);
}
slotWeightUsed.set(placement.sequenceNo, weightRow);
if (heaviestEdge > slot.capacityTons) {
violations.push(
`Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`,
`Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`,
);
}
}