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

@@ -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,