mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
feat(train-sets): implement multi-locomotive support for train sets
- Added TrainSetLocomotive entity to link multiple locomotives to a train set. - Updated TrainSet entity to include a OneToMany relationship with TrainSetLocomotive. - Modified the train scheduling logic to require at least two locomotives for a train set. - Enhanced the UI components to support selecting multiple locomotives. - Introduced new permissions for viewing customs clearance. - Updated migrations to create the train_set_locomotives table and backfill existing data. - Implemented utility functions for managing train numbers based on cargo type and direction. - Added tests for train number utilities to ensure correct functionality.
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
locomotiveId!: string;
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Locomotives pulling the train (minimum 2 — front and back)',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
/**
|
||||
* Effective pull limits for a train set with multiple locomotives: the weakest
|
||||
* locomotive caps the train, so take the minimum pull weight and minimum length
|
||||
* across all assigned locomotives. Returns null when no locomotives are given.
|
||||
*/
|
||||
export function minLocomotiveLimits(
|
||||
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
|
||||
): LocomotiveLimits | null {
|
||||
if (!locomotives.length) return null;
|
||||
return {
|
||||
maxPullWeightTons: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
|
||||
),
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
BULK_IMPORT_NUMBERS,
|
||||
CONTAINER_EXPORT_NUMBERS,
|
||||
CONTAINER_IMPORT_NUMBERS,
|
||||
pickLowestFreeNumber,
|
||||
pickTrainNumberPool,
|
||||
} from './train-number.util';
|
||||
|
||||
describe('train-number.util', () => {
|
||||
describe('pickTrainNumberPool', () => {
|
||||
it('picks container export (odd) when container wagons dominate and direction is EXPORT', () => {
|
||||
const pool = pickTrainNumberPool(5, 2, 'EXPORT');
|
||||
expect(pool.cargo).toBe('CONTAINER');
|
||||
expect(pool.direction).toBe('EXPORT');
|
||||
expect(pool.numbers).toEqual(CONTAINER_EXPORT_NUMBERS);
|
||||
});
|
||||
|
||||
it('picks container import (even) when container wagons dominate and direction is IMPORT', () => {
|
||||
const pool = pickTrainNumberPool(5, 2, 'IMPORT');
|
||||
expect(pool.numbers).toEqual(CONTAINER_IMPORT_NUMBERS);
|
||||
});
|
||||
|
||||
it('picks bulk when bulk wagons dominate', () => {
|
||||
const pool = pickTrainNumberPool(1, 9, 'IMPORT');
|
||||
expect(pool.cargo).toBe('BULK');
|
||||
expect(pool.numbers).toEqual(BULK_IMPORT_NUMBERS);
|
||||
});
|
||||
|
||||
it('treats a tie as container', () => {
|
||||
expect(pickTrainNumberPool(3, 3, 'EXPORT').cargo).toBe('CONTAINER');
|
||||
});
|
||||
|
||||
it('defaults DOMESTIC to the export/odd pool', () => {
|
||||
expect(pickTrainNumberPool(5, 0, 'DOMESTIC').direction).toBe('EXPORT');
|
||||
expect(pickTrainNumberPool(5, 0, null).direction).toBe('EXPORT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickLowestFreeNumber', () => {
|
||||
it('returns the lowest unused number', () => {
|
||||
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, ['8001'])).toBe('8101');
|
||||
});
|
||||
|
||||
it('returns the first number when none are used', () => {
|
||||
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, [])).toBe('8001');
|
||||
});
|
||||
|
||||
it('returns null when the pool is exhausted', () => {
|
||||
expect(pickLowestFreeNumber(BULK_IMPORT_NUMBERS, [...BULK_IMPORT_NUMBERS])).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Fixed train-number pools assigned to a train on dispatch.
|
||||
*
|
||||
* The prefix encodes cargo type (8 = container, 1 = bulk) and the parity encodes
|
||||
* trade direction (odd = export, even = import). Numbers are finite and recycle:
|
||||
* a number is "in use" only while its train is DISPATCHED and not yet ARRIVED.
|
||||
*/
|
||||
|
||||
export const CONTAINER_EXPORT_NUMBERS = [
|
||||
'8001', '8101', '8201', '8301', '8401', '8501', '8601', '8701', '8801', '8901',
|
||||
] as const;
|
||||
|
||||
export const CONTAINER_IMPORT_NUMBERS = [
|
||||
'8002', '8102', '8202', '8302', '8402', '8502', '8602', '8702', '8802', '8902',
|
||||
] as const;
|
||||
|
||||
export const BULK_EXPORT_NUMBERS = ['1101', '1103', '1105', '1107'] as const;
|
||||
|
||||
export const BULK_IMPORT_NUMBERS = ['1002', '1004', '1006', '1008'] as const;
|
||||
|
||||
export type CargoKind = 'CONTAINER' | 'BULK';
|
||||
export type PoolDirection = 'IMPORT' | 'EXPORT';
|
||||
|
||||
export interface TrainNumberPool {
|
||||
cargo: CargoKind;
|
||||
/** EXPORT = odd numbers, IMPORT = even numbers. */
|
||||
direction: PoolDirection;
|
||||
numbers: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which fixed pool a train draws from.
|
||||
*
|
||||
* - Cargo: container vs bulk by dominant wagon count; ties resolve to container.
|
||||
* - Direction: EXPORT → odd pool, IMPORT → even pool. DOMESTIC (neither end is
|
||||
* Djibouti) has no dedicated pool, so it defaults to the export/odd pool.
|
||||
*/
|
||||
export function pickTrainNumberPool(
|
||||
containerWagons: number,
|
||||
bulkWagons: number,
|
||||
direction: 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null | undefined,
|
||||
): TrainNumberPool {
|
||||
const cargo: CargoKind = bulkWagons > containerWagons ? 'BULK' : 'CONTAINER';
|
||||
const poolDirection: PoolDirection = direction === 'IMPORT' ? 'IMPORT' : 'EXPORT';
|
||||
|
||||
const numbers =
|
||||
cargo === 'CONTAINER'
|
||||
? poolDirection === 'IMPORT'
|
||||
? CONTAINER_IMPORT_NUMBERS
|
||||
: CONTAINER_EXPORT_NUMBERS
|
||||
: poolDirection === 'IMPORT'
|
||||
? BULK_IMPORT_NUMBERS
|
||||
: BULK_EXPORT_NUMBERS;
|
||||
|
||||
return { cargo, direction: poolDirection, numbers };
|
||||
}
|
||||
|
||||
/** Lowest pool number not currently in use, or null when the pool is exhausted. */
|
||||
export function pickLowestFreeNumber(
|
||||
pool: readonly string[],
|
||||
usedNumbers: Iterable<string>,
|
||||
): string | null {
|
||||
const used = new Set(usedNumbers);
|
||||
for (const number of pool) {
|
||||
if (!used.has(number)) return number;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Route } from '../routes/entities/route.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 { TrainSetsModule } from '../train-sets/train-sets.module';
|
||||
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
WagonType,
|
||||
TrainSet,
|
||||
TrainSetWagon,
|
||||
TrainSetLocomotive,
|
||||
Route,
|
||||
Wagon,
|
||||
Container,
|
||||
|
||||
@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||
const lockedLocomotiveRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(locomotive),
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(locomotive)
|
||||
.mockResolvedValueOnce(locomotive2),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const trainScheduleRepo = {
|
||||
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
||||
};
|
||||
const trainSetLocomotiveRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
switch (entity?.name) {
|
||||
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
|
||||
return trainScheduleRepo;
|
||||
case 'TrainSet':
|
||||
return trainSetRepo;
|
||||
case 'TrainSetLocomotive':
|
||||
return trainSetLocomotiveRepo;
|
||||
default:
|
||||
throw new Error(`Unexpected transaction repository ${entity?.name}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') {
|
||||
return { findOne: jest.fn().mockResolvedValue(route) };
|
||||
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
locomotiveId: 'loc-1',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
@@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => {
|
||||
})),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Route') {
|
||||
return {
|
||||
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
|
||||
service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
locomotiveId: 'loc-1',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { Route } from '../routes/entities/route.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 { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
@@ -79,8 +80,10 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
|
||||
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
|
||||
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||||
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
|
||||
where: { id: locomotive.id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!lockedLocomotive) {
|
||||
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
||||
}
|
||||
if (lockedLocomotive.status !== 'AVAILABLE') {
|
||||
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
||||
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
|
||||
const lockedLocomotives: Locomotive[] = [];
|
||||
for (const locomotiveId of locomotiveIds) {
|
||||
const locked = await manager.getRepository(Locomotive).findOne({
|
||||
where: { id: locomotiveId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!locked) {
|
||||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||
}
|
||||
if (locked.status !== 'AVAILABLE') {
|
||||
throw new ConflictException(`Locomotive ${locked.code} is not available`);
|
||||
}
|
||||
if (locked.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
);
|
||||
}
|
||||
lockedLocomotives.push(locked);
|
||||
}
|
||||
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
if (lockedLocomotive.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||
// Effective capacity is capped by the weakest locomotive in the set.
|
||||
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
|
||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||
).maxWagonsPerTrain,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ id: In(lockedLocomotives.map((l) => l.id)) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||||
};
|
||||
|
||||
const locomotive = schedule.trainSet.locomotive;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
|
||||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
freightType ?? null,
|
||||
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
|
||||
const totalWeightTons = validation.summary.totalWeightTons;
|
||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||
|
||||
if (!locomotive) {
|
||||
throw new BadRequestException('Schedule train set has no locomotive');
|
||||
if (!limitLoco) {
|
||||
throw new BadRequestException('Schedule train set has no locomotives');
|
||||
}
|
||||
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
||||
if (limitLoco.maxPullWeightTons < totalWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
|
||||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||||
);
|
||||
}
|
||||
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
|
||||
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -681,10 +699,12 @@ export class TrainSchedulingService {
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
{ actualDepartureAt: now },
|
||||
{ actualDepartureAt: now, trainNumber },
|
||||
manager,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a fixed train number on dispatch. The number is drawn from the pool
|
||||
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||||
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
|
||||
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
|
||||
* concurrent dispatches can't grab the same number. Throws when the pool is
|
||||
* exhausted. Idempotent: returns the existing number if already assigned.
|
||||
*/
|
||||
private async assignTrainNumber(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
): Promise<string> {
|
||||
if (schedule.trainNumber) return schedule.trainNumber;
|
||||
|
||||
// Count container vs bulk wagons from the planned allocations.
|
||||
let containerWagons = 0;
|
||||
let bulkWagons = 0;
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
|
||||
if (isBulk) bulkWagons += 1;
|
||||
else containerWagons += 1;
|
||||
}
|
||||
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
|
||||
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
|
||||
|
||||
// Lock the set of currently-active numbered schedules so two concurrent
|
||||
// dispatches serialize and can't both claim the same lowest-free number.
|
||||
const activeNumbered = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('schedule')
|
||||
.setLock('pessimistic_write')
|
||||
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||||
.andWhere('schedule.train_number IS NOT NULL')
|
||||
.getMany();
|
||||
|
||||
const usedNumbers = activeNumbered
|
||||
.map((s) => s.trainNumber)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
|
||||
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
|
||||
if (!number) {
|
||||
throw new ConflictException(
|
||||
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
|
||||
);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
/** Open or close a schedule's booking window (staff override). */
|
||||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||||
await this.dataSource
|
||||
@@ -931,16 +1005,12 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
const loco = await manager
|
||||
.getRepository(Locomotive)
|
||||
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
|
||||
if (loco) {
|
||||
await manager.getRepository(Locomotive).update(loco.id, {
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
}
|
||||
const arrivingLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (arrivingLocoIds.length) {
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ id: In(arrivingLocoIds) },
|
||||
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
@@ -982,7 +1052,7 @@ export class TrainSchedulingService {
|
||||
async getContainerTrainSchedules() {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
route: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
@@ -1013,10 +1083,11 @@ export class TrainSchedulingService {
|
||||
if (schedule.trainSetId) {
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||||
}
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
|
||||
status: 'AVAILABLE',
|
||||
});
|
||||
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (cancelledLocoIds.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
|
||||
}
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
if (wagon.physicalWagonId) {
|
||||
@@ -1251,24 +1322,29 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
let assignedLocomotive: Locomotive | null = null;
|
||||
let assignedLocomotives: Locomotive[] = [];
|
||||
if (targetScheduleId) {
|
||||
const targetSchedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||||
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
|
||||
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
|
||||
}
|
||||
|
||||
if (assignedLocomotive) {
|
||||
if (assignedLocomotive.currentYardId !== originYardId) {
|
||||
if (assignedLocomotives.length) {
|
||||
// Every locomotive of the set must sit at the origin yard, and the weakest
|
||||
// one must still be able to pull the train (min limits across the set).
|
||||
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
||||
const setLimits = minLocomotiveLimits(assignedLocomotives);
|
||||
if (offYard) {
|
||||
violations.push(
|
||||
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
|
||||
`Locomotive ${offYard.code} is not at the schedule origin yard`,
|
||||
);
|
||||
} else if (
|
||||
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
|
||||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons < totalWeightTons ||
|
||||
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
||||
) {
|
||||
violations.push(
|
||||
'Assigned locomotive cannot support the total train weight and length',
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1818,6 +1894,22 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All locomotives attached to a loaded train set. Prefers the `locomotives`
|
||||
* link rows; falls back to the legacy single `locomotive` for train sets
|
||||
* created before multi-loco support.
|
||||
*/
|
||||
private locomotivesOfTrainSet(
|
||||
trainSet: TrainSet | null | undefined,
|
||||
): Locomotive[] {
|
||||
if (!trainSet) return [];
|
||||
const linked = (trainSet.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||
if (linked.length) return linked;
|
||||
return trainSet.locomotive ? [trainSet.locomotive] : [];
|
||||
}
|
||||
|
||||
async selectOrValidateLocomotive(
|
||||
locomotiveId: string,
|
||||
totalWeightTons: number,
|
||||
@@ -1841,15 +1933,28 @@ export class TrainSchedulingService {
|
||||
return locomotive;
|
||||
}
|
||||
|
||||
private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) {
|
||||
private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) {
|
||||
const [primary] = locomotives;
|
||||
const trainSet = manager.getRepository(TrainSet).create({
|
||||
locomotiveId: locomotive.id,
|
||||
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
|
||||
locomotiveId: primary.id,
|
||||
totalWeightTons: 0,
|
||||
totalLengthMeters: 0,
|
||||
wagonCount: 0,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
return manager.getRepository(TrainSet).save(trainSet);
|
||||
const saved = await manager.getRepository(TrainSet).save(trainSet);
|
||||
|
||||
const links = locomotives.map((loco, index) =>
|
||||
manager.getRepository(TrainSetLocomotive).create({
|
||||
trainSetId: saved.id,
|
||||
locomotiveId: loco.id,
|
||||
sequenceNo: index,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(TrainSetLocomotive).save(links);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async getActiveRoute(routeId: string) {
|
||||
@@ -1915,6 +2020,12 @@ export class TrainSchedulingService {
|
||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||||
id: loco.id,
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
currentYardId: loco.currentYardId ?? null,
|
||||
})),
|
||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||
@@ -1952,7 +2063,7 @@ export class TrainSchedulingService {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
route: { milestones: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
@@ -2099,6 +2210,15 @@ export class TrainSchedulingService {
|
||||
),
|
||||
}
|
||||
: null,
|
||||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||||
id: loco.id,
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
status: loco.status,
|
||||
currentYardId: loco.currentYardId ?? null,
|
||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||
})),
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
|
||||
Reference in New Issue
Block a user