diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 1ff1880b7..62ed50c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -34,3 +34,13 @@ export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4; /** Default CW3 gondola tare for bulk bookings (T). */ export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4; + +/** + * Fallback rated payloads (T) matching the tare fallbacks above. A bulk booking's + * wagon count is its cargo divided by this, so a zero here would make the count + * infinite — callers must floor it at a positive number. + */ +export const DEFAULT_CONTAINER_WAGON_CAPACITY_TONS = 70; + +/** Default CW3 gondola rated payload for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_CAPACITY_TONS = 60; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index b7156880f..a20ef600d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -731,3 +731,76 @@ describe('BookingBatchService — PAID reconcile', () => { }); }); }); + +describe('BookingBatchService — wagonsFor', () => { + // wagonsFor is pure arithmetic over its two arguments and touches no injected + // dependency, so the service can be built with none. + const service = new BookingBatchService( + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + ) as unknown as { + wagonsFor(booking: unknown, dims: unknown): number; + }; + + // PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m. + const dims = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }, + }; + + const bulk = (cargoTons: number, over: Record = {}) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + ...over, + }); + + it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => { + // 37 × 1400 fertilizer packages × 50kg = 2590T of cargo. + expect(service.wagonsFor(bulk(2590), dims)).toBe(37); + }); + + it('rounds a partial wagon up', () => { + expect(service.wagonsFor(bulk(70.1), dims)).toBe(2); + expect(service.wagonsFor(bulk(70), dims)).toBe(1); + }); + + it('still floors at one wagon when a bulk booking has no recorded cargo', () => { + expect(service.wagonsFor(bulk(0), dims)).toBe(1); + }); + + it('honours an explicit wagonsRequired override', () => { + expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40); + }); + + it('takes the binding axis for containers: weight can exceed TEU geometry', () => { + // Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 210, + bookingContainers: [ + { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(3); + }); + + it('keeps TEU geometry when it binds before weight', () => { + // Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 40, + bookingContainers: [ + { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d043fed32..93227f3ab 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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), }, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts index b5e93f5da..1fd1e9a08 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -51,7 +51,7 @@ export class AssignBookingsDto { @IsUUID('4', { each: true }) bookingIds!: string[]; - @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @ApiPropertyOptional({ description: 'Suppress soft hold and overweight warnings' }) @IsOptional() @IsBoolean() forceAssign?: boolean; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b7938857f..e97b3de56 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -72,7 +72,7 @@ const makeBooking = ( wagonsRequired, vgmPerUnitTons: weight / quantity, isOverweight: false, - containerType: { code: containerCode, label: containerCode }, + containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id }, }, ], ...extra, @@ -282,7 +282,7 @@ describe('TrainSchedulingService', () => { expect(result.warnings[0]).toContain('soft hold window'); }); - it('flags the overweight booking as invalid', async () => { + it('warns on the overweight booking but still allows scheduling', async () => { const bookings = [ makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { bookingContainers: [ @@ -293,7 +293,7 @@ describe('TrainSchedulingService', () => { wagonsRequired: 80, vgmPerUnitTons: 45, isOverweight: true, - containerType: { code: '40FT', label: '40FT' }, + containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id }, }, ], }), @@ -311,8 +311,8 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-destination', }); - expect(result.valid).toBe(false); - expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + expect(result.violations.some((v) => v.includes('overweight'))).toBe(false); + expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true); }); it('allows preview when bookings are already on the target schedule', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index d96c8062e..00b1bf02a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2802,10 +2802,14 @@ export class TrainSchedulingService { `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, ); } + // Overweight is the soft threshold (maxVgmTons): the customer already + // paid the overweight surcharge at booking. The hard ceiling + // (maxCapacityTons) blocks booking creation, so anything reaching + // scheduling is shippable — warn the planner, never block allocation. const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); if (overweightLines.length) { - violations.push( - `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + warnings.push( + `Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`, ); } }