leg-aware capacity and wagon sharing

This commit is contained in:
Marshal
2026-07-30 17:21:44 +00:00
parent 8f25fb3e8b
commit acef6870e9
15 changed files with 653 additions and 119 deletions

View File

@@ -127,6 +127,10 @@ export interface ExportSpaceReport {
*/
export interface ExportTrainOption {
scheduleId: string;
/** Schedule's train number (falls back to the built train's number). */
trainNumber: string | null;
/** Built train's name/code, when the schedule runs a Train Builder train. */
trainName: string | null;
departure: Date;
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
bookingClosesAt: Date | null;
@@ -297,11 +301,20 @@ export interface BatchBoardSchedule {
/** Train length used by allocated bookings (from wagon-type dimensions). */
allocatedLengthMeters: number;
maxLengthMeters: number | null;
/** Weight committed on the train (allocated + selected-for-batch). */
/**
* Weight committed on the train (allocated + selected-for-batch). On a
* multi-stop corridor this is the HEAVIEST single edge, not the sum —
* disjoint legs (intercity + export) never ride together, so summing
* them over-reports the train against the pull limit.
*/
usedWeightTons: number;
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
maxWagons: number | null;
/** Physical consist length of the built train (Train Builder), null without one. */
trainLengthMeters: number | null;
/** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */
legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null;
};
counts: {
allocated: number;
@@ -1076,21 +1089,52 @@ export class BookingBatchService implements OnModuleInit {
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
const room = budget.remainingFor(leg);
const byWagonType = allowed.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
return {
wagonTypeId,
code: type?.code ?? null,
name: type?.name ?? null,
freeWagons: this.bookableWithin(room, dims).wagons,
};
});
// The abstract budget can't tell wagon types apart — cap each type's free
// count with the PHYSICAL wagons of that type the train (or yard pool)
// actually holds on this leg, and on a built train hide types the consist
// doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free".
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
);
const byWagonType = allowed
.filter(
({ wagonTypeId }) =>
stock.mode !== 'TRAIN' ||
!wagonTypeId ||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
)
.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
const roomWagons = this.bookableWithin(room, dims).wagons;
const physical = wagonTypeId
? ledger.availableFor([wagonTypeId], leg)
: roomWagons;
return {
wagonTypeId,
code: type?.code ?? null,
name: type?.name ?? null,
freeWagons: Math.min(roomWagons, physical),
};
});
const freeWagons = byWagonType.reduce(
(best, t) => Math.max(best, t.freeWagons),
0,
);
const builtTrain = schedule.trainSet?.train;
out.push({
scheduleId: schedule.id,
trainNumber:
schedule.trainNumber ??
builtTrain?.exportTrainNumber ??
builtTrain?.trainNumber ??
null,
trainName: builtTrain?.trainName ?? builtTrain?.code ?? null,
departure: schedule.scheduledDepartureDate!,
bookingClosesAt: schedule.windowClosesAt ?? null,
isOpen: this.isFillable(schedule),
@@ -1531,9 +1575,18 @@ export class BookingBatchService implements OnModuleInit {
// waiting pool (the 7 that lost the batch), not just the winners. These are
// display-only candidates: they are excluded from the capacity meters below.
const pinnedIds = new Set(bookings.map((b) => b.id));
if (s.scheduledDepartureDate) {
// Corridor stops drive both the day-pool candidate merge and the per-leg
// capacity meters below; a failed lookup degrades to whole-route math.
let stops: string[] = [];
try {
stops = await this.stopsForSchedule(s);
} catch (err) {
this.logger.warn(
`Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`,
);
}
if (s.scheduledDepartureDate && stops.length) {
try {
const stops = await this.stopsForSchedule(s);
const candidates =
await this.bookingsRepository.findBatchPoolByCorridorDay(
stops,
@@ -1662,6 +1715,18 @@ export class BookingBatchService implements OnModuleInit {
const windowBookings = items.filter((i) => i.fullyExecutedAt);
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
const stopLabels =
stops.length > 2 ? await this.yardLabels(stops) : new Map<string, string>();
const yardsByBookingId = new Map(
bookings.map((b) => [
b.id,
{
originYardId: b.originYardId ?? null,
destinationYardId: b.destinationYardId ?? null,
},
]),
);
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
@@ -1707,6 +1772,12 @@ export class BookingBatchService implements OnModuleInit {
items.filter((i) => pinnedIds.has(i.id)),
loco,
s.maxWagons ?? null,
{
stops,
labelByYardId: stopLabels,
yardsByBookingId,
trainLengthMeters: this.builtTrainLengthOf(s),
},
),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
@@ -1747,6 +1818,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private computeBoardCapacity(
items: Array<{
id: string;
state: BatchBoardBookingState;
wagons: number;
weightTons: number;
@@ -1754,6 +1826,16 @@ export class BookingBatchService implements OnModuleInit {
}>,
loco: LocomotiveLimits | null,
maxWagons: number | null,
legCtx?: {
/** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */
stops: string[];
labelByYardId: Map<string, string>;
yardsByBookingId: Map<
string,
{ originYardId: string | null; destinationYardId: string | null }
>;
trainLengthMeters: number | null;
},
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
// Every booking still targeting this train holds gross weight — including
@@ -1771,18 +1853,72 @@ export class BookingBatchService implements OnModuleInit {
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
// Per-leg committed weight: a booking holds weight only on the edges it
// rides, so the meter compares the HEAVIEST single edge against the pull
// limit. Whole-route bookings (or yards missing from the stop list) load
// every edge — never under-reported.
const stops = legCtx?.stops ?? [];
let usedWeightTons = round2(
committed.reduce((sum, i) => sum + i.weightTons, 0),
);
let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
if (legCtx && stops.length > 2) {
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
const edges = new Array<number>(stops.length - 1).fill(0);
for (const item of committed) {
const yards = legCtx.yardsByBookingId.get(item.id);
const from = yards?.originYardId
? stopIndex.get(yards.originYardId)
: undefined;
const to = yards?.destinationYardId
? stopIndex.get(yards.destinationYardId)
: undefined;
const leg =
from != null && to != null && from < to
? { from, to }
: { from: 0, to: edges.length };
for (let e = leg.from; e < leg.to; e += 1) edges[e] += item.weightTons;
}
const label = (yardId: string) =>
legCtx.labelByYardId.get(yardId) ?? yardId;
legUsage = edges.map((weight, i) => ({
from: label(stops[i]),
to: label(stops[i + 1]),
usedWeightTons: round2(weight),
}));
usedWeightTons = round2(Math.max(0, ...edges));
}
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters: round2(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
),
maxLengthMeters: caps ? caps.maxLengthMeters : null,
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
usedWeightTons,
maxWeightTons: caps ? caps.maxWeightTons : null,
maxWagons: maxWagons ?? null,
trainLengthMeters: legCtx?.trainLengthMeters ?? null,
legUsage,
};
}
/** Built consist's physical length (what Train Builder shows), null without a built train. */
private builtTrainLengthOf(s: TrainSchedule): number | null {
const raw = s.trainSet?.totalLengthMeters;
const value = raw != null ? Number(raw) : NaN;
return Number.isFinite(value) && value > 0 ? value : null;
}
/** Yard display labels for corridor stops (falls back to the yard id). */
private async yardLabels(yardIds: string[]): Promise<Map<string, string>> {
if (!yardIds.length) return new Map();
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(yardIds) } });
return new Map(yards.map((y) => [y.id, y.label ?? y.code]));
}
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
@@ -1829,7 +1965,12 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
stops: [],
labelByYardId: new Map(),
yardsByBookingId: new Map(),
trainLengthMeters: this.builtTrainLengthOf(s),
}),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")