This commit is contained in:
Marshal
2026-07-14 13:10:00 +00:00
parent 6d0cf50b4d
commit b5a97d344a
36 changed files with 1101 additions and 355 deletions

View File

@@ -1,4 +1,4 @@
import { Freight, WagonStatus } from '@edr/types';
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -202,7 +203,6 @@ export class TrainBuilderService {
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
@@ -221,14 +221,17 @@ export class TrainBuilderService {
totals: {
wagonCount: wagons.length,
totalTareTons,
// Informational only — building never checks against full capacity;
// the real gross check (cargo + tare vs haul limit) runs at allocation.
totalCapacityTons,
maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
// Fully loaded gross vs. what the weakest locomotive can haul.
weightUtilizationPct: maxPullWeightTons
? round((maxGrossTons / maxPullWeightTons) * 100)
// Cargo the locomotives can still haul once pulling the empty consist.
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
// Share of the haul limit consumed by the empty wagons alone.
tareUtilizationPct: maxPullWeightTons
? round((totalTareTons / maxPullWeightTons) * 100)
: null,
lengthUtilizationPct: maxTrainLengthMeters
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
@@ -269,6 +272,54 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Relocate the train to another yard. The consist moves as one unit: every
* coupled locomotive and wagon follows to the new yard (so their current
* yards always match the train's), and each wagon gets a movement-ledger row.
* Blocked while the train is out on a dispatched run.
*/
async setYard(id: string, currentYardId: string) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
if (train.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
const links = await manager
.getRepository(TrainLocomotive)
.find({ where: { trainId: train.id } });
if (links.length) {
await manager
.getRepository(Locomotive)
.update(
{ id: In(links.map((link) => link.locomotiveId)) },
{ currentYardId: yard.id },
);
}
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
const now = new Date();
for (const wagon of wagons) {
if (wagon.currentYardId === yard.id) continue;
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
// Ledger row keeps the wagon's yard history auditable (mirrors the
// manual-relocation path in the wagons service).
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: wagon.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
occurredAt: now,
}),
);
}
});
return this.getComposition(id);
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -361,12 +412,8 @@ export class TrainBuilderService {
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
const maxGrossTons = round(
wagons.reduce(
(sum, w) =>
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
0,
),
const totalTareTons = round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
);
return {
id: train.id,
@@ -379,7 +426,7 @@ export class TrainBuilderService {
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
maxGrossTons,
totalTareTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),