add items per wagon map to cargo types and sync bulk rate units

- Implemented  in the  to manage the physical item capacity for each wagon type.
- Added a new migration to create the  column in the  table.
- Introduced  method in  to update rate units when cargo type unit of measure changes.
- Updated booking calculations to consider items per wagon for break-bulk cargo.
- Refactored various components to utilize the new items fit logic and ensure consistent date formatting across the application.
- Added tests for the new display timezone functionality to ensure consistent date/time representation across different user settings.
This commit is contained in:
Marshal
2026-08-01 09:55:21 +00:00
parent a8788eb549
commit 0d8c63328a
28 changed files with 506 additions and 46 deletions

View File

@@ -69,6 +69,7 @@ import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
@@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit {
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
const byItems = bulkItemWagonsRequired(booking, capacityTons);
// `dimsFor` resolved dims from the first allowed wagon type, so charge that
// same type's configured items-fit alongside its capacity.
const byItems = bulkItemWagonsRequired(
booking,
capacityTons,
bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id),
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
}

View File

@@ -1,4 +1,4 @@
import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util';
import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
if (booking.freightType === 'BULK') {
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
// holds the item count there, not tons.
const byItems = bulkItemWagonsRequired(booking, capacity);
// 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;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity));

View File

@@ -2,6 +2,7 @@ import {
bookingCargoTons,
bookingGrossWeightTons,
bookingTrainLengthMeters,
bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired,
consistUsage,
consistViolations,
@@ -76,6 +77,62 @@ describe('train-capacity.util', () => {
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
});
describe('configured items-fit (floor space vs tonnage)', () => {
it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => {
// floor(70/20) = 3 by tonnage < 4 by floor space.
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17);
});
it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => {
// floor(70/10) = 7 by tonnage, but only 4 fit physically.
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13);
});
it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => {
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17);
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17);
// floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons.
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8);
});
});
});
describe('bulkItemWagonsForAllowedTypes', () => {
const breakBulk = (quantity: number, weightTons: number) => ({
freightType: 'BULK',
cargoTotalWeightVgm: quantity,
bulkTotalWeightTons: weightTons,
});
it('picks the fewest-wagon allowed type, each capped by its own fit', () => {
const cargoType = {
wagonTypes: [
{ id: 'nw5', capacityTons: 70 },
{ id: 'nw7', capacityTons: 80 },
],
itemsPerWagonMap: { nw5: 4, nw7: 6 },
};
// 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons;
// NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13.
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13);
});
it('equals the old max-capacity estimate when no fits are configured', () => {
const cargoType = {
wagonTypes: [
{ id: 'a', capacityTons: 50 },
{ id: 'b', capacityTons: 70 },
],
};
// Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17.
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17);
});
it('falls back to the given capacity when the relation is missing', () => {
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17);
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17);
});
});
describe('bookingCargoTons (break-bulk weight preference)', () => {

View File

@@ -120,6 +120,12 @@ export function bookingCargoTons(booking: {
* 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons.
* Returns 0 when the booking is not item-counted (PER_TON bulk, containers) —
* callers then fall back to the pooled-tonnage math.
*
* `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from
* cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon
* that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T
* (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls
* back to tonnage-only (legacy cargo types without a configured fit).
*/
export function bulkItemWagonsRequired(
booking: {
@@ -128,6 +134,7 @@ export function bulkItemWagonsRequired(
bulkTotalWeightTons?: number | string | null;
},
capacityTons: number,
itemsFit?: number | null,
): number {
if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0;
const quantity = num(booking.cargoTotalWeightVgm);
@@ -136,10 +143,56 @@ export function bulkItemWagonsRequired(
const perItemTons = totalWeightTons / quantity;
// ponytail: an item heavier than a whole wagon still charges 1 wagon per
// item; reject such bookings at creation time if the case turns real.
const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons));
const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons));
const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity;
const itemsPerWagon = Math.min(byTonnage, byFloor);
return Math.max(1, Math.ceil(quantity / itemsPerWagon));
}
type ItemFitCargoType = {
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
itemsPerWagonMap?: Record<string, number> | null;
} | null;
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
export function bulkItemsFitFor(
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
): number | null {
const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN;
return Number.isFinite(fit) && fit >= 1 ? fit : null;
}
/**
* Break-bulk wagon count when no single wagon type is fixed yet: the best
* (fewest-wagon) count across the cargo type's allowed wagon types, each
* respecting its own items-fit. With no fits configured this equals the old
* max-capacity estimate; with no allowed types it degrades to
* `fallbackCapacityTons` tonnage-only.
*/
export function bulkItemWagonsForAllowedTypes(
booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
},
cargoType: ItemFitCargoType | undefined,
fallbackCapacityTons: number,
): number {
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons);
let best = 0;
for (const wagonType of allowed) {
const wagons = bulkItemWagonsRequired(
booking,
num(wagonType.capacityTons),
bulkItemsFitFor(cargoType, wagonType.id),
);
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
}
return best;
}
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);

View File

@@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
consistViolations,
} from './train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;
@@ -175,7 +180,11 @@ export function buildBulkWagonPlan(
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
// their tonnage must NOT pool with PER_TON cargo (an item can't split
// across wagons the way loose tonnage can).
const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity));
const itemSlotsByBooking = bookings.map((b) =>
// The plan fixed THIS wagon type, so its configured items-fit binds — not
// the best fit across the cargo's allowed types.
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
);
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(