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

@@ -44,7 +44,7 @@ export class BuildTrainDto {
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
description: 'Wagons to attach at build time, in consist order (any yard)',
})
@IsOptional()
@IsArray()

View File

@@ -283,6 +283,10 @@ export class TrainBuilderService {
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
status: wagon.status,
currentYardId: wagon.currentYardId ?? null,
currentYard: wagon.currentYard
? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label }
: null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
@@ -326,6 +330,27 @@ export class TrainBuilderService {
: null,
locomotives,
wagons,
// Where the consist physically stands. A train built from several yards
// only picks a yard's wagons up when it reaches that yard, and a customer
// boarding there can only book the wagons standing there — the schedule
// route must therefore cover every one of these yards before its
// destination.
wagonYards: [
...wagons
.reduce((acc, wagon) => {
const id = wagon.currentYardId ?? 'UNASSIGNED';
const entry = acc.get(id) ?? {
yardId: wagon.currentYardId ?? null,
code: wagon.currentYard?.code ?? null,
label: wagon.currentYard?.label ?? null,
wagonCount: 0,
};
entry.wagonCount += 1;
acc.set(id, entry);
return acc;
}, new Map<string, { yardId: string | null; code: string | null; label: string | null; wagonCount: number }>())
.values(),
].sort((a, b) => b.wagonCount - a.wagonCount),
totals: {
wagonCount: wagons.length,
totalTareTons,
@@ -428,10 +453,13 @@ export class TrainBuilderService {
}
/**
* 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.
* Relocate the train to another yard. The locomotives always follow. Of the
* wagons, only those standing WITH the train move: on a consist spread
* across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo
* relocates the 20 it is actually pulling and leaves the Mojo wagons where
* they stand — the train collects those by arriving, not by this call.
* Each moved 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) => {
@@ -439,6 +467,7 @@ export class TrainBuilderService {
if (train.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
const previousYardId = train.currentYardId ?? null;
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
@@ -454,7 +483,17 @@ export class TrainBuilderService {
);
}
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
const allWagons = await manager
.getRepository(Wagon)
.find({ where: { trainId: train.id } });
// Wagons travelling with the train = those at the yard it is leaving.
// A yard-less wagon has no standing position of its own, so it follows.
const wagons = allWagons.filter(
(wagon) =>
wagon.currentYardId == null ||
previousYardId == null ||
wagon.currentYardId === previousYardId,
);
const now = new Date();
for (const wagon of wagons) {
if (wagon.currentYardId === yard.id) continue;
@@ -475,7 +514,7 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
@@ -1037,11 +1076,8 @@ export class TrainBuilderService {
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
}
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
// Wagons may sit in any yard — the schedule's route must pass through
// every wagon yard before its destination (checked at scheduling time).
toAttach.push(wagon);
}
if (!toAttach.length) return [];