test batch system

This commit is contained in:
Marshal
2026-07-09 17:04:25 +00:00
parent 1690f9e498
commit a7d05fba34
9 changed files with 413 additions and 111 deletions

View File

@@ -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<string, PerWagonDims>;
};
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<number> {
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<Capacity> {
): Promise<TrainLimits> {
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<void> {
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<WagonDims> {
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<CorridorBudget> {
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,
);