This commit is contained in:
Marshal
2026-08-22 00:09:00 +00:00
parent bf4d0632f1
commit bad418d79e
5 changed files with 657 additions and 28 deletions

View File

@@ -416,8 +416,14 @@ export function planWagonsWithStock(params: {
}
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' &&
(open.kind === 'CONTAINER' || disjointFrom(open)) &&
allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) &&
canExtendSpan(open, leg);
@@ -457,6 +463,7 @@ export function planWagonsWithStock(params: {
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,
@@ -500,9 +507,61 @@ export function planWagonsWithStock(params: {
)
: candidates;
// One bulk booking per wagon: a wagon carrying bulk takes that one
// booking's cargo only — never topped up from another booking, even of
// the same cargo type. Every bulk booking therefore opens its own wagons.
// 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