Merge pull request #579 from Tria-plc/freight_feature/usermanagement

Enhance wagon capacity handling and validation in booking service
This commit is contained in:
marshal
2026-07-09 16:20:55 +03:00
committed by GitHub
6 changed files with 155 additions and 26 deletions

View File

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

View File

@@ -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<string, unknown> = {}) => ({
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);
});
});

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),
},
};
}

View File

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

View File

@@ -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 () => {

View File

@@ -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`,
);
}
}