fix train builder and consolidation

This commit is contained in:
Marshal
2026-07-14 13:49:39 +00:00
parent 01459bd73c
commit 5cffe2860c
14 changed files with 1092 additions and 17 deletions

View File

@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
export class AdjustScheduleConsistDto {
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description:
"AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).",
})
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
addWagonIds?: string[];
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description:
'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.',
})
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
removeWagonIds?: string[];
}

View File

@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
@@ -166,6 +167,34 @@ export class TrainSchedulingController {
);
}
@Get("schedules/:id/consist")
@TrainSchedulingView()
@ApiOperation({
summary:
"Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history",
})
getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleConsist(id);
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
})
adjustScheduleConsist(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AdjustScheduleConsistDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.adjustScheduleConsist(
id,
dto,
resolveAuthUserId(user),
);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -25,6 +25,7 @@ import {
FindOptionsWhere,
ILike,
In,
IsNull,
Not,
QueryFailedError,
Raw,
@@ -48,6 +49,7 @@ import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
@@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
@@ -1331,11 +1334,25 @@ export class TrainSchedulingService {
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
// The locomotives pull GROSS weight: the customers' cargo plus the empty
// weight of every planned wagon — cargo-only comparison understates the load.
const planTareTons = roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
// A built train hauls EVERY coupled wagon's tare — empty ones included —
// so train-bound schedules count the full consist, not just planned slots.
const consistWagons = schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
const planTareTons = consistWagons
? roundTons(
consistWagons.reduce(
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
0,
),
)
: roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
@@ -4626,6 +4643,294 @@ export class TrainSchedulingService {
});
}
/**
* Consist snapshot for the adjust-consist UI: the built train's wagons with
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
* against the locomotive limits incl. overage tolerance, addable yard wagons,
* and the adjustment history.
*/
async getScheduleConsist(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const addableWagons = await this.dataSource.getRepository(Wagon).find({
where: {
trainId: IsNull(),
status: WagonStatus.Available,
currentYardId: builtTrain.currentYardId ?? undefined,
},
relations: { wagonType: true },
order: { wagonNumber: 'ASC' },
});
const adjustments = await this.dataSource
.getRepository(ScheduleWagonAdjustmentLog)
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
// Slots with cargo aboard — their physical wagons are "loaded" and can
// never be trimmed.
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const consistTareTons = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
);
const consistLengthMeters = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
);
const mapWagon = (wagon: Wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
}
: null,
});
return {
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
train: {
id: builtTrain.id,
code: builtTrain.code,
trainName: builtTrain.trainName ?? null,
currentYardId: builtTrain.currentYardId ?? null,
},
limits: {
maxPullWeightTons,
overageToleranceTons,
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
maxTrainLengthMeters,
overageToleranceMeters,
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
},
totals: {
wagonCount: wagons.length,
cargoTons,
consistTareTons,
grossTons: roundTons(cargoTons + consistTareTons),
consistLengthMeters,
},
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
// Free = not pinned to any run; only free wagons can be trimmed.
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
id: log.id,
action: log.action,
wagonId: log.wagonId,
wagonNumber: log.wagonNumber,
adjustedByUserId: log.adjustedByUserId,
occurredAt: log.occurredAt,
})),
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
};
}
/**
* Permanently adjust the built train's consist from a schedule: trim free
* wagons (their tare no longer rides — the usual fix when gross weight beats
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
* length headroom remain (limits incl. overage tolerance). The built train
* updates in place, the schedule's wagon cap follows, and every change is
* logged for the schedule's history.
*/
async adjustScheduleConsist(
scheduleId: string,
dto: AdjustScheduleConsistDto,
userId?: string | null,
) {
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
if (!addWagonIds.length && !removeWagonIds.length) {
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
}
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
if (overlap.length) {
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
'The consist is frozen once the train is dispatched — adjust before departure',
);
}
const builtTrainRef = schedule.trainSet?.train;
if (!builtTrainRef) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);
const lengthCapMeters = roundTons(
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
);
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({
where: { id: builtTrainRef.id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
const consist = await manager.getRepository(Wagon).find({
where: { trainId: train.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
}
removed.push(wagon);
}
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
relations: { wagonType: true },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
if (wagon.trainId) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
}
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 coupled`,
);
}
added.push(wagon);
}
// --- headroom check (only additions can push the train over a cap) ---
const removedIds = new Set(removed.map((w) => w.id));
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const finalGrossTons = roundTons(cargoTons + finalTareTons);
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
throw new BadRequestException(
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
);
}
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
throw new BadRequestException(
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
);
}
// --- apply: detach trims, couple additions, compact the sequence ---
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
}
const remaining = consist.filter((w) => !removedIds.has(w.id));
for (let i = 0; i < remaining.length; i++) {
if (remaining[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
}
}
let sequence = remaining.length;
for (const wagon of added) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
}
// The schedule is full when every consist wagon is allocated.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { maxWagons: finalConsist.length });
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
const now = new Date();
await logRepo.save(
[
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
].map(({ action, wagon }) =>
logRepo.create({
trainScheduleId: scheduleId,
trainId: train.id,
action,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: userId ?? null,
occurredAt: now,
}),
),
);
});
return this.getScheduleConsist(scheduleId);
}
/**
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →