Merge pull request #1030 from Tria-plc/freight_feature/usermanagement

leg-aware capacity and wagon sharing
This commit is contained in:
marshal
2026-07-30 20:22:44 +03:00
committed by GitHub
15 changed files with 653 additions and 119 deletions

View File

@@ -127,6 +127,10 @@ export interface ExportSpaceReport {
*/
export interface ExportTrainOption {
scheduleId: string;
/** Schedule's train number (falls back to the built train's number). */
trainNumber: string | null;
/** Built train's name/code, when the schedule runs a Train Builder train. */
trainName: string | null;
departure: Date;
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
bookingClosesAt: Date | null;
@@ -297,11 +301,20 @@ export interface BatchBoardSchedule {
/** Train length used by allocated bookings (from wagon-type dimensions). */
allocatedLengthMeters: number;
maxLengthMeters: number | null;
/** Weight committed on the train (allocated + selected-for-batch). */
/**
* Weight committed on the train (allocated + selected-for-batch). On a
* multi-stop corridor this is the HEAVIEST single edge, not the sum —
* disjoint legs (intercity + export) never ride together, so summing
* them over-reports the train against the pull limit.
*/
usedWeightTons: number;
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
maxWagons: number | null;
/** Physical consist length of the built train (Train Builder), null without one. */
trainLengthMeters: number | null;
/** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */
legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null;
};
counts: {
allocated: number;
@@ -1076,21 +1089,52 @@ export class BookingBatchService implements OnModuleInit {
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
const room = budget.remainingFor(leg);
const byWagonType = allowed.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
return {
wagonTypeId,
code: type?.code ?? null,
name: type?.name ?? null,
freeWagons: this.bookableWithin(room, dims).wagons,
};
});
// The abstract budget can't tell wagon types apart — cap each type's free
// count with the PHYSICAL wagons of that type the train (or yard pool)
// actually holds on this leg, and on a built train hide types the consist
// doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free".
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
);
const byWagonType = allowed
.filter(
({ wagonTypeId }) =>
stock.mode !== 'TRAIN' ||
!wagonTypeId ||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
)
.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
const roomWagons = this.bookableWithin(room, dims).wagons;
const physical = wagonTypeId
? ledger.availableFor([wagonTypeId], leg)
: roomWagons;
return {
wagonTypeId,
code: type?.code ?? null,
name: type?.name ?? null,
freeWagons: Math.min(roomWagons, physical),
};
});
const freeWagons = byWagonType.reduce(
(best, t) => Math.max(best, t.freeWagons),
0,
);
const builtTrain = schedule.trainSet?.train;
out.push({
scheduleId: schedule.id,
trainNumber:
schedule.trainNumber ??
builtTrain?.exportTrainNumber ??
builtTrain?.trainNumber ??
null,
trainName: builtTrain?.trainName ?? builtTrain?.code ?? null,
departure: schedule.scheduledDepartureDate!,
bookingClosesAt: schedule.windowClosesAt ?? null,
isOpen: this.isFillable(schedule),
@@ -1531,9 +1575,18 @@ export class BookingBatchService implements OnModuleInit {
// waiting pool (the 7 that lost the batch), not just the winners. These are
// display-only candidates: they are excluded from the capacity meters below.
const pinnedIds = new Set(bookings.map((b) => b.id));
if (s.scheduledDepartureDate) {
// Corridor stops drive both the day-pool candidate merge and the per-leg
// capacity meters below; a failed lookup degrades to whole-route math.
let stops: string[] = [];
try {
stops = await this.stopsForSchedule(s);
} catch (err) {
this.logger.warn(
`Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`,
);
}
if (s.scheduledDepartureDate && stops.length) {
try {
const stops = await this.stopsForSchedule(s);
const candidates =
await this.bookingsRepository.findBatchPoolByCorridorDay(
stops,
@@ -1662,6 +1715,18 @@ export class BookingBatchService implements OnModuleInit {
const windowBookings = items.filter((i) => i.fullyExecutedAt);
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
const stopLabels =
stops.length > 2 ? await this.yardLabels(stops) : new Map<string, string>();
const yardsByBookingId = new Map(
bookings.map((b) => [
b.id,
{
originYardId: b.originYardId ?? null,
destinationYardId: b.destinationYardId ?? null,
},
]),
);
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
@@ -1707,6 +1772,12 @@ export class BookingBatchService implements OnModuleInit {
items.filter((i) => pinnedIds.has(i.id)),
loco,
s.maxWagons ?? null,
{
stops,
labelByYardId: stopLabels,
yardsByBookingId,
trainLengthMeters: this.builtTrainLengthOf(s),
},
),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
@@ -1747,6 +1818,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private computeBoardCapacity(
items: Array<{
id: string;
state: BatchBoardBookingState;
wagons: number;
weightTons: number;
@@ -1754,6 +1826,16 @@ export class BookingBatchService implements OnModuleInit {
}>,
loco: LocomotiveLimits | null,
maxWagons: number | null,
legCtx?: {
/** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */
stops: string[];
labelByYardId: Map<string, string>;
yardsByBookingId: Map<
string,
{ originYardId: string | null; destinationYardId: string | null }
>;
trainLengthMeters: number | null;
},
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
// Every booking still targeting this train holds gross weight — including
@@ -1771,18 +1853,72 @@ export class BookingBatchService implements OnModuleInit {
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
// Per-leg committed weight: a booking holds weight only on the edges it
// rides, so the meter compares the HEAVIEST single edge against the pull
// limit. Whole-route bookings (or yards missing from the stop list) load
// every edge — never under-reported.
const stops = legCtx?.stops ?? [];
let usedWeightTons = round2(
committed.reduce((sum, i) => sum + i.weightTons, 0),
);
let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
if (legCtx && stops.length > 2) {
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
const edges = new Array<number>(stops.length - 1).fill(0);
for (const item of committed) {
const yards = legCtx.yardsByBookingId.get(item.id);
const from = yards?.originYardId
? stopIndex.get(yards.originYardId)
: undefined;
const to = yards?.destinationYardId
? stopIndex.get(yards.destinationYardId)
: undefined;
const leg =
from != null && to != null && from < to
? { from, to }
: { from: 0, to: edges.length };
for (let e = leg.from; e < leg.to; e += 1) edges[e] += item.weightTons;
}
const label = (yardId: string) =>
legCtx.labelByYardId.get(yardId) ?? yardId;
legUsage = edges.map((weight, i) => ({
from: label(stops[i]),
to: label(stops[i + 1]),
usedWeightTons: round2(weight),
}));
usedWeightTons = round2(Math.max(0, ...edges));
}
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters: round2(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
),
maxLengthMeters: caps ? caps.maxLengthMeters : null,
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
usedWeightTons,
maxWeightTons: caps ? caps.maxWeightTons : null,
maxWagons: maxWagons ?? null,
trainLengthMeters: legCtx?.trainLengthMeters ?? null,
legUsage,
};
}
/** Built consist's physical length (what Train Builder shows), null without a built train. */
private builtTrainLengthOf(s: TrainSchedule): number | null {
const raw = s.trainSet?.totalLengthMeters;
const value = raw != null ? Number(raw) : NaN;
return Number.isFinite(value) && value > 0 ? value : null;
}
/** Yard display labels for corridor stops (falls back to the yard id). */
private async yardLabels(yardIds: string[]): Promise<Map<string, string>> {
if (!yardIds.length) return new Map();
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(yardIds) } });
return new Map(yards.map((y) => [y.id, y.label ?? y.code]));
}
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
@@ -1829,7 +1965,12 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
stops: [],
labelByYardId: new Map(),
yardsByBookingId: new Map(),
trainLengthMeters: this.builtTrainLengthOf(s),
}),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")

View File

@@ -4139,6 +4139,7 @@ export class TrainSchedulingService {
fittingBookings,
dto.originStationId,
dto.destinationStationId,
stops,
);
violations.push(
@@ -4185,6 +4186,8 @@ export class TrainSchedulingService {
wagonPlan,
containerPlacements,
placementRules,
legByBookingId,
Math.max(1, stops.length - 1),
),
);
violations.push(
@@ -5011,6 +5014,7 @@ export class TrainSchedulingService {
bookings: Booking[],
scheduleOriginYardId: string,
scheduleDestinationYardId: string,
stops: string[],
): void {
const bookingById = new Map(bookings.map((b) => [b.id, b]));
for (const slot of wagonPlan) {
@@ -5026,13 +5030,33 @@ export class TrainSchedulingService {
b.originYardId === first.originYardId &&
b.destinationYardId === first.destinationYardId,
);
if (!sameCorridor) continue;
slot.boardYardId =
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
slot.alightYardId =
first.destinationYardId === scheduleDestinationYardId
? null
: first.destinationYardId;
if (sameCorridor) {
slot.boardYardId =
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
slot.alightYardId =
first.destinationYardId === scheduleDestinationYardId
? null
: first.destinationYardId;
continue;
}
// Mixed corridors on one wagon (cross-leg TEU sharing): the wagon rides
// the UNION of its cargo legs. A yard missing from the stop list keeps
// the slot on the whole route so capacity is never under-occupied.
let from = Number.POSITIVE_INFINITY;
let to = Number.NEGATIVE_INFINITY;
for (const b of slotBookings) {
const f = stops.indexOf(b.originYardId);
const t = stops.indexOf(b.destinationYardId);
if (f < 0 || t <= f) {
from = Number.POSITIVE_INFINITY;
break;
}
from = Math.min(from, f);
to = Math.max(to, t);
}
if (!Number.isFinite(from) || to <= from) continue;
slot.boardYardId = from === 0 ? null : stops[from];
slot.alightYardId = to === stops.length - 1 ? null : stops[to];
}
}

View File

@@ -209,7 +209,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
new Map(entries);
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only:
// the intercity 20ft alights where the export 20ft boards, so both share
// the single physical wagon (cross-leg TEU sharing).
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
@@ -233,16 +235,19 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
'EXPORT-1',
'INTERCITY-1',
]);
// Two slots planned, but both drawn from the single physical wagon.
expect(result.plan).toHaveLength(2);
expect(result.plan).toHaveLength(1);
});
it('still defers when the legs overlap and stock is exhausted', () => {
it('still defers when the wagon has no per-edge TEU room and stock is exhausted', () => {
// Export is a 40ft (2 TEU) riding the whole corridor — no edge has room
// for the intercity 20ft, and there is no second wagon to open.
const fortyFooter = containerBooking('EXPORT-1', 1, 1);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
} as never;
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)],
allowed,
stock: {
mode: 'TRAIN',
@@ -250,7 +255,6 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
// Both ride edge 0 — they compete for the one wagon.
['EXPORT-1', { from: 0, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
@@ -263,9 +267,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
});
it('never packs bookings with different legs into the same wagon slot', () => {
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
// must open separate slots (each with its own leg), not one mixed slot.
it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => {
// Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills
// the open wagon (span grows to the union) rather than opening wagon #2.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
@@ -284,11 +288,12 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
edgeCount: 2,
});
expect(result.plan).toHaveLength(2);
const bookingsPerSlot = result.plan.map((s) =>
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
);
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
expect(result.deferred).toHaveLength(0);
expect(result.plan).toHaveLength(1);
const bookingsInSlot = [
...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)),
].sort();
expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']);
});
it('behaves exactly like the whole-route planner when no legs are given', () => {

View File

@@ -53,18 +53,25 @@ export type FlexPlanResult = {
type OpenSlot = {
slot: WagonPlanSlot;
teuUsed: number;
/**
* 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;
/**
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
* share a slot when their legs are identical — mixing corridors in one slot
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
* re-occupy edges the cargo never rides.
* 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. */
@@ -227,16 +234,54 @@ export function planWagonsWithStock(params: {
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuUsed: 0,
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);
@@ -260,17 +305,23 @@ export function planWagonsWithStock(params: {
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
let target = openSlots.find(
(open) =>
open.kind === 'CONTAINER' &&
open.legKey === legKey &&
allowedIds.has(open.slot.wagonTypeId) &&
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
);
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,
@@ -279,7 +330,9 @@ export function planWagonsWithStock(params: {
unit.grossWeightTons,
AllocationLoadType.Container,
);
target.teuUsed += teu;
for (let e = leg.from; e < leg.to; e += 1) {
target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu;
}
}
return null;
}
@@ -343,7 +396,8 @@ export function planWagonsWithStock(params: {
);
const slotCountSnapshot = openSlots.length;
const slotStateSnapshot = openSlots.map((open) => ({
teuUsed: open.teuUsed,
teuPerEdge: [...open.teuPerEdge],
covered: { ...open.covered },
freeCapacityTons: open.freeCapacityTons,
assignedWeightTons: open.slot.assignedWeightTons,
allocationCount: open.slot.allocations.length,
@@ -363,7 +417,8 @@ export function planWagonsWithStock(params: {
openSlots.forEach((open, index) => {
const snap = slotStateSnapshot[index];
if (!snap) return;
open.teuUsed = snap.teuUsed;
open.teuPerEdge = [...snap.teuPerEdge];
open.covered = { ...snap.covered };
open.freeCapacityTons = snap.freeCapacityTons;
open.slot.assignedWeightTons = snap.assignedWeightTons;
open.slot.allocations.length = snap.allocationCount;

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`,
);
}
}