Merge pull request #586 from Tria-plc/freight_feature/usermanagement

test batch system
This commit is contained in:
marshal
2026-07-09 20:15:39 +03:00
committed by GitHub
9 changed files with 413 additions and 111 deletions

View File

@@ -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',

View File

@@ -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
});
});
});

View File

@@ -43,7 +43,6 @@ import {
import {
WagonTypeDimensions,
bookingGrossWeightTons,
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
trainHardCaps,
@@ -61,11 +60,18 @@ import {
Capacity,
CorridorBudget,
CorridorLeg,
OverageTolerance,
stopYardsFor,
} from './corridor-capacity.util';
export type { Capacity } from './corridor-capacity.util';
/**
* A train's fill limits: the base caps the corridor budget spends from, plus
* the locomotive overage tolerance spendable only on whole-booking admission.
*/
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -74,13 +80,20 @@ interface RouteDayGroup {
day: string;
}
/** One wagon type's footprint: its length on the train, the tare it adds to the
* locomotive's gross load, and the payload it carries. */
type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number };
/**
* Per-freight-type wagon dimensions used to size a booking's capacity draw:
* its length on the train and the tare it adds to the locomotive's gross load.
* Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds
* every wagon type so a booking is measured on the type its cargo/container type
* actually rides (the same FK resolution allocation uses); `container`/`bulk` are
* representative fallbacks for bookings whose type has no wagon type configured.
*/
type WagonDims = {
container: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
container: PerWagonDims;
bulk: PerWagonDims;
byWagonTypeId: Map<string, PerWagonDims>;
};
export type BatchBoardBookingState =
@@ -571,7 +584,14 @@ export class BookingBatchService implements OnModuleInit {
const partner = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
.findOne({
where: { id: partnerId },
relations: {
company: true,
bookingContainers: { containerType: true },
cargoType: true,
},
});
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
// waits; the partner's later accept will reserve the pair.
if (!partner || partner.status !== 'FULLY_EXECUTED') {
@@ -1518,24 +1538,27 @@ export class BookingBatchService implements OnModuleInit {
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonDims = await this.loadWagonDims();
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
// The wagon-slot axis alone under-constrains the offer. On a weight- or
// length-limited train (slots to spare, but e.g. only 798T of pull weight
// left) sizing by slots either produced an offer the fits() check below
// rejected, or — when the free slots exceeded the booking's own wagon
// count — sizeOffer refused outright, so a bulk booking on a weight-bound
// train was never offered a split at all. Size across all three axes.
const perWagon =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon);
// train was never offered a split at all. Size across all three axes,
// measured on the booking's REAL wagon type — the same one allocation
// validates against. Bulk splits ride FULL wagons only: the offer never
// part-loads its last wagon.
const perWagon = this.dimsFor(booking, wagonDims);
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, {
fullWagonsOnly: booking.freightType === "BULK",
});
if (!partial) return null;
const sized = await this.splitService.sizeOffer(
booking,
partial.wagons,
need.wagons,
bulkCapacityTons,
perWagon.capacityTons,
partial.maxCargoTons,
);
if (!sized) return null;
@@ -1545,13 +1568,9 @@ export class BookingBatchService implements OnModuleInit {
weightTons: bookingGrossWeightTons(
sized.offeredWeightTons,
sized.offeredWagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
sized.offeredWagons,
this.lengthsOf(wagonDims),
perWagon.tareWeightTons,
),
lengthMeters: sized.offeredWagons * perWagon.lengthMeters,
};
if (!this.fits(offeredNeed, budget)) return null;
@@ -1569,14 +1588,6 @@ export class BookingBatchService implements OnModuleInit {
return offeredNeed;
}
private async loadBulkWagonCapacityTons(): Promise<number> {
const cw3 = await this.dataSource
.getRepository(WagonType)
.findOne({ where: { code: "CW3" } });
const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60;
return capacity > 0 ? capacity : 60;
}
/**
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
* how to treat a reservation with no deadline (durable path: leave it; timeout
@@ -2270,8 +2281,10 @@ export class BookingBatchService implements OnModuleInit {
// Consolidation shares TEU slots, never rated payload: the pair still needs
// enough wagons to carry its combined cargo, so the weight axis bounds the
// shared count exactly as it bounds an individual booking's.
const capacityTons = this.capacityFor(primary.freightType, wagonDims);
// shared count exactly as it bounds an individual booking's. A pair shares
// wagons, so the primary's wagon type stands for both partners.
const dims = this.dimsFor(primary, wagonDims);
const capacityTons = dims.capacityTons;
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
const byLength =
@@ -2287,27 +2300,9 @@ export class BookingBatchService implements OnModuleInit {
weightTons: bookingGrossWeightTons(
cargoTons,
sharedWagons,
this.tareFor(primary.freightType, wagonDims),
dims.tareWeightTons,
),
lengthMeters: bookingTrainLengthMeters(
primary.freightType,
sharedWagons,
this.lengthsOf(wagonDims),
),
};
}
/** Per-wagon tare for the wagon type this freight rides on. */
private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number {
return freightType === 'BULK'
? wagonDims.bulk.tareWeightTons
: wagonDims.container.tareWeightTons;
}
private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } {
return {
container: wagonDims.container.lengthMeters,
bulk: wagonDims.bulk.lengthMeters,
lengthMeters: sharedWagons * dims.lengthMeters,
};
}
@@ -2388,7 +2383,7 @@ export class BookingBatchService implements OnModuleInit {
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const capacityTons = this.capacityFor(booking.freightType, wagonDims);
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
@@ -2396,15 +2391,6 @@ export class BookingBatchService implements OnModuleInit {
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
}
private capacityFor(
freightType: string | null | undefined,
wagonDims: WagonDims,
): number {
return freightType === "BULK"
? wagonDims.bulk.capacityTons
: wagonDims.container.capacityTons;
}
/**
* What one booking consumes along all three capacity axes.
*
@@ -2415,18 +2401,15 @@ export class BookingBatchService implements OnModuleInit {
*/
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
const wagons = this.wagonsFor(booking, wagonDims);
const dims = this.dimsFor(booking, wagonDims);
return {
wagons,
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
wagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
wagons,
this.lengthsOf(wagonDims),
dims.tareWeightTons,
),
lengthMeters: wagons * dims.lengthMeters,
};
}
@@ -2439,14 +2422,16 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* Hard caps for a schedule's train: gross pull weight, train length, and the
* Caps for a schedule's train: gross pull weight, train length, and the
* length-derived wagon slot count (never a fixed 53). Bookings spend against
* these via {@link needFor}, whose weight axis is gross.
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
* overage tolerance is returned separately — the corridor budget spends it
* only to admit a booking whole, never to size a split.
*/
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<Capacity> {
): Promise<TrainLimits> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
@@ -2466,9 +2451,15 @@ export class BookingBatchService implements OnModuleInit {
},
);
return {
wagons: derived.maxWagonSlots,
weightTons: derived.maxWeightTons,
lengthMeters: derived.maxLengthMeters,
base: {
wagons: derived.maxWagonSlots,
weightTons: derived.baseWeightTons,
lengthMeters: derived.baseLengthMeters,
},
tolerance: {
weightTons: derived.toleranceTons,
lengthMeters: derived.toleranceMeters,
},
};
}
@@ -2479,11 +2470,11 @@ export class BookingBatchService implements OnModuleInit {
rules: TrainSchedulingGlobalRules | null,
): Promise<void> {
const limits = await this.capacityLimits(locomotive, rules);
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
if ((schedule.maxWagons ?? 0) !== limits.base.wagons) {
await this.dataSource
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: limits.wagons });
schedule.maxWagons = limits.wagons;
.update(schedule.id, { maxWagons: limits.base.wagons });
schedule.maxWagons = limits.base.wagons;
}
}
@@ -2510,14 +2501,20 @@ export class BookingBatchService implements OnModuleInit {
];
}
/** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */
/**
* Every wagon type keyed by id (drives per-booking dims via the cargo/container
* type's wagon_type_id FK), plus representative fallbacks per freight type
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
* no wagon type configured yet.
*/
private async loadWagonDims(): Promise<WagonDims> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: "NW5" }, { code: "CW3" }],
});
const types = await this.dataSource.getRepository(WagonType).find();
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
const byWagonTypeId = new Map(
types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]),
);
const nw5 = byCode.get("NW5");
const cw3 = byCode.get("CW3");
// capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload
@@ -2535,6 +2532,33 @@ export class BookingBatchService implements OnModuleInit {
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS),
},
byWagonTypeId,
};
}
/**
* Dimensions of the wagon type THIS booking rides: bulk resolves through its
* cargo type's wagon_type_id, container through the first container line's
* type — the same FK resolution `resolveWagonType` applies when the paid
* booking is allocated. Board/fill math measured on a representative wagon
* while allocation validated the real one let a selected batch flunk the
* post-payment gross-weight check; sharing the resolution closes that gap.
* Falls back to the representative dims when the FK or relation is absent.
*/
private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
const fallback =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const wagonTypeId =
booking.freightType === "BULK"
? booking.cargoType?.wagonTypeId
: (booking.bookingContainers ?? [])
.map((line) => line.containerType?.wagonTypeId)
.find((id): id is string => Boolean(id));
const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
if (!dims) return fallback;
return {
...dims,
capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons,
};
}
@@ -2570,11 +2594,11 @@ export class BookingBatchService implements OnModuleInit {
*/
private async remainingBudget(
schedule: TrainSchedule,
limits: Capacity,
limits: TrainLimits,
wagonDims: WagonDims,
): Promise<CorridorBudget> {
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
@@ -2599,9 +2623,12 @@ export class BookingBatchService implements OnModuleInit {
const budget = await this.remainingBudget(
schedule,
{
wagons: schedule.maxWagons ?? 0,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
base: {
wagons: schedule.maxWagons ?? 0,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
},
wagonDims,
);

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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'`)

View File

@@ -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();
});
});
});
});

View File

@@ -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) {

View File

@@ -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