feat: add per-ton cargo loading limits and update related services

This commit is contained in:
Marshal
2026-08-03 19:16:28 +00:00
parent 9afc281d21
commit 488c2465be
14 changed files with 394 additions and 21 deletions

View File

@@ -71,6 +71,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -4100,8 +4101,15 @@ export class BookingBatchService implements OnModuleInit {
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = bookingCargoTons(booking);
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon), so divide by the cap where one is configured for this type.
const tonsPerWagon = bulkTonsPerWagon(
booking.cargoType,
booking.cargoType?.wagonTypes?.[0]?.id,
capacityTons,
);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0;
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
@@ -4169,6 +4177,13 @@ export class BookingBatchService implements OnModuleInit {
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
.map((o) => {
const wagonTypeId = o.wagonTypeId as string;
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
// — a type capped lower swallows less per wagon.
const tonsPerWagon = bulkTonsPerWagon(
booking.cargoType,
wagonTypeId,
o.dims.capacityTons,
);
const wagonsIfAlone = Math.max(
1,
bulkItemWagonsRequired(
@@ -4176,8 +4191,8 @@ export class BookingBatchService implements OnModuleInit {
o.dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId),
) ||
(o.dims.capacityTons > 0
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
(tonsPerWagon > 0
? Math.ceil(bookingCargoTons(booking) / tonsPerWagon)
: total),
);
return {

View File

@@ -1,4 +1,4 @@
import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -56,8 +56,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
// holds the item count there, not tons. No wagon type is fixed yet, so use
// the best count across the cargo's allowed types (per-type items-fit
// respected); falls back to `capacity` when the relation isn't loaded.
const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity);
if (byItems > 0) return byItems;
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon), so tonnage divides by that cap, not by raw capacity.
const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity);
if (byWagons > 0) return byWagons;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity));
}

View File

@@ -4,6 +4,10 @@ import {
bookingTrainLengthMeters,
bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsForAllowedTypes,
bulkTonWagonsRequired,
bulkWagonsForAllowedTypes,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
@@ -135,6 +139,61 @@ describe('train-capacity.util', () => {
});
});
describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => {
// Sugar is loaded 50T per wagon even on a 70T wagon.
const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } };
const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons });
it('uses the configured cap instead of the rated capacity', () => {
expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50);
});
it('falls back to rated capacity when the cargo type caps nothing', () => {
expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70);
expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70);
expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70);
});
it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => {
// Saved when NW5 was rated 70T; the type was later re-rated to 45T.
expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45);
});
it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => {
expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4);
// Same booking, no cap → the old 3-wagon answer.
expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3);
});
it('picks the fewest-wagon allowed type, each on its own cap', () => {
const cargoType = {
wagonTypes: [
{ id: 'nw5', capacityTons: 70 },
{ id: 'nw7', capacityTons: 80 },
],
tonsPerWagonMap: { nw5: 50 },
};
// NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3.
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
});
it('routes PER_ITEM and PER_TON through one call', () => {
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
// PER_ITEM still wins where an item count is present.
const cars = {
wagonTypes: [{ id: 'nw5', capacityTons: 70 }],
itemsPerWagonMap: { nw5: 4 },
};
expect(
bulkWagonsForAllowedTypes(
{ freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 },
cars,
70,
),
).toBe(17);
});
});
describe('bookingCargoTons (break-bulk weight preference)', () => {
it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
expect(

View File

@@ -152,8 +152,98 @@ export function bulkItemWagonsRequired(
type ItemFitCargoType = {
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
itemsPerWagonMap?: Record<string, number> | null;
tonsPerWagonMap?: Record<string, number> | null;
} | null;
/**
* Tons of THIS cargo one wagon of this type may carry: the cargo type's
* configured loading limit when set, else the wagon's full rated capacity.
* Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each
* is loaded to 50 — both the count and the fill follow from this one number.
*
* The configured cap is CLAMPED to the rated capacity rather than trusted: the
* cargo-types service rejects a cap above capacity at save time, but a wagon
* type edited DOWN afterwards would leave a stale cap that overloads the wagon.
* Clamping here means no call site can ever load past the physical rating.
*/
export function bulkTonsPerWagon(
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
capacityTons: number | string | null | undefined,
): number {
const capacity = num(capacityTons);
const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0;
if (!(cap > 0)) return capacity;
return capacity > 0 ? Math.min(cap, capacity) : cap;
}
/**
* Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo
* type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even
* though the wagon is rated 70T. Returns 0 when there is no tonnage or no
* usable per-wagon figure, so callers can fall back as before.
*/
export function bulkTonWagonsRequired(
booking: Parameters<typeof bookingCargoTons>[0],
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
capacityTons: number | string | null | undefined,
): number {
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
const tons = bookingCargoTons(booking);
if (!(perWagon > 0) || !(tons > 0)) return 0;
return Math.max(1, Math.ceil(tons / perWagon));
}
/**
* Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon
* types, each sized on its OWN loading limit — the tonnage twin of
* {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single
* wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo
* type has no usable allowed types.
*/
export function bulkTonWagonsForAllowedTypes(
booking: Parameters<typeof bookingCargoTons>[0],
cargoType: ItemFitCargoType | undefined,
fallbackCapacityTons: number,
): number {
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
if (!allowed.length) {
return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons);
}
let best = 0;
for (const wagonType of allowed) {
const wagons = bulkTonWagonsRequired(
booking,
cargoType,
wagonType.id,
wagonType.capacityTons,
);
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
}
return best;
}
/**
* Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM
* sizes by indivisible items, everything else by tonnage under the cargo type's
* per-wagon loading limit. One call so no site has to remember both paths.
*/
export function bulkWagonsForAllowedTypes(
booking: Parameters<typeof bookingCargoTons>[0] & {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
},
cargoType: ItemFitCargoType | undefined,
fallbackCapacityTons: number,
): number {
return (
bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) ||
bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons)
);
}
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
export function bulkItemsFitFor(
cargoType: ItemFitCargoType | undefined,

View File

@@ -138,6 +138,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits,
trainSetLocomotiveLimits,
@@ -7347,8 +7348,10 @@ export class TrainSchedulingService {
? Math.ceil(booking.wagonsRequired)
: 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const byWeight =
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon) — more wagons for the same cargo, so more tare to pull.
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
const byItems = bulkItemWagonsRequired(

View File

@@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsForAllowedTypes,
bulkWagonsForAllowedTypes,
} from './train-capacity.util';
import {
sortBookingsForScheduling,
@@ -122,10 +122,11 @@ const shortageFor = (
? 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(
// 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))),

View File

@@ -7,6 +7,8 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsRequired,
consistViolations,
} from './train-capacity.util';
@@ -186,15 +188,29 @@ export function buildBulkWagonPlan(
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
);
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
// PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
const cappedTonSlotsByBooking = bookings.map((b, i) =>
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
? 0
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
);
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(
(sum, b, i) =>
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
? sum
: sum + Number(b.cargoTotalWeightVgm ?? 0),
0,
),
);
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
const slots = Math.max(1, tonSlots + itemSlots);
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
@@ -315,6 +331,7 @@ function allocateBookingsToSlots(
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
// bookings that column is an item COUNT, not tons.
remainingWeightTons: roundTons(bookingCargoTons(booking)),
cargoType: booking.cargoType,
}));
let bookingIndex = 0;
@@ -326,8 +343,15 @@ function allocateBookingsToSlots(
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
// as the wagon count — the plan reserved a wagon per capped chunk, so
// pouring rated capacity into it would leave the last wagon empty.
const takeCap = Math.min(
wagonRemaining,
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
);
const allocatedWeightTons = roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons),
Math.min(takeCap, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
@@ -350,6 +374,12 @@ function allocateBookingsToSlots(
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
} else if (allocatedWeightTons >= takeCap) {
// The cap stopped this wagon short of its rating and the booking has
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
// already reserved a wagon for the rest, so backfilling another booking
// here would double-book the consist. Close the wagon.
break;
}
}