Enhance wagon capacity handling and validation in booking service

- Added default capacities for container and bulk wagons.
- Updated wagonsFor method to consider weight and length for wagon calculations.
- Improved handling of overweight bookings with appropriate warnings.
- Adjusted tests to reflect changes in wagon capacity and validation logic.
This commit is contained in:
Marshal
2026-07-09 13:10:32 +00:00
parent db35b90b45
commit eb819d66a1
6 changed files with 155 additions and 26 deletions

View File

@@ -29,8 +29,10 @@ import { BillingService } from "../billing/billing.service";
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
@@ -73,8 +75,8 @@ interface RouteDayGroup {
* its length on the train and the tare it adds to the locomotive's gross load.
*/
type WagonDims = {
container: { lengthMeters: number; tareWeightTons: number };
bulk: { lengthMeters: number; tareWeightTons: number };
container: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
};
export type BatchBoardBookingState =
@@ -1917,8 +1919,9 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims());
this.logger.log(
`[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` +
`[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` +
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId}` +
`pay by ${deadline.toISOString()}`,
);
@@ -2235,12 +2238,21 @@ export class BookingBatchService implements OnModuleInit {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const sharedWagons =
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary) + this.wagonsFor(partner);
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
// Consolidation shares TEU slots, never rated payload: the pair still needs
// enough wagons to carry its combined cargo, so the weight axis bounds the
// shared count exactly as it bounds an individual booking's.
const capacityTons = this.capacityFor(primary.freightType, wagonDims);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
const byLength =
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims);
const sharedWagons = Math.max(byLength, byWeight);
return {
wagons: sharedWagons,
// Consolidation saves tare as well as slots: the pair rides `sharedWagons`
@@ -2272,19 +2284,43 @@ export class BookingBatchService implements OnModuleInit {
};
}
private wagonsFor(booking: Booking): number {
/**
* Wagons a booking occupies. Two axes bind independently and the booking needs
* enough wagons to satisfy BOTH, so the count is the larger of:
*
* weight — ceil(cargoTons / wagonType.capacityTons), the rated payload
* length — TEU geometry, two 20ft to a wagon (container bookings only)
*
* The weight axis was missing entirely. A BULK booking carries no container
* lines, so `containerWagonsForLines` returned 0 and every bulk booking
* collapsed to a single wagon no matter its tonnage — a 2590T fertilizer
* booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37.
* That under-reported the board and let the fill loop overbook the train.
*/
private wagonsFor(booking: Booking, wagonDims: WagonDims): number {
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
return Math.ceil(booking.wagonsRequired);
}
// booking.wagonsRequired is NULL for most rows (only set on certain
// scheduling paths). Derive from the container lines, TEU-aware: two 20ft
// share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw
// container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and
// wrongly filled the train.
const fromContainers = containerWagonsForLines(
booking.bookingContainers ?? [],
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const capacityTons = this.capacityFor(booking.freightType, wagonDims);
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
return Math.max(DEFAULT_WAGONS_PER_BOOKING, byLength, byWeight);
}
private capacityFor(
freightType: string | null | undefined,
wagonDims: WagonDims,
): number {
return freightType === "BULK"
? wagonDims.bulk.capacityTons
: wagonDims.container.capacityTons;
}
/**
@@ -2296,7 +2332,7 @@ export class BookingBatchService implements OnModuleInit {
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
*/
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
const wagons = this.wagonsFor(booking);
const wagons = this.wagonsFor(booking, wagonDims);
return {
wagons,
weightTons: bookingGrossWeightTons(
@@ -2402,14 +2438,20 @@ export class BookingBatchService implements OnModuleInit {
);
const nw5 = byCode.get("NW5");
const cw3 = byCode.get("CW3");
// capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload
// must fall back rather than yield an infinite wagon count.
const payload = (value: number | undefined, fallback: number): number =>
value && value > 0 ? value : fallback;
return {
container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS),
},
bulk: {
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS),
},
};
}