mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +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.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
@@ -1030,6 +1031,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||||
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||||
@@ -1069,6 +1071,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||||
@@ -1134,6 +1137,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
@@ -1148,6 +1152,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||||
.getMany();
|
.getMany();
|
||||||
@@ -1177,6 +1182,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return this.repository
|
return this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
TrainScheduleBooking,
|
TrainScheduleBooking,
|
||||||
'sb',
|
'sb',
|
||||||
|
|||||||
@@ -752,12 +752,18 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
null as never,
|
null as never,
|
||||||
) as unknown as {
|
) as unknown as {
|
||||||
wagonsFor(booking: unknown, dims: unknown): number;
|
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.
|
// PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m.
|
||||||
const dims = {
|
const dims = {
|
||||||
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
|
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
|
||||||
bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 },
|
bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 },
|
||||||
|
byWagonTypeId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulk = (cargoTons: number, over: Record<string, unknown> = {}) => ({
|
const bulk = (cargoTons: number, over: Record<string, unknown> = {}) => ({
|
||||||
@@ -815,4 +821,49 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
};
|
};
|
||||||
expect(service.wagonsFor(booking, dims)).toBe(2);
|
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 {
|
import {
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
bookingGrossWeightTons,
|
bookingGrossWeightTons,
|
||||||
bookingTrainLengthMeters,
|
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
sizePartialOfferWagons,
|
sizePartialOfferWagons,
|
||||||
trainHardCaps,
|
trainHardCaps,
|
||||||
@@ -61,11 +60,18 @@ import {
|
|||||||
Capacity,
|
Capacity,
|
||||||
CorridorBudget,
|
CorridorBudget,
|
||||||
CorridorLeg,
|
CorridorLeg,
|
||||||
|
OverageTolerance,
|
||||||
stopYardsFor,
|
stopYardsFor,
|
||||||
} from './corridor-capacity.util';
|
} from './corridor-capacity.util';
|
||||||
|
|
||||||
export type { Capacity } 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. */
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
interface RouteDayGroup {
|
interface RouteDayGroup {
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
@@ -74,13 +80,20 @@ interface RouteDayGroup {
|
|||||||
day: string;
|
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:
|
* Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds
|
||||||
* its length on the train and the tare it adds to the locomotive's gross load.
|
* 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 = {
|
type WagonDims = {
|
||||||
container: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
|
container: PerWagonDims;
|
||||||
bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
|
bulk: PerWagonDims;
|
||||||
|
byWagonTypeId: Map<string, PerWagonDims>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BatchBoardBookingState =
|
export type BatchBoardBookingState =
|
||||||
@@ -571,7 +584,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const partner = await this.dataSource
|
const partner = await this.dataSource
|
||||||
.getRepository(Booking)
|
.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
|
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
|
||||||
// waits; the partner's later accept will reserve the pair.
|
// waits; the partner's later accept will reserve the pair.
|
||||||
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||||
@@ -1518,24 +1538,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
|
||||||
|
|
||||||
// The wagon-slot axis alone under-constrains the offer. On a weight- or
|
// 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
|
// 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
|
// left) sizing by slots either produced an offer the fits() check below
|
||||||
// rejected, or — when the free slots exceeded the booking's own wagon
|
// rejected, or — when the free slots exceeded the booking's own wagon
|
||||||
// count — sizeOffer refused outright, so a bulk booking on a weight-bound
|
// 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.
|
// train was never offered a split at all. Size across all three axes,
|
||||||
const perWagon =
|
// measured on the booking's REAL wagon type — the same one allocation
|
||||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
// validates against. Bulk splits ride FULL wagons only: the offer never
|
||||||
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon);
|
// 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;
|
if (!partial) return null;
|
||||||
|
|
||||||
const sized = await this.splitService.sizeOffer(
|
const sized = await this.splitService.sizeOffer(
|
||||||
booking,
|
booking,
|
||||||
partial.wagons,
|
partial.wagons,
|
||||||
need.wagons,
|
need.wagons,
|
||||||
bulkCapacityTons,
|
perWagon.capacityTons,
|
||||||
partial.maxCargoTons,
|
partial.maxCargoTons,
|
||||||
);
|
);
|
||||||
if (!sized) return null;
|
if (!sized) return null;
|
||||||
@@ -1545,13 +1568,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
weightTons: bookingGrossWeightTons(
|
weightTons: bookingGrossWeightTons(
|
||||||
sized.offeredWeightTons,
|
sized.offeredWeightTons,
|
||||||
sized.offeredWagons,
|
sized.offeredWagons,
|
||||||
this.tareFor(booking.freightType, wagonDims),
|
perWagon.tareWeightTons,
|
||||||
),
|
|
||||||
lengthMeters: bookingTrainLengthMeters(
|
|
||||||
booking.freightType,
|
|
||||||
sized.offeredWagons,
|
|
||||||
this.lengthsOf(wagonDims),
|
|
||||||
),
|
),
|
||||||
|
lengthMeters: sized.offeredWagons * perWagon.lengthMeters,
|
||||||
};
|
};
|
||||||
if (!this.fits(offeredNeed, budget)) return null;
|
if (!this.fits(offeredNeed, budget)) return null;
|
||||||
|
|
||||||
@@ -1569,14 +1588,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return offeredNeed;
|
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
|
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
|
||||||
* how to treat a reservation with no deadline (durable path: leave it; timeout
|
* 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
|
// Consolidation shares TEU slots, never rated payload: the pair still needs
|
||||||
// enough wagons to carry its combined cargo, so the weight axis bounds the
|
// enough wagons to carry its combined cargo, so the weight axis bounds the
|
||||||
// shared count exactly as it bounds an individual booking's.
|
// shared count exactly as it bounds an individual booking's. A pair shares
|
||||||
const capacityTons = this.capacityFor(primary.freightType, wagonDims);
|
// wagons, so the primary's wagon type stands for both partners.
|
||||||
|
const dims = this.dimsFor(primary, wagonDims);
|
||||||
|
const capacityTons = dims.capacityTons;
|
||||||
const byWeight =
|
const byWeight =
|
||||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||||
const byLength =
|
const byLength =
|
||||||
@@ -2287,27 +2300,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
weightTons: bookingGrossWeightTons(
|
weightTons: bookingGrossWeightTons(
|
||||||
cargoTons,
|
cargoTons,
|
||||||
sharedWagons,
|
sharedWagons,
|
||||||
this.tareFor(primary.freightType, wagonDims),
|
dims.tareWeightTons,
|
||||||
),
|
),
|
||||||
lengthMeters: bookingTrainLengthMeters(
|
lengthMeters: sharedWagons * dims.lengthMeters,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2388,7 +2383,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
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 cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
const byWeight =
|
const byWeight =
|
||||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
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);
|
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.
|
* 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 {
|
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
|
||||||
const wagons = this.wagonsFor(booking, wagonDims);
|
const wagons = this.wagonsFor(booking, wagonDims);
|
||||||
|
const dims = this.dimsFor(booking, wagonDims);
|
||||||
return {
|
return {
|
||||||
wagons,
|
wagons,
|
||||||
weightTons: bookingGrossWeightTons(
|
weightTons: bookingGrossWeightTons(
|
||||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||||
wagons,
|
wagons,
|
||||||
this.tareFor(booking.freightType, wagonDims),
|
dims.tareWeightTons,
|
||||||
),
|
|
||||||
lengthMeters: bookingTrainLengthMeters(
|
|
||||||
booking.freightType,
|
|
||||||
wagons,
|
|
||||||
this.lengthsOf(wagonDims),
|
|
||||||
),
|
),
|
||||||
|
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
|
* 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(
|
private async capacityLimits(
|
||||||
locomotive: Locomotive,
|
locomotive: Locomotive,
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
rules: TrainSchedulingGlobalRules | null,
|
||||||
): Promise<Capacity> {
|
): Promise<TrainLimits> {
|
||||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{
|
{
|
||||||
@@ -2466,9 +2451,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
wagons: derived.maxWagonSlots,
|
base: {
|
||||||
weightTons: derived.maxWeightTons,
|
wagons: derived.maxWagonSlots,
|
||||||
lengthMeters: derived.maxLengthMeters,
|
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,
|
rules: TrainSchedulingGlobalRules | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
|
if ((schedule.maxWagons ?? 0) !== limits.base.wagons) {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(schedule.id, { maxWagons: limits.wagons });
|
.update(schedule.id, { maxWagons: limits.base.wagons });
|
||||||
schedule.maxWagons = limits.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> {
|
private async loadWagonDims(): Promise<WagonDims> {
|
||||||
const types = await this.dataSource.getRepository(WagonType).find({
|
const types = await this.dataSource.getRepository(WagonType).find();
|
||||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
|
||||||
});
|
|
||||||
const byCode = new Map(
|
const byCode = new Map(
|
||||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||||
);
|
);
|
||||||
|
const byWagonTypeId = new Map(
|
||||||
|
types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]),
|
||||||
|
);
|
||||||
const nw5 = byCode.get("NW5");
|
const nw5 = byCode.get("NW5");
|
||||||
const cw3 = byCode.get("CW3");
|
const cw3 = byCode.get("CW3");
|
||||||
// capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload
|
// 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,
|
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
|
||||||
capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_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(
|
private async remainingBudget(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
limits: Capacity,
|
limits: TrainLimits,
|
||||||
wagonDims: WagonDims,
|
wagonDims: WagonDims,
|
||||||
): Promise<CorridorBudget> {
|
): Promise<CorridorBudget> {
|
||||||
const stops = await this.stopsForSchedule(schedule);
|
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 ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
.filter((b): b is Booking => Boolean(b));
|
.filter((b): b is Booking => Boolean(b));
|
||||||
@@ -2599,9 +2623,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const budget = await this.remainingBudget(
|
const budget = await this.remainingBudget(
|
||||||
schedule,
|
schedule,
|
||||||
{
|
{
|
||||||
wagons: schedule.maxWagons ?? 0,
|
base: {
|
||||||
weightTons: Number.POSITIVE_INFINITY,
|
wagons: schedule.maxWagons ?? 0,
|
||||||
lengthMeters: Number.POSITIVE_INFINITY,
|
weightTons: Number.POSITIVE_INFINITY,
|
||||||
|
lengthMeters: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||||
},
|
},
|
||||||
wagonDims,
|
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];
|
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 {
|
export class CorridorBudget {
|
||||||
private readonly edges: Capacity[];
|
private readonly edges: Capacity[];
|
||||||
private readonly stopIndex: Map<string, number>;
|
private readonly stopIndex: Map<string, number>;
|
||||||
|
private readonly tolerance: OverageTolerance;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly stops: string[],
|
readonly stops: string[],
|
||||||
initial: Capacity,
|
initial: Capacity,
|
||||||
|
tolerance?: Partial<OverageTolerance> | null,
|
||||||
) {
|
) {
|
||||||
const edgeCount = Math.max(1, stops.length - 1);
|
const edgeCount = Math.max(1, stops.length - 1);
|
||||||
this.edges = Array.from({ length: edgeCount }, () => ({ ...initial }));
|
this.edges = Array.from({ length: edgeCount }, () => ({ ...initial }));
|
||||||
this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
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. */
|
/** 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();
|
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 {
|
remainingFor(leg: CorridorLeg): Capacity {
|
||||||
let min = { ...this.edges[leg.fromEdge] };
|
let min = { ...this.edges[leg.fromEdge] };
|
||||||
for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) {
|
for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) {
|
||||||
@@ -113,8 +140,20 @@ export class CorridorBudget {
|
|||||||
return min;
|
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 {
|
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 {
|
subtract(need: Capacity, leg: CorridorLeg): void {
|
||||||
|
|||||||
@@ -107,7 +107,13 @@ export class IntercityService {
|
|||||||
for (const bookingId of bookingIds) {
|
for (const bookingId of bookingIds) {
|
||||||
const booking = await this.dataSource
|
const booking = await this.dataSource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
|
.findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
relations: {
|
||||||
|
bookingContainers: { containerType: true },
|
||||||
|
cargoType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
rejected.push({ bookingId, reason: 'Booking not found' });
|
rejected.push({ bookingId, reason: 'Booking not found' });
|
||||||
continue;
|
continue;
|
||||||
@@ -205,6 +211,8 @@ export class IntercityService {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
@@ -230,6 +238,8 @@ export class IntercityService {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ describe('train-capacity.util', () => {
|
|||||||
expect(derived.maxWeightTons).toBe(3590);
|
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)', () => {
|
it('ignores overage tolerance when unset (strict cap)', () => {
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||||
@@ -235,5 +253,47 @@ describe('train-capacity.util', () => {
|
|||||||
sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2),
|
sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2),
|
||||||
).toBeNull();
|
).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;
|
maxLengthMeters: number;
|
||||||
/** Length-derived slot count. Weight is enforced separately against real cargo. */
|
/** Length-derived slot count. Weight is enforced separately against real cargo. */
|
||||||
maxWagonSlots: number;
|
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. */
|
/** 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
|
* Hard caps for a train: the locomotive's own limits, floored by the global rule
|
||||||
* caps, then widened by the locomotive's overage tolerance.
|
* 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(
|
export function trainHardCaps(
|
||||||
locomotive: LocomotiveLimits,
|
locomotive: LocomotiveLimits,
|
||||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
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 overageTons = num(locomotive.overageToleranceTons);
|
||||||
const overageMeters = num(locomotive.overageToleranceMeters);
|
const overageMeters = num(locomotive.overageToleranceMeters);
|
||||||
|
|
||||||
const weight =
|
const baseWeight = Math.min(
|
||||||
Math.min(
|
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
||||||
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
);
|
||||||
) + overageTons;
|
const baseLength = Math.min(
|
||||||
const length =
|
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
||||||
Math.min(
|
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||||
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
);
|
||||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
|
||||||
) + overageMeters;
|
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 {
|
return {
|
||||||
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
|
maxWeightTons: baseWeightTons + toleranceTons,
|
||||||
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
|
maxLengthMeters: baseLengthMeters + toleranceMeters,
|
||||||
|
baseWeightTons,
|
||||||
|
baseLengthMeters,
|
||||||
|
toleranceTons,
|
||||||
|
toleranceMeters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +155,7 @@ export function deriveTrainCapacityFromLocomotive(
|
|||||||
wagonTypes: WagonTypeDimensions[],
|
wagonTypes: WagonTypeDimensions[],
|
||||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||||
): DerivedTrainCapacity {
|
): DerivedTrainCapacity {
|
||||||
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
|
const caps = trainHardCaps(locomotive, ruleCaps);
|
||||||
|
|
||||||
const lengths = wagonTypes
|
const lengths = wagonTypes
|
||||||
.map((w) => num(w.lengthMeters))
|
.map((w) => num(w.lengthMeters))
|
||||||
@@ -137,9 +163,9 @@ export function deriveTrainCapacityFromLocomotive(
|
|||||||
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
|
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
|
||||||
|
|
||||||
const maxWagonSlots =
|
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
|
* 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
|
* caller should apply. Null when not even one part-loaded wagon fits. The
|
||||||
* offer is a strict subset of the booking: never all `bookingWagons`.
|
* 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(
|
export function sizePartialOfferWagons(
|
||||||
room: { wagons: number; weightTons: number; lengthMeters: number },
|
room: { wagons: number; weightTons: number; lengthMeters: number },
|
||||||
bookingWagons: number,
|
bookingWagons: number,
|
||||||
perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number },
|
perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number },
|
||||||
|
opts?: { fullWagonsOnly?: boolean },
|
||||||
): { wagons: number; maxCargoTons: number } | null {
|
): { wagons: number; maxCargoTons: number } | null {
|
||||||
const maxByLength =
|
const maxByLength =
|
||||||
perWagon.lengthMeters > 0
|
perWagon.lengthMeters > 0
|
||||||
? Math.floor(room.lengthMeters / perWagon.lengthMeters)
|
? Math.floor(room.lengthMeters / perWagon.lengthMeters)
|
||||||
: room.wagons;
|
: room.wagons;
|
||||||
const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1);
|
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 wagons = 0;
|
||||||
let bestCargoTons = 0;
|
let bestCargoTons = 0;
|
||||||
for (let w = 1; w <= ceiling; w += 1) {
|
for (let w = 1; w <= ceiling; w += 1) {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Boxes,
|
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -890,14 +889,6 @@ export default function BatchScheduleDetailPage() {
|
|||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
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",
|
label: "Train length",
|
||||||
value: data.capacity.maxLengthMeters
|
value: data.capacity.maxLengthMeters
|
||||||
|
|||||||
Reference in New Issue
Block a user