mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge pull request #586 from Tria-plc/freight_feature/usermanagement
test batch system
This commit is contained in:
@@ -999,6 +999,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.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<Booking> {
|
||||
.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<Booking> {
|
||||
.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<Booking> {
|
||||
.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<Booking> {
|
||||
.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<Booking> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.innerJoin(
|
||||
TrainScheduleBooking,
|
||||
'sb',
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>;
|
||||
private readonly tolerance: OverageTolerance;
|
||||
|
||||
constructor(
|
||||
readonly stops: string[],
|
||||
initial: Capacity,
|
||||
tolerance?: Partial<OverageTolerance> | 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 {
|
||||
|
||||
@@ -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'`)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ConsistSlot, 'tareWeightTons' |
|
||||
/**
|
||||
* Hard caps for a train: the locomotive's own limits, floored by the global rule
|
||||
* caps, then widened by the locomotive's overage tolerance.
|
||||
*
|
||||
* `base*` are the caps BEFORE the tolerance is added. The tolerance is not
|
||||
* general-purpose headroom: batch filling budgets against the base caps and may
|
||||
* spend the tolerance only to admit a booking WHOLE (never to size a split), so
|
||||
* both figures are returned. `base + tolerance === max` always holds, including
|
||||
* the fallback path.
|
||||
*/
|
||||
export function trainHardCaps(
|
||||
locomotive: LocomotiveLimits,
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): { maxWeightTons: number; maxLengthMeters: number } {
|
||||
): {
|
||||
maxWeightTons: number;
|
||||
maxLengthMeters: number;
|
||||
baseWeightTons: number;
|
||||
baseLengthMeters: number;
|
||||
toleranceTons: number;
|
||||
toleranceMeters: number;
|
||||
} {
|
||||
const overageTons = num(locomotive.overageToleranceTons);
|
||||
const overageMeters = num(locomotive.overageToleranceMeters);
|
||||
|
||||
const weight =
|
||||
Math.min(
|
||||
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
) + overageTons;
|
||||
const length =
|
||||
Math.min(
|
||||
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
) + overageMeters;
|
||||
const baseWeight = Math.min(
|
||||
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
);
|
||||
const baseLength = Math.min(
|
||||
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
);
|
||||
|
||||
const baseWeightTons = Number.isFinite(baseWeight) ? baseWeight : MAX_FALLBACK_WEIGHT;
|
||||
const baseLengthMeters = Number.isFinite(baseLength) ? baseLength : MAX_FALLBACK_LENGTH;
|
||||
const toleranceTons = Number.isFinite(baseWeight) ? overageTons : 0;
|
||||
const toleranceMeters = Number.isFinite(baseLength) ? overageMeters : 0;
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
|
||||
maxWeightTons: baseWeightTons + toleranceTons,
|
||||
maxLengthMeters: baseLengthMeters + toleranceMeters,
|
||||
baseWeightTons,
|
||||
baseLengthMeters,
|
||||
toleranceTons,
|
||||
toleranceMeters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +155,7 @@ export function deriveTrainCapacityFromLocomotive(
|
||||
wagonTypes: WagonTypeDimensions[],
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): DerivedTrainCapacity {
|
||||
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
|
||||
const caps = trainHardCaps(locomotive, ruleCaps);
|
||||
|
||||
const lengths = wagonTypes
|
||||
.map((w) => 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) {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
@@ -890,14 +889,6 @@ export default function BatchScheduleDetailPage() {
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.maxWagons
|
||||
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||
: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
|
||||
Reference in New Issue
Block a user