mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
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:
@@ -34,3 +34,13 @@ export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4;
|
|||||||
|
|
||||||
/** Default CW3 gondola tare for bulk bookings (T). */
|
/** Default CW3 gondola tare for bulk bookings (T). */
|
||||||
export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4;
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ import { BillingService } from "../billing/billing.service";
|
|||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_BULK_WAGON_TARE_TONS,
|
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||||
|
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||||
DEFAULT_WAGONS_PER_BOOKING,
|
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.
|
* its length on the train and the tare it adds to the locomotive's gross load.
|
||||||
*/
|
*/
|
||||||
type WagonDims = {
|
type WagonDims = {
|
||||||
container: { lengthMeters: number; tareWeightTons: number };
|
container: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
|
||||||
bulk: { lengthMeters: number; tareWeightTons: number };
|
bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BatchBoardBookingState =
|
export type BatchBoardBookingState =
|
||||||
@@ -1917,8 +1919,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
"PREPAID",
|
"PREPAID",
|
||||||
);
|
);
|
||||||
await this.notifier.payNow(booking, deadline);
|
await this.notifier.payNow(booking, deadline);
|
||||||
|
const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims());
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` +
|
`[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` +
|
||||||
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` +
|
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` +
|
||||||
`pay by ${deadline.toISOString()}`,
|
`pay by ${deadline.toISOString()}`,
|
||||||
);
|
);
|
||||||
@@ -2235,12 +2238,21 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const containers = (b: Booking): number =>
|
const containers = (b: Booking): number =>
|
||||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
const totalContainers = containers(primary) + containers(partner);
|
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 =
|
const cargoTons =
|
||||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
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 {
|
return {
|
||||||
wagons: sharedWagons,
|
wagons: sharedWagons,
|
||||||
// Consolidation saves tare as well as slots: the pair rides `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) {
|
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||||
return Math.ceil(booking.wagonsRequired);
|
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
|
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
|
||||||
// share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw
|
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||||||
// container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and
|
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||||
// wrongly filled the train.
|
|
||||||
const fromContainers = containerWagonsForLines(
|
const capacityTons = this.capacityFor(booking.freightType, wagonDims);
|
||||||
booking.bookingContainers ?? [],
|
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
);
|
const byWeight =
|
||||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
|
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.
|
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
|
||||||
*/
|
*/
|
||||||
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
|
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
|
||||||
const wagons = this.wagonsFor(booking);
|
const wagons = this.wagonsFor(booking, wagonDims);
|
||||||
return {
|
return {
|
||||||
wagons,
|
wagons,
|
||||||
weightTons: bookingGrossWeightTons(
|
weightTons: bookingGrossWeightTons(
|
||||||
@@ -2402,14 +2438,20 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
const nw5 = byCode.get("NW5");
|
const nw5 = byCode.get("NW5");
|
||||||
const cw3 = byCode.get("CW3");
|
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 {
|
return {
|
||||||
container: {
|
container: {
|
||||||
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||||
|
capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS),
|
||||||
},
|
},
|
||||||
bulk: {
|
bulk: {
|
||||||
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
|
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
|
||||||
|
capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class AssignBookingsDto {
|
|||||||
@IsUUID('4', { each: true })
|
@IsUUID('4', { each: true })
|
||||||
bookingIds!: string[];
|
bookingIds!: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' })
|
@ApiPropertyOptional({ description: 'Suppress soft hold and overweight warnings' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
forceAssign?: boolean;
|
forceAssign?: boolean;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const makeBooking = (
|
|||||||
wagonsRequired,
|
wagonsRequired,
|
||||||
vgmPerUnitTons: weight / quantity,
|
vgmPerUnitTons: weight / quantity,
|
||||||
isOverweight: false,
|
isOverweight: false,
|
||||||
containerType: { code: containerCode, label: containerCode },
|
containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
...extra,
|
...extra,
|
||||||
@@ -282,7 +282,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(result.warnings[0]).toContain('soft hold window');
|
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 = [
|
const bookings = [
|
||||||
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
|
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
|
||||||
bookingContainers: [
|
bookingContainers: [
|
||||||
@@ -293,7 +293,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
wagonsRequired: 80,
|
wagonsRequired: 80,
|
||||||
vgmPerUnitTons: 45,
|
vgmPerUnitTons: 45,
|
||||||
isOverweight: true,
|
isOverweight: true,
|
||||||
containerType: { code: '40FT', label: '40FT' },
|
containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -311,8 +311,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
destinationStationId: 'yard-destination',
|
destinationStationId: 'yard-destination',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.violations.some((v) => v.includes('overweight'))).toBe(false);
|
||||||
expect(result.violations.some((v) => v.includes('overweight'))).toBe(true);
|
expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows preview when bookings are already on the target schedule', async () => {
|
it('allows preview when bookings are already on the target schedule', async () => {
|
||||||
|
|||||||
@@ -2802,10 +2802,14 @@ export class TrainSchedulingService {
|
|||||||
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
|
`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);
|
const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
|
||||||
if (overweightLines.length) {
|
if (overweightLines.length) {
|
||||||
violations.push(
|
warnings.push(
|
||||||
`Booking ${booking.reference} has overweight container lines; use forceAssign to override`,
|
`Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user