mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
fix train builder and consolidation
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
|
||||
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* History row for a consist adjustment made from a schedule: staff coupled a
|
||||
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
|
||||
* e.g. trimming free wagons whose tare pushed gross weight over the
|
||||
* locomotives' pull limit. Plain columns (no FK relations) so the history
|
||||
* survives the wagon or train being deleted later.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainId'])
|
||||
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@Column({ name: 'action', type: 'varchar', length: 10 })
|
||||
action!: WagonAdjustmentAction;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
|
||||
wagonNumber!: string;
|
||||
|
||||
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
||||
adjustedByUserId!: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
@@ -511,9 +512,13 @@ export class TrainBuilderService {
|
||||
startCount: number,
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
let sequence = startCount;
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
// First pass: lock + validate every wagon so the length gate below sees
|
||||
// the full incoming set before any row is written.
|
||||
const toAttach: Wagon[] = [];
|
||||
for (const wagonId of uniqueIds) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({
|
||||
const wagon = await wagonRepo.findOne({
|
||||
where: { id: wagonId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
@@ -530,8 +535,16 @@ export class TrainBuilderService {
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
toAttach.push(wagon);
|
||||
}
|
||||
if (!toAttach.length) return;
|
||||
|
||||
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||
|
||||
let sequence = startCount;
|
||||
for (const wagon of toAttach) {
|
||||
sequence += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
await wagonRepo.update(wagon.id, {
|
||||
trainId: train.id,
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
@@ -539,6 +552,58 @@ export class TrainBuilderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The consist (already-attached wagons + the incoming ones) must fit the
|
||||
* train's locomotive length limit — the weakest locomotive of the set caps
|
||||
* the train, mirroring how scheduling derives capacity.
|
||||
*/
|
||||
private async assertConsistLengthWithinLimit(
|
||||
manager: EntityManager,
|
||||
train: Train,
|
||||
incoming: Wagon[],
|
||||
): Promise<void> {
|
||||
const links = await manager.getRepository(TrainLocomotive).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { locomotive: true },
|
||||
});
|
||||
const limits = minLocomotiveLimits(
|
||||
links
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||
);
|
||||
const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
|
||||
if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
|
||||
|
||||
const existing = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const currentLength = existing.reduce(
|
||||
(sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
|
||||
const types = typeIds.length
|
||||
? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
|
||||
: [];
|
||||
const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
|
||||
const addedLength = incoming.reduce(
|
||||
(sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalLength = currentLength + addedLength;
|
||||
if (totalLength > maxLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Cannot attach wagons — train length would be ${round(totalLength)} m ` +
|
||||
`(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
|
||||
`over the locomotive limit of ${round(maxLengthMeters)} m. ` +
|
||||
'Remove wagons from the consist or use locomotives with a higher length limit.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
||||
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
||||
const wagons = await manager.getRepository(Wagon).find({
|
||||
|
||||
Reference in New Issue
Block a user