mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
test batch system
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user