From a7d05fba3413b185f1e49146e3102091238b0094 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 17:04:25 +0000 Subject: [PATCH] test batch system --- .../modules/bookings/bookings.repository.ts | 7 + .../booking-batch.service.spec.ts | 51 +++++ .../train-scheduling/booking-batch.service.ts | 191 ++++++++++-------- .../corridor-capacity.util.spec.ts | 75 +++++++ .../corridor-capacity.util.ts | 45 ++++- .../train-scheduling/intercity.service.ts | 12 +- .../train-capacity.util.spec.ts | 60 ++++++ .../train-scheduling/train-capacity.util.ts | 74 +++++-- .../BatchScheduleDetailPage.tsx | 9 - 9 files changed, 413 insertions(+), 111 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 087786715..843f91d24 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -999,6 +999,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') @@ -1030,6 +1031,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id = :originYardId', { originYardId }) .andWhere('booking.destination_yard_id = :destinationYardId', { @@ -1069,6 +1071,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { @@ -1134,6 +1137,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') @@ -1148,6 +1152,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .getMany(); @@ -1177,6 +1182,8 @@ export class BookingsRepository extends BaseRepository { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .innerJoin( TrainScheduleBooking, 'sb', 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 60e2b6767..24e908761 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 @@ -752,12 +752,18 @@ describe('BookingBatchService — wagonsFor', () => { null as never, ) as unknown as { wagonsFor(booking: unknown, dims: unknown): number; + needFor(booking: unknown, dims: unknown): { + wagons: number; + weightTons: number; + lengthMeters: 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 }, + byWagonTypeId: new Map(), }; const bulk = (cargoTons: number, over: Record = {}) => ({ @@ -815,4 +821,49 @@ describe('BookingBatchService — wagonsFor', () => { }; expect(service.wagonsFor(booking, dims)).toBe(2); }); + + describe('per-booking wagon type (cargo/container type FK)', () => { + // The booking's cargo type rides PW2 (25.2T tare / 70T), but the + // representative bulk fallback is a CW3-ish 23.4T tare. Measuring the + // booking on the fallback under-charged its gross (2100 + 30 × 23.4 = + // 2802 instead of 2856), so the fill loop admitted sets that allocation's + // real-consist check later rejected — after the customer had paid. + const dimsWithTypes = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 23.4, capacityTons: 70 }, + byWagonTypeId: new Map([ + ['pw2-id', { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }], + ]), + }; + + it('charges a bulk booking the tare of ITS wagon type, not the representative', () => { + const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } }); + const need = service.needFor(booking, dimsWithTypes); + expect(need.wagons).toBe(30); + expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation + }); + + it('falls back to the representative dims when no wagon type is configured', () => { + const need = service.needFor(bulk(2100), dimsWithTypes); + expect(need.weightTons).toBe(2802); // 2100 + 30 × 23.4 (legacy behavior) + }); + + it('resolves a container booking through its container type', () => { + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 140, + bookingContainers: [ + { + quantity: 2, + wagonsRequired: 2, + containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' }, + }, + ], + }; + const need = service.needFor(booking, dimsWithTypes); + expect(need.wagons).toBe(2); + expect(need.weightTons).toBe(190.4); // 140 + 2 × 25.2 + expect(need.lengthMeters).toBeCloseTo(34.132, 3); // 2 × 17.066, not NW5's 13.966 + }); + }); }); 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 cf710e429..b8987e4e4 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 @@ -43,7 +43,6 @@ import { import { WagonTypeDimensions, bookingGrossWeightTons, - bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, trainHardCaps, @@ -61,11 +60,18 @@ import { Capacity, CorridorBudget, CorridorLeg, + OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; export type { Capacity } from './corridor-capacity.util'; +/** + * A train's fill limits: the base caps the corridor budget spends from, plus + * the locomotive overage tolerance spendable only on whole-booking admission. + */ +type TrainLimits = { base: Capacity; tolerance: OverageTolerance }; + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -74,13 +80,20 @@ interface RouteDayGroup { day: string; } +/** One wagon type's footprint: its length on the train, the tare it adds to the + * locomotive's gross load, and the payload it carries. */ +type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + /** - * Per-freight-type wagon dimensions used to size a booking's capacity draw: - * its length on the train and the tare it adds to the locomotive's gross load. + * Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds + * every wagon type so a booking is measured on the type its cargo/container type + * actually rides (the same FK resolution allocation uses); `container`/`bulk` are + * representative fallbacks for bookings whose type has no wagon type configured. */ type WagonDims = { - container: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; - bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + container: PerWagonDims; + bulk: PerWagonDims; + byWagonTypeId: Map; }; export type BatchBoardBookingState = @@ -571,7 +584,14 @@ export class BookingBatchService implements OnModuleInit { const partner = await this.dataSource .getRepository(Booking) - .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + .findOne({ + where: { id: partnerId }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply // waits; the partner's later accept will reserve the pair. if (!partner || partner.status !== 'FULLY_EXECUTED') { @@ -1518,24 +1538,27 @@ export class BookingBatchService implements OnModuleInit { if (await this.splitService.findOpenOffer(booking.id)) return null; const wagonDims = await this.loadWagonDims(); - const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); // The wagon-slot axis alone under-constrains the offer. On a weight- or // length-limited train (slots to spare, but e.g. only 798T of pull weight // left) sizing by slots either produced an offer the fits() check below // rejected, or — when the free slots exceeded the booking's own wagon // count — sizeOffer refused outright, so a bulk booking on a weight-bound - // train was never offered a split at all. Size across all three axes. - const perWagon = - booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; - const partial = sizePartialOfferWagons(budget, need.wagons, perWagon); + // train was never offered a split at all. Size across all three axes, + // measured on the booking's REAL wagon type — the same one allocation + // validates against. Bulk splits ride FULL wagons only: the offer never + // part-loads its last wagon. + const perWagon = this.dimsFor(booking, wagonDims); + const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { + fullWagonsOnly: booking.freightType === "BULK", + }); if (!partial) return null; const sized = await this.splitService.sizeOffer( booking, partial.wagons, need.wagons, - bulkCapacityTons, + perWagon.capacityTons, partial.maxCargoTons, ); if (!sized) return null; @@ -1545,13 +1568,9 @@ export class BookingBatchService implements OnModuleInit { weightTons: bookingGrossWeightTons( sized.offeredWeightTons, sized.offeredWagons, - this.tareFor(booking.freightType, wagonDims), - ), - lengthMeters: bookingTrainLengthMeters( - booking.freightType, - sized.offeredWagons, - this.lengthsOf(wagonDims), + perWagon.tareWeightTons, ), + lengthMeters: sized.offeredWagons * perWagon.lengthMeters, }; if (!this.fits(offeredNeed, budget)) return null; @@ -1569,14 +1588,6 @@ export class BookingBatchService implements OnModuleInit { return offeredNeed; } - private async loadBulkWagonCapacityTons(): Promise { - const cw3 = await this.dataSource - .getRepository(WagonType) - .findOne({ where: { code: "CW3" } }); - const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; - return capacity > 0 ? capacity : 60; - } - /** * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * how to treat a reservation with no deadline (durable path: leave it; timeout @@ -2270,8 +2281,10 @@ export class BookingBatchService implements OnModuleInit { // 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); + // shared count exactly as it bounds an individual booking's. A pair shares + // wagons, so the primary's wagon type stands for both partners. + const dims = this.dimsFor(primary, wagonDims); + const capacityTons = dims.capacityTons; const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; const byLength = @@ -2287,27 +2300,9 @@ export class BookingBatchService implements OnModuleInit { weightTons: bookingGrossWeightTons( cargoTons, sharedWagons, - this.tareFor(primary.freightType, wagonDims), + dims.tareWeightTons, ), - lengthMeters: bookingTrainLengthMeters( - primary.freightType, - sharedWagons, - this.lengthsOf(wagonDims), - ), - }; - } - - /** Per-wagon tare for the wagon type this freight rides on. */ - private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number { - return freightType === 'BULK' - ? wagonDims.bulk.tareWeightTons - : wagonDims.container.tareWeightTons; - } - - private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } { - return { - container: wagonDims.container.lengthMeters, - bulk: wagonDims.bulk.lengthMeters, + lengthMeters: sharedWagons * dims.lengthMeters, }; } @@ -2388,7 +2383,7 @@ export class BookingBatchService implements OnModuleInit { // 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 capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; @@ -2396,15 +2391,6 @@ export class BookingBatchService implements OnModuleInit { return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); } - private capacityFor( - freightType: string | null | undefined, - wagonDims: WagonDims, - ): number { - return freightType === "BULK" - ? wagonDims.bulk.capacityTons - : wagonDims.container.capacityTons; - } - /** * What one booking consumes along all three capacity axes. * @@ -2415,18 +2401,15 @@ export class BookingBatchService implements OnModuleInit { */ private needFor(booking: Booking, wagonDims: WagonDims): Capacity { const wagons = this.wagonsFor(booking, wagonDims); + const dims = this.dimsFor(booking, wagonDims); return { wagons, weightTons: bookingGrossWeightTons( Number(booking.cargoTotalWeightVgm ?? 0), wagons, - this.tareFor(booking.freightType, wagonDims), - ), - lengthMeters: bookingTrainLengthMeters( - booking.freightType, - wagons, - this.lengthsOf(wagonDims), + dims.tareWeightTons, ), + lengthMeters: wagons * dims.lengthMeters, }; } @@ -2439,14 +2422,16 @@ export class BookingBatchService implements OnModuleInit { } /** - * Hard caps for a schedule's train: gross pull weight, train length, and the + * Caps for a schedule's train: gross pull weight, train length, and the * length-derived wagon slot count (never a fixed 53). Bookings spend against - * these via {@link needFor}, whose weight axis is gross. + * `base` via {@link needFor}, whose weight axis is gross. The locomotive's + * overage tolerance is returned separately — the corridor budget spends it + * only to admit a booking whole, never to size a split. */ private async capacityLimits( locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, - ): Promise { + ): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -2466,9 +2451,15 @@ export class BookingBatchService implements OnModuleInit { }, ); return { - wagons: derived.maxWagonSlots, - weightTons: derived.maxWeightTons, - lengthMeters: derived.maxLengthMeters, + base: { + wagons: derived.maxWagonSlots, + weightTons: derived.baseWeightTons, + lengthMeters: derived.baseLengthMeters, + }, + tolerance: { + weightTons: derived.toleranceTons, + lengthMeters: derived.toleranceMeters, + }, }; } @@ -2479,11 +2470,11 @@ export class BookingBatchService implements OnModuleInit { rules: TrainSchedulingGlobalRules | null, ): Promise { const limits = await this.capacityLimits(locomotive, rules); - if ((schedule.maxWagons ?? 0) !== limits.wagons) { + if ((schedule.maxWagons ?? 0) !== limits.base.wagons) { await this.dataSource .getRepository(TrainSchedule) - .update(schedule.id, { maxWagons: limits.wagons }); - schedule.maxWagons = limits.wagons; + .update(schedule.id, { maxWagons: limits.base.wagons }); + schedule.maxWagons = limits.base.wagons; } } @@ -2510,14 +2501,20 @@ export class BookingBatchService implements OnModuleInit { ]; } - /** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */ + /** + * Every wagon type keyed by id (drives per-booking dims via the cargo/container + * type's wagon_type_id FK), plus representative fallbacks per freight type + * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has + * no wagon type configured yet. + */ private async loadWagonDims(): Promise { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + const types = await this.dataSource.getRepository(WagonType).find(); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); + const byWagonTypeId = new Map( + types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]), + ); const nw5 = byCode.get("NW5"); const cw3 = byCode.get("CW3"); // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload @@ -2535,6 +2532,33 @@ export class BookingBatchService implements OnModuleInit { tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS), }, + byWagonTypeId, + }; + } + + /** + * Dimensions of the wagon type THIS booking rides: bulk resolves through its + * cargo type's wagon_type_id, container through the first container line's + * type — the same FK resolution `resolveWagonType` applies when the paid + * booking is allocated. Board/fill math measured on a representative wagon + * while allocation validated the real one let a selected batch flunk the + * post-payment gross-weight check; sharing the resolution closes that gap. + * Falls back to the representative dims when the FK or relation is absent. + */ + private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const wagonTypeId = + booking.freightType === "BULK" + ? booking.cargoType?.wagonTypeId + : (booking.bookingContainers ?? []) + .map((line) => line.containerType?.wagonTypeId) + .find((id): id is string => Boolean(id)); + const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined; + if (!dims) return fallback; + return { + ...dims, + capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons, }; } @@ -2570,11 +2594,11 @@ export class BookingBatchService implements OnModuleInit { */ private async remainingBudget( schedule: TrainSchedule, - limits: Capacity, + limits: TrainLimits, wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); - const budget = new CorridorBudget(stops, limits); + const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); @@ -2599,9 +2623,12 @@ export class BookingBatchService implements OnModuleInit { const budget = await this.remainingBudget( schedule, { - wagons: schedule.maxWagons ?? 0, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, + base: { + wagons: schedule.maxWagons ?? 0, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, }, wagonDims, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts new file mode 100644 index 000000000..a00e1f59f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -0,0 +1,75 @@ +import { Capacity, CorridorBudget } from './corridor-capacity.util'; +import { sizePartialOfferWagons } from './train-capacity.util'; + +describe('corridor-capacity.util — overage tolerance', () => { + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const stops = ['yard-a', 'yard-b']; + const base: Capacity = { wagons: 44, weightTons: 3500, lengthMeters: 760 }; + const tolerance = { weightTons: 90, lengthMeters: 0 }; + + const need = (weightTons: number, wagons = 1, lengthMeters = 17): Capacity => ({ + wagons, + weightTons, + lengthMeters, + }); + + const budgetAt = (usedWeightTons: number): CorridorBudget => { + const budget = new CorridorBudget(stops, base, tolerance); + budget.subtract(need(usedWeightTons, 10, 170), budget.fullLeg()); + return budget; + }; + + it('admits a whole booking that overflows the base cap by less than the tolerance', () => { + // 3500T train, 90T tolerance, 3560T committed: a 25T booking still boards + // entire (3585 ≤ 3590). + const budget = budgetAt(3560); + expect(budget.fits(need(25), budget.fullLeg())).toBe(true); + }); + + it('rejects a whole booking that overflows past the tolerance — no partial admission', () => { + // Same train at 3560T: a 210T booking would need 3770 > 3590 — skipped. + const budget = budgetAt(3560); + expect(budget.fits(need(210), budget.fullLeg())).toBe(false); + }); + + it('caps stacked overage admissions at base + tolerance', () => { + // Small units may keep boarding inside the overage zone, but never past it. + const budget = budgetAt(3560); + budget.subtract(need(25), budget.fullLeg()); // now 3585 committed + expect(budget.fits(need(5), budget.fullLeg())).toBe(true); // 3590 exactly + expect(budget.fits(need(6), budget.fullLeg())).toBe(false); // 3591 > 3590 + }); + + it('excludes the tolerance from remainingFor, so split room never reaches into it', () => { + const budget = budgetAt(3400); + expect(budget.remainingFor(budget.fullLeg()).weightTons).toBe(100); + // Once a whole-unit admission spends the tolerance, base room goes negative. + const over = budgetAt(3560); + expect(over.remainingFor(over.fullLeg()).weightTons).toBe(-60); + }); + + it('yields no split offer once the base cap is spent — tolerance is whole-bookings-only', () => { + // The batch engine sizes splits from remainingFor; at/over base capacity + // that room cannot carry even one part-loaded wagon, so no offer opens. + const over = budgetAt(3560); + const room = over.remainingFor(over.fullLeg()); + expect(sizePartialOfferWagons(room, 15, pw2)).toBeNull(); + }); + + it('still offers a split while committed weight is under the base cap', () => { + // 744T of base room left: the boundary booking is offered the part that + // fits up to 3500, not up to 3590. + const budget = budgetAt(2756); + const room = budget.remainingFor(budget.fullLeg()); + expect(sizePartialOfferWagons(room, 15, pw2)).toEqual({ + wagons: 8, + maxCargoTons: 542.4, + }); + }); + + it('leaves fits() strict when no tolerance is configured', () => { + const strict = new CorridorBudget(stops, base); + strict.subtract(need(3500, 10, 170), strict.fullLeg()); + expect(strict.fits(need(1), strict.fullLeg())).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index b16b25179..dbf304141 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -63,18 +63,40 @@ export function stopYardsFor( return [originStationId, destinationStationId]; } -/** Per-edge capacity budget along a schedule's stop list. */ +/** Overage a locomotive may absorb beyond its base caps. */ +export interface OverageTolerance { + weightTons: number; + lengthMeters: number; +} + +/** + * Per-edge capacity budget along a schedule's stop list. + * + * `initial` must be the BASE caps (locomotive floored by rule caps, WITHOUT the + * overage tolerance). The tolerance is passed separately and is spendable only + * by admitting a unit WHOLE via {@link fits} — e.g. base 3500T + 90T tolerance, + * 3560T already committed: a 25T booking still boards entire (3585 ≤ 3590), a + * 210T booking does not. {@link remainingFor} deliberately excludes the + * tolerance (and goes negative once it is consumed), so split/partial offers + * sized from it can only fill up to the base cap and never spend the tolerance. + */ export class CorridorBudget { private readonly edges: Capacity[]; private readonly stopIndex: Map; + private readonly tolerance: OverageTolerance; constructor( readonly stops: string[], initial: Capacity, + tolerance?: Partial | null, ) { const edgeCount = Math.max(1, stops.length - 1); this.edges = Array.from({ length: edgeCount }, () => ({ ...initial })); this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + this.tolerance = { + weightTons: tolerance?.weightTons ?? 0, + lengthMeters: tolerance?.lengthMeters ?? 0, + }; } /** The leg between two stops, or null when they aren't on this corridor in order. */ @@ -99,7 +121,12 @@ export class CorridorBudget { return this.legOf(originYardId, destinationYardId) ?? this.fullLeg(); } - /** Remaining capacity usable by this leg = min across its edges. */ + /** + * Remaining BASE capacity usable by this leg = min across its edges. Excludes + * the overage tolerance and goes negative once a whole-unit admission has + * spent it — sizing a split from this can therefore never reach into the + * tolerance, and yields nothing at all once the base cap is exhausted. + */ remainingFor(leg: CorridorLeg): Capacity { let min = { ...this.edges[leg.fromEdge] }; for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) { @@ -113,8 +140,20 @@ export class CorridorBudget { return min; } + /** + * Whether a unit fits WHOLE on this leg. This is the only place the overage + * tolerance may be spent: the unit boards entirely or not at all, so weight + * and length may dip into the tolerance. Admission keeps the invariant + * `remaining ≥ -tolerance` on every edge, i.e. the train never exceeds + * base + tolerance no matter how many small units board in the overage zone. + */ fits(need: Capacity, leg: CorridorLeg): boolean { - return capacityFits(need, this.remainingFor(leg)); + const remaining = this.remainingFor(leg); + return ( + need.wagons <= remaining.wagons && + need.weightTons <= remaining.weightTons + this.tolerance.weightTons && + need.lengthMeters <= remaining.lengthMeters + this.tolerance.lengthMeters + ); } subtract(need: Capacity, leg: CorridorLeg): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index bce4207d3..d2f1dd7b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -107,7 +107,13 @@ export class IntercityService { for (const bookingId of bookingIds) { const booking = await this.dataSource .getRepository(Booking) - .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } }); + .findOne({ + where: { id: bookingId }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); if (!booking) { rejected.push({ bookingId, reason: 'Booking not found' }); continue; @@ -205,6 +211,8 @@ export class IntercityService { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) @@ -230,6 +238,8 @@ export class IntercityService { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index cd0411831..dd9234bdb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -74,6 +74,24 @@ describe('train-capacity.util', () => { expect(derived.maxWeightTons).toBe(3590); }); + it('reports the base caps and tolerance separately so filling can budget on base', () => { + const derived = deriveTrainCapacityFromLocomotive( + { + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + overageToleranceTons: 90, + overageToleranceMeters: 20, + }, + [pw2], + ); + expect(derived.baseWeightTons).toBe(3500); + expect(derived.baseLengthMeters).toBe(760); + expect(derived.toleranceTons).toBe(90); + expect(derived.toleranceMeters).toBe(20); + expect(derived.baseWeightTons + derived.toleranceTons).toBe(derived.maxWeightTons); + expect(derived.baseLengthMeters + derived.toleranceMeters).toBe(derived.maxLengthMeters); + }); + it('ignores overage tolerance when unset (strict cap)', () => { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, @@ -235,5 +253,47 @@ describe('train-capacity.util', () => { sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2), ).toBeNull(); }); + + describe('fullWagonsOnly (bulk)', () => { + it('offers only whole full wagons — each costs capacity + tare of gross room', () => { + // 704T of pull weight left. A full PW2 wagon is 70 + 25.2 = 95.2T gross, + // so 7 fit (666.4T) and the 8th (761.6T) does not. Cargo is exactly + // 7 × 70 = 490T — the last wagon is never part-loaded into the leftover. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 704, lengthMeters: 500 }, + 9, + pw2, + { fullWagonsOnly: true }, + ); + expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 }); + }); + + it('never squeezes a part-loaded wagon into leftover weight room', () => { + // Same 744T room as the part-load scenario above: the scan would pick + // 8 wagons hauling 542.4T (last wagon at 52.4/70). Full-wagon sizing + // stops at 7 fully loaded wagons. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 744, lengthMeters: 500 }, + 15, + pw2, + { fullWagonsOnly: true }, + ); + expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 }); + }); + + it('returns null when the room cannot take even one FULL wagon', () => { + // 67.6T left (3590 cap − 3522.4 boarded): a part-loaded wagon would fit + // (25.2 tare + 42.4 cargo) but a full one (95.2 gross) does not — the + // booking must be skipped entirely, not trimmed onto the train. + expect( + sizePartialOfferWagons( + { wagons: 40, weightTons: 67.6, lengthMeters: 500 }, + 3, + pw2, + { fullWagonsOnly: true }, + ), + ).toBeNull(); + }); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 372b29357..463ae7ea8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -52,6 +52,12 @@ export type DerivedTrainCapacity = { maxLengthMeters: number; /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; + /** Caps WITHOUT the overage tolerance — what batch filling budgets against. */ + baseWeightTons: number; + baseLengthMeters: number; + /** Overage spendable only by admitting a booking whole, never by a split. */ + toleranceTons: number; + toleranceMeters: number; }; /** What a consist currently uses, and what is left on each axis. */ @@ -88,28 +94,48 @@ export function grossWagonWeightTons(slot: Pick num(w.lengthMeters)) @@ -137,9 +163,9 @@ export function deriveTrainCapacityFromLocomotive( const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; const maxWagonSlots = - minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0; + minLength > 0 ? Math.max(0, Math.floor(caps.maxLengthMeters / minLength)) : 0; - return { maxWeightTons, maxLengthMeters, maxWagonSlots }; + return { ...caps, maxWagonSlots }; } /** @@ -265,17 +291,33 @@ export function bookingGrossWeightTons( * returns the count that maximizes the cargo carried, with the cargo cap the * caller should apply. Null when not even one part-loaded wagon fits. The * offer is a strict subset of the booking: never all `bookingWagons`. + * + * `fullWagonsOnly` (bulk): every offered wagon rides at its full rated payload, + * so each wagon costs `capacityTons + tareWeightTons` of gross weight room and + * the offer is the largest whole-wagon count whose gross fits — never a + * part-loaded last wagon squeezed into leftover pull weight. */ export function sizePartialOfferWagons( room: { wagons: number; weightTons: number; lengthMeters: number }, bookingWagons: number, perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number }, + opts?: { fullWagonsOnly?: boolean }, ): { wagons: number; maxCargoTons: number } | null { const maxByLength = perWagon.lengthMeters > 0 ? Math.floor(room.lengthMeters / perWagon.lengthMeters) : room.wagons; const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1); + + if (opts?.fullWagonsOnly) { + const grossPerWagon = perWagon.capacityTons + perWagon.tareWeightTons; + const maxByWeight = + grossPerWagon > 0 ? Math.floor(room.weightTons / grossPerWagon) : 0; + const wagons = Math.min(ceiling, maxByWeight); + if (wagons < 1) return null; + return { wagons, maxCargoTons: round3(wagons * perWagon.capacityTons) }; + } + let wagons = 0; let bestCargoTons = 0; for (let w = 1; w <= ceiling; w += 1) { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 558f15ab4..34ff013cb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,7 +20,6 @@ import { AlertTriangle, ArrowLeft, ArrowLeftRight, - Boxes, CalendarDays, CheckCircle2, ClipboardCheck, @@ -890,14 +889,6 @@ export default function BatchScheduleDetailPage() {