mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
leg-aware capacity and wagon sharing
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user