mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
feat(freight): offer built-train wagons per boarding yard on multi-yard consists
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user