Merge pull request #94 from Tria-plc/freight/feature/add_train_scheduling

Freight/feature/add train scheduling reviewed
This commit is contained in:
Hagernesh Tadesse
2026-06-05 09:38:30 +03:00
committed by GitHub
41 changed files with 3162 additions and 12 deletions

View File

@@ -62,6 +62,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService],
exports: [BookingsService, BookingsRepository],
})
export class BookingsModule {}

View File

@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
status?: string;
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
'ASSIGNED',
'MAINTENANCE',
'INACTIVE',
] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@Index(['status'])
export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
name?: string | null;
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
maxPullWeightTons!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
availableFrom?: Date | null;
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
trainSets?: TrainSet[];
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
@Controller('locomotives')
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@Get()
@ApiOperation({ summary: 'List locomotives' })
findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LocomotivesController } from './locomotives.controller';
import { Locomotive } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
import { LocomotivesService } from './locomotives.service';
@Module({
imports: [TypeOrmModule.forFeature([Locomotive])],
controllers: [LocomotivesController],
providers: [LocomotivesRepository, LocomotivesService],
exports: [LocomotivesRepository, LocomotivesService],
})
export class LocomotivesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Locomotive } from './entities/locomotive.entity';
@Injectable()
export class LocomotivesRepository extends BaseRepository<Locomotive> {
constructor(
@InjectRepository(Locomotive)
repository: Repository<Locomotive>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
export class LocomotivesService {
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
where: filter.status
? { status: filter.status as LocomotiveStatus }
: undefined,
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return locomotive;
}
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from './train-schedule.entity';
@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
@Index(['trainScheduleId', 'bookingId'], { unique: true })
@Index(['bookingId'], { unique: true })
export class TrainScheduleBooking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
export const TRAIN_SCHEDULE_STATUSES = [
'DRAFT',
'SCHEDULED',
'DISPATCHED',
'ARRIVED',
'CANCELLED',
] as const;
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'train_schedules' })
@Index(['scheduledDepartureDate'])
@Index(['status'])
export class TrainSchedule extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid', unique: true })
trainSetId!: string;
@OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule)
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_station_id' })
originStation?: Yard;
@Column({ name: 'destination_station_id', type: 'uuid' })
destinationStationId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_station_id' })
destinationStation?: Yard;
@Column({ name: 'scheduled_departure_date', type: 'timestamptz' })
scheduledDepartureDate!: Date;
@Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true })
scheduledArrivalDate?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainScheduleStatus;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
@Index(['trainSetWagonId', 'bookingId'])
export class WagonBookingAllocation extends BaseEntity {
@Column({ name: 'train_set_wagon_id', type: 'uuid' })
trainSetWagonId!: string;
@ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_wagon_id' })
trainSetWagon?: TrainSetWagon;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
allocatedWeightTons!: number;
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
@Injectable()
export class TrainScheduleBookingsRepository extends BaseRepository<TrainScheduleBooking> {
constructor(
@InjectRepository(TrainScheduleBooking)
repository: Repository<TrainScheduleBooking>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.entity';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.repository';
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
providers: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
],
exports: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
],
})
export class TrainSchedulesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSchedule } from './entities/train-schedule.entity';
@Injectable()
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
constructor(
@InjectRepository(TrainSchedule)
repository: Repository<TrainSchedule>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
@Injectable()
export class WagonBookingAllocationsRepository extends BaseRepository<WagonBookingAllocation> {
constructor(
@InjectRepository(WagonBookingAllocation)
repository: Repository<WagonBookingAllocation>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
@IsOptional()
@IsDateString()
scheduleDate?: string;
@ApiPropertyOptional()
@IsOptional()
status?: string;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
export class PreviewContainerTrainScheduleDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
originStationId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
destinationStationId!: string;
}

View File

@@ -0,0 +1,58 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling')
export class TrainSchedulingController {
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
@Get('container/eligible-bookings')
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Post('container/preview')
@ApiOperation({ summary: 'Preview a container train schedule' })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('container/schedules')
@ApiOperation({ summary: 'Create a container train schedule' })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Get('container/schedules')
@ApiOperation({ summary: 'List container train schedules' })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
}

View File

@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
Locomotive,
WagonType,
TrainSet,
TrainSetWagon,
TrainSchedule,
TrainScheduleBooking,
WagonBookingAllocation,
Yard,
]),
BookingsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],
exports: [TrainSchedulingService],
})
export class TrainSchedulingModule {}

View File

@@ -0,0 +1,328 @@
import { ConflictException } from '@nestjs/common';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
id: 'wagon-type-1',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
};
const locomotive = {
id: 'loc-1',
code: 'LOC-001',
maxPullWeightTons: 3500,
status: 'AVAILABLE',
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
) => ({
id,
reference,
freightType: 'CONTAINER',
cargoTotalWeightVgm: weight,
scheduledDate: new Date(scheduledDate),
originYardId,
destinationYardId,
status: 'APPROVED',
customer: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
quantity,
containerType: { code: containerCode, label: containerCode },
},
],
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let locomotivesRepository: {
findById: jest.Mock;
};
let wagonTypesRepository: {
findAll: jest.Mock;
};
beforeEach(() => {
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
};
locomotivesRepository = {
findById: jest.fn(),
};
wagonTypesRepository = {
findAll: jest.fn(),
};
service = new TrainSchedulingService(
dataSource as never,
locomotivesRepository as never,
wagonTypesRepository as never,
);
});
it('computes the expected valid preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((booking) => booking.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary).toEqual({
totalBookings: 3,
totalWeightTons: 1250,
wagonType: 'NW5',
wagonsNeeded: 18,
totalLengthMeters: 252,
});
expect(result.wagonPlan).toHaveLength(18);
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
});
});
it('flags the overweight booking as invalid', async () => {
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600);
expect(result.violations).toContain(
'Total booking weight 3600T exceeds max train weight 3500T',
);
});
it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
const validation = {
valid: true,
violations: [],
bookings,
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
};
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
};
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
case 'Locomotive':
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet':
return trainSetRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
});
it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -0,0 +1,684 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
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';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
const DEFAULT_WAGON_TYPE_CODE = 'NW5';
const MAX_TRAIN_WEIGHT_TONS = 3500;
const MAX_TRAIN_LENGTH_METERS = 760;
type EligibleBookingItem = {
id: string;
reference: string;
customer: string;
containerType: string;
quantity: number;
weightTons: number;
origin: string;
destination: string;
preferredDepartureDate: string;
status: string;
};
type WagonAllocationRecord = {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
};
type WagonPlanRecord = {
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonAllocationRecord[];
};
type ValidationResult = {
valid: boolean;
violations: string[];
bookings: Booking[];
wagonType: WagonType;
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
wagonPlan: WagonPlanRecord[];
};
@Injectable()
export class TrainSchedulingService {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly locomotivesRepository: LocomotivesRepository,
private readonly wagonTypesRepository: WagonTypesRepository,
) {}
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
const bookingRepository = this.dataSource.getRepository(Booking);
const queryBuilder = bookingRepository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
.where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
.andWhere('scheduleBooking.id IS NULL');
if (query.originStationId) {
queryBuilder.andWhere('booking.originYardId = :originStationId', {
originStationId: query.originStationId,
});
}
if (query.destinationStationId) {
queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: query.destinationStationId,
});
}
if (query.scheduleDate) {
queryBuilder.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
{ scheduleDate: this.toUtcDateKey(query.scheduleDate) },
);
}
if (query.status) {
queryBuilder.andWhere('booking.status = :status', { status: query.status });
}
const bookings = await queryBuilder
.orderBy('booking.scheduled_date', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
const items: EligibleBookingItem[] = bookings.map((booking) => ({
id: booking.id,
reference: booking.reference,
customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer',
containerType: booking.bookingContainers
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
.join(', ') ?? 'Container',
quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
preferredDepartureDate: booking.scheduledDate.toISOString(),
status: booking.status,
}));
return {
count: items.length,
items,
};
}
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
return {
valid: validation.valid,
violations: validation.violations,
summary: validation.summary,
bookingIds: validation.bookings.map((booking) => booking.id),
wagonPlan: validation.wagonPlan,
};
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
if (!validation.valid) {
throw new BadRequestException({
message: 'train_schedule_invalid',
violations: validation.violations,
});
}
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
validation.summary.totalWeightTons,
);
const createdSchedule = await this.dataSource.transaction(async (manager) => {
const locomotiveRepository = manager.getRepository(Locomotive);
const lockedLocomotive = await locomotiveRepository.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`);
}
if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({
where: { bookingId: In(validation.bookings.map((booking) => booking.id)) },
});
if (existingScheduleCount > 0) {
throw new BadRequestException('One or more bookings are already scheduled');
}
const trainSet = await this.buildTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: 'SCHEDULED',
});
const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager.getRepository(TrainScheduleBooking).save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: 'ASC' },
});
const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon]));
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager.getRepository(WagonBookingAllocation).save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, {
status: 'ASSIGNED',
});
return savedSchedule.id;
});
return this.getContainerTrainScheduleById(createdSchedule);
}
async validateContainerBookingsForScheduling(
dto: PreviewContainerTrainScheduleDto,
): Promise<ValidationResult> {
const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) {
throw new BadRequestException('At least one booking is required');
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`);
}
const bookings = await this.loadBookingsForScheduling(bookingIds);
const violations: string[] = [];
if (bookings.length !== bookingIds.length) {
const foundIds = new Set(bookings.map((booking) => booking.id));
const missing = bookingIds.filter((id) => !foundIds.has(id));
violations.push(`Bookings not found: ${missing.join(', ')}`);
}
const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { bookingId: In(bookingIds) },
select: { bookingId: true },
});
if (scheduledLinks.length > 0) {
violations.push('One or more selected bookings are already assigned to a train schedule');
}
const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER');
if (nonContainerBookings.length > 0) {
violations.push('Only CONTAINER bookings are supported for train scheduling');
}
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
const routeMismatch = bookings.some(
(booking) =>
booking.originYardId !== dto.originStationId ||
booking.destinationYardId !== dto.destinationStationId,
);
if (routeMismatch) {
violations.push('Selected bookings must share the same origin and destination as the schedule');
}
const dateMismatch = bookings.some(
(booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
);
if (dateMismatch) {
violations.push('Selected bookings must share the same schedule date');
}
const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size;
if (uniqueOriginCount > 1) {
violations.push('Selected bookings must share the same origin station');
}
const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size;
if (uniqueDestinationCount > 1) {
violations.push('Selected bookings must share the same destination station');
}
const uniqueDateCount = new Set(
bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
).size;
if (uniqueDateCount > 1) {
violations.push('Selected bookings must share the same preferred departure date');
}
const totalWeightTons = this.roundTons(
bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0),
);
const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType));
const totalLengthMeters = this.roundTons(
wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
);
if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`);
}
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
violations.push(
`Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`,
);
}
if (
wagonType.maxWagonsPerTrain != null &&
wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
) {
violations.push(
`Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
);
}
const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({
where: { status: 'AVAILABLE' as LocomotiveStatus },
});
if (availableLocomotiveCount === 0) {
violations.push('No available locomotive exists for scheduling');
} else {
const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({
where: { status: 'AVAILABLE' },
});
const canPull = capableLocomotives.some(
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
);
if (!canPull) {
violations.push('No available locomotive can pull the total weight');
}
}
return {
valid: violations.length === 0,
violations,
bookings,
wagonType,
summary: {
totalBookings: bookings.length,
totalWeightTons,
wagonType: wagonType.code,
wagonsNeeded: wagonPlan.length,
totalLengthMeters,
},
wagonPlan,
};
}
calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] {
const wagonCapacityTons = Number(wagonType.capacityTons);
const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
let remainingWeight = this.roundTons(totalBookingWeightTons);
return Array.from({ length: wagonsNeeded }, (_, index) => {
const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight));
remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons));
return {
sequenceNo: index + 1,
capacityTons: wagonCapacityTons,
lengthMeters: this.roundTons(Number(wagonType.lengthMeters)),
assignedWeightTons,
allocations: [],
};
});
}
async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locomotive.status !== 'AVAILABLE') {
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
);
}
return locomotive;
}
async buildTrainSet(
manager: EntityManager,
locomotive: Locomotive,
wagonType: WagonType,
totalWeightTons: number,
totalLengthMeters: number,
wagonPlan: WagonPlanRecord[],
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons,
totalLengthMeters,
wagonCount: wagonPlan.length,
status: 'ASSIGNED',
});
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
const wagons = wagonPlan.map((wagon) =>
manager.getRepository(TrainSetWagon).create({
trainSetId: savedTrainSet.id,
wagonTypeId: wagonType.id,
sequenceNo: wagon.sequenceNo,
capacityTons: wagon.capacityTons,
lengthMeters: wagon.lengthMeters,
assignedWeightTons: wagon.assignedWeightTons,
}),
);
await manager.getRepository(TrainSetWagon).save(wagons);
return savedTrainSet;
}
allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] {
const remaining = bookings.map((booking) => ({
bookingId: booking.id,
bookingReference: booking.reference,
remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
}));
let bookingIndex = 0;
return baseWagonPlan.map((wagon) => {
let wagonRemaining = this.roundTons(wagon.capacityTons);
const allocations: WagonAllocationRecord[] = [];
let assignedWeightTons = 0;
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
const allocatedWeightTons = this.roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
bookingIndex += 1;
continue;
}
allocations.push({
bookingId: booking.bookingId,
bookingReference: booking.bookingReference,
allocatedWeightTons,
});
booking.remainingWeightTons = this.roundTons(
booking.remainingWeightTons - allocatedWeightTons,
);
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons);
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
}
}
return {
...wagon,
assignedWeightTons,
allocations,
};
});
}
async getContainerTrainSchedules() {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
trainSet: { locomotive: true },
originStation: true,
destinationStation: true,
scheduleBookings: true,
},
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
});
return schedules.map((schedule) => ({
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
locomotive: schedule.trainSet?.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null,
}
: null,
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
bookingsCount: schedule.scheduleBookings?.length ?? 0,
status: schedule.status,
}));
}
async getContainerTrainScheduleById(id: string) {
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id },
relations: {
trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } },
},
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
return {
id: schedule.id,
status: schedule.status,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation,
destinationStation: schedule.destinationStation,
trainSet: schedule.trainSet
? {
id: schedule.trainSet.id,
status: schedule.trainSet.status,
wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)),
totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)),
locomotive: schedule.trainSet.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status,
maxPullWeightTons: this.roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
}
: null,
wagons:
[...(schedule.trainSet.wagons ?? [])]
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((wagon) => ({
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: this.roundTons(Number(wagon.capacityTons)),
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)),
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations:
wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)),
})) ?? [],
})),
}
: null,
bookings:
schedule.scheduleBookings?.map((scheduleBooking) => ({
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
reference: scheduleBooking.booking?.reference ?? null,
customer:
scheduleBooking.booking?.customer?.companyName ??
scheduleBooking.booking?.customer?.email ??
null,
weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)),
status: scheduleBooking.booking?.status ?? null,
})) ?? [],
};
}
async cancelTrainSchedule(id: string) {
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id },
relations: { trainSet: { locomotive: true } },
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSchedule).update(schedule.id, {
status: 'CANCELLED',
});
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',
});
}
});
return this.getContainerTrainScheduleById(id);
}
private async loadBookingsForScheduling(bookingIds: string[]) {
return this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
relations: {
customer: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
},
order: { createdAt: 'ASC' },
});
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);
}
private roundTons(value: number | string | null | undefined) {
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
if (!Number.isFinite(numericValue)) {
return 0;
}
return Number(numericValue.toFixed(3));
}
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSet } from './train-set.entity';
@Entity({ schema: 'freight', name: 'train_set_wagons' })
@Index(['trainSetId', 'sequenceNo'], { unique: true })
export class TrainSetWagon extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
@ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType;
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
capacityTons!: number;
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
assignedWeightTons!: number;
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
allocations?: WagonBookingAllocation[];
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
'DRAFT',
'ASSIGNED',
'DISPATCHED',
'COMPLETED',
'CANCELLED',
] as const;
export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Entity({ schema: 'freight', name: 'train_sets' })
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;
@Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 })
totalLengthMeters!: number;
@Column({ name: 'wagon_count', type: 'int' })
wagonCount!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainSetStatus;
@OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet)
wagons?: TrainSetWagon[];
@OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet)
trainSchedule?: TrainSchedule;
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
@Injectable()
export class TrainSetWagonsRepository extends BaseRepository<TrainSetWagon> {
constructor(
@InjectRepository(TrainSetWagon)
repository: Repository<TrainSetWagon>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})
export class TrainSetsModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSet } from './entities/train-set.entity';
@Injectable()
export class TrainSetsRepository extends BaseRepository<TrainSet> {
constructor(
@InjectRepository(TrainSet)
repository: Repository<TrainSet>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
@Entity({ schema: 'freight', name: 'wagon_types' })
@Index(['code'])
@Index(['isActive'])
export class WagonType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
capacityTons!: number;
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'max_wagons_per_train', type: 'int', nullable: true })
maxWagonsPerTrain?: number | null;
@Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
supportedLoadTypes!: string[];
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType)
trainSetWagons?: TrainSetWagon[];
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository';
import { WagonTypesService } from './wagon-types.service';
@Module({
imports: [TypeOrmModule.forFeature([WagonType])],
providers: [WagonTypesRepository, WagonTypesService],
exports: [WagonTypesRepository, WagonTypesService],
})
export class WagonTypesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WagonType } from './entities/wagon-type.entity';
@Injectable()
export class WagonTypesRepository extends BaseRepository<WagonType> {
constructor(
@InjectRepository(WagonType)
repository: Repository<WagonType>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository';
@Injectable()
export class WagonTypesService {
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
async findByCode(code: string): Promise<WagonType> {
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
if (!wagonType) {
throw new NotFoundException(`Wagon type ${code} not found`);
}
return wagonType;
}
}