feat(freight): offer built-train wagons per boarding yard on multi-yard consists

This commit is contained in:
Marshal
2026-08-18 08:27:18 +00:00
parent 1ca7776143
commit c723b660e2
33 changed files with 1234 additions and 231 deletions

View File

@@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit {
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
// On a multi-yard consist the pool that matters is the one standing at
// the booking's own boarding yard — a type carried only in Mojo must not
// be advertised to a customer boarding at Dire.
const carriedAtBoardYard = (wagonTypeId: string): number => {
const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null;
if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0;
return stock.remainingByTypeId.get(wagonTypeId) ?? 0;
};
const byWagonType = allowed
.filter(
({ wagonTypeId }) =>
stock.mode !== 'TRAIN' ||
!wagonTypeId ||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
)
.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
@@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit {
return new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
}

View File

@@ -1589,6 +1589,7 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
await this.assertRouteCoversWagonYards(builtTrain, route);
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
@@ -4274,12 +4275,26 @@ export class TrainSchedulingService {
.getRepository(Locomotive)
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
}
// Only wagons the train has actually COLLECTED move with it. On a
// consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo
// moves the Dire wagons — the ones already aboard — and picks up the
// Mojo ones standing here. Wagons waiting at yards further down the
// line stay where they are until the train physically gets to them.
const passedYardIds = stations
.filter((s) => s.sequenceNo <= dto.sequenceNo)
.map((s) => s.yardId);
await manager
.getRepository(Wagon)
.update(
{ currentTrainScheduleId: scheduleId },
{ currentYardId: station.yardId },
);
.createQueryBuilder()
.update(Wagon)
.set({ currentYardId: station.yardId })
.where('current_train_schedule_id = :scheduleId', { scheduleId })
// A yard-less wagon has no "waiting further down the line" position
// to protect, so it rides along as it always did.
.andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', {
passedYardIds,
})
.execute();
if (schedule.trainSet?.trainId) {
await manager
.getRepository(Train)
@@ -5286,12 +5301,26 @@ export class TrainSchedulingService {
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
// A built consist spread across several yards can only offer, at each yard,
// the wagons standing there. A single-yard consist keeps the original
// behaviour: the whole train counts wherever it currently sits.
const consistYards = builtTrainId
? new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
)
: new Set<string>();
const consistIsSplit = consistYards.size > 1;
for (const wagon of wagons) {
// Train-bound schedule: the built train's own consist IS the fleet — only
// its wagons count (wherever they currently sit; they travel with the
// train), and loose yard wagons never do.
// its wagons count, and loose yard wagons never do. A single-yard consist
// counts wherever it sits (it travels with the train); a split consist is
// counted at the yard each wagon actually stands in.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
@@ -5651,12 +5680,23 @@ export class TrainSchedulingService {
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const consistYards = new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
);
// Split consist: a slot boarding at a given yard must take a wagon that
// physically stands there — the train cannot load a Mojo wagon at Dire.
// A single-yard consist ignores this (the whole train is at one place).
const requiredYardId =
consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null;
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id),
spanFree(w.id) &&
(!requiredYardId || w.currentYardId === requiredYardId),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5825,14 +5865,28 @@ export class TrainSchedulingService {
});
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>();
for (const wagon of wagons) {
remainingByTypeId.set(
wagon.wagonTypeId,
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
if (wagon.currentYardId) {
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(wagon.currentYardId, perType);
}
}
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
// Single-yard consist (the overwhelming majority): the whole train is
// offered at every boarding yard exactly as before — the per-yard split is
// only meaningful once the consist is genuinely spread across yards.
return {
mode: 'TRAIN',
remainingByTypeId,
codesByTypeId,
...(byYardId.size > 1 ? { byYardId } : {}),
};
}
/**
@@ -6160,6 +6214,45 @@ export class TrainSchedulingService {
return saved;
}
/**
* A built train's wagons may stand in several yards. The route must pass
* through every one of them as origin or an intermediate stop — never only
* as the final destination (the train has to pick the wagons up en route).
*/
private async assertRouteCoversWagonYards(train: Train, route: Route) {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: train.id },
select: { id: true, currentYardId: true },
});
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
if (!wagonYards.length) return;
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } });
const stops = milestones.length >= 2
? milestones.map((m) => m.yardId)
: [route.originYardId, route.destinationYardId];
// Every stop except the last one is a pickup point.
const pickupYards = new Set(stops.slice(0, -1));
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
if (!uncovered.length) return;
const labels = await this.yardLabelMap(uncovered);
const destination = stops[stops.length - 1];
const detail = uncovered
.map((y) =>
y === destination
? `${labels.get(y) ?? y} (only as the destination)`
: `${labels.get(y) ?? y} (not on route)`,
)
.join(', ');
throw new BadRequestException(
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
);
}
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },

View File

@@ -43,6 +43,16 @@ export type WagonStock = {
remainingByTypeId: Map<string, number>;
/** Wagon-type code per id, for human-readable shortfall messages. */
codesByTypeId: Map<string, string>;
/**
* Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons
* standing at that yard. A train whose wagons are split across yards can
* only offer, at each boarding yard, the wagons physically standing there —
* a wagon waiting in Mojo is not bookable from Dire, and one picked up at
* Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits
* in one yard, which keeps single-yard trains on the original whole-train
* math.
*/
byYardId?: Map<string, Map<string, number>>;
};
export type FlexPlanResult = {

View File

@@ -68,3 +68,94 @@ describe('WagonStockLedger', () => {
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
});
});
describe('WagonStockLedger — multi-yard consist', () => {
// The reported case: a built train of 53 wagons, 20 standing in Dire and 33
// in Mojo. Each yard may only sell the wagons physically standing there.
const DIRE = 'yard-dire';
const MOJO = 'yard-mojo';
const ADDIS = 'yard-addis';
const STOPS = [DIRE, MOJO, ADDIS];
const EDGES = STOPS.length - 1;
const splitStock = () =>
new Map([
[DIRE, new Map([['nw5', 20]])],
[MOJO, new Map([['nw5', 33]])],
]);
// Legs along Dire → Mojo → Addis.
const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 };
const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 };
const splitLedger = () =>
new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS);
it('offers each yard only the wagons standing there', () => {
const ledger = splitLedger();
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20);
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
});
it('keeps the yards independent — Dire bookings never eat Mojo stock', () => {
const ledger = splitLedger();
// A Dire booking rides the whole corridor, occupying the Mojo→Addis edge…
expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20);
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0);
// …but those are Dire's steel, so Mojo still has its own 33 to sell.
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33);
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0);
});
it('never lends a free Dire wagon to a Mojo customer', () => {
const ledger = splitLedger();
// Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty.
expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5);
// Mojo is still capped at its own 33 — the 15 empty Dire wagons are not
// offered here, exactly as the operator requires.
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33);
});
it('offers nothing at the destination — there is nothing to pick up there', () => {
const ledger = splitLedger();
// A leg boarding at the last stop has no pool of its own.
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0);
});
it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => {
const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [
'yard-add',
'yard-dire',
'yard-indode',
'yard-mojo',
'yard-djibouti',
];
const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI];
const ledger = new WagonStockLedger(
new Map([['nw5', 53]]),
stops.length - 1,
new Map([
[DIRE_2, new Map([['nw5', 20]])],
[MOJO_2, new Map([['nw5', 33]])],
]),
stops,
);
const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 });
// Addis: the train starts empty — nothing to sell.
expect(ledger.availableFor(['nw5'], to(0))).toBe(0);
// Dire: the 20 wagons waiting there.
expect(ledger.availableFor(['nw5'], to(1))).toBe(20);
// Indode: the same 20 wagons, which have moved with the train.
expect(ledger.availableFor(['nw5'], to(2))).toBe(0);
// Mojo: its own 33 only.
expect(ledger.availableFor(['nw5'], to(3))).toBe(33);
});
it('single-yard consist keeps the original whole-train behaviour', () => {
// No byYardId (the train is not split) — every leg sees the whole train,
// exactly as before this feature.
const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES);
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53);
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
});
});

View File

@@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util';
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/
export class WagonStockLedger {
/**
* Usage rows keyed by pool. A single-yard train has one pool (''), so this is
* exactly the original per-type accounting. A multi-yard consist keys by
* boarding yard as well, because the Dire wagons and the Mojo wagons are
* disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the
* Mojo→Addis edge, but they must not shrink what Mojo itself can offer.
*/
private readonly usedPerEdge = new Map<string, number[]>();
constructor(
private readonly remainingByTypeId: Map<string, number>,
private readonly edgeCount: number,
/**
* Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons
* standing at each yard. When present, a leg is served ONLY by the wagons
* standing at the yard it boards from — a Dire→Addis booking on a train
* whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis
* booking sees 33, never the Dire wagons that ride past empty.
*/
private readonly byYardId?: Map<string, Map<string, number>>,
/** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */
private readonly stops: readonly string[] = [],
) {}
/** The yard a leg boards from, or '' when the train is not split across yards. */
private poolYardOf(leg: CorridorLeg): string {
if (!this.byYardId) return '';
return this.stops[leg.fromEdge] ?? '';
}
/** Usage-row key: one row per (pool, wagon type). */
private rowKey(wagonTypeId: string, leg: CorridorLeg): string {
const pool = this.poolYardOf(leg);
return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
}
/** Wagons of one type offered at the yard a leg boards from. */
private totalForType(wagonTypeId: string, leg: CorridorLeg): number {
const pool = this.poolYardOf(leg);
if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0;
return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0;
}
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = this.usedPerEdge.get(wagonTypeId);
const total = this.totalForType(wagonTypeId, leg);
const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg));
if (!row) return total;
let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
@@ -69,10 +105,11 @@ export class WagonStockLedger {
if (!deepest) break;
const take = Math.min(outstanding, deepest.free);
let row = this.usedPerEdge.get(deepest.id);
const key = this.rowKey(deepest.id, leg);
let row = this.usedPerEdge.get(key);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(deepest.id, row);
this.usedPerEdge.set(key, row);
}
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take;