mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #579 from Tria-plc/freight_feature/usermanagement
Enhance wagon capacity handling and validation in booking service
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). */
|
||||
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 {
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user