feat(freight): basics of train scheduling

This commit is contained in:
Michael Abebe
2026-06-04 16:50:38 +03:00
parent ec0d789502
commit 8b3aaad5fd
41 changed files with 3147 additions and 12 deletions

View File

@@ -13,6 +13,11 @@ import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
@@ -30,6 +35,7 @@ import {
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
@Module({
@@ -60,6 +66,11 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
FilesModule,
ConsignmentsModule,
TrainsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
TrainSchedulingModule,
CustomersModule,
CompaniesModule,
TrackingModule,
@@ -72,13 +83,14 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
BackofficeModule,
DemoPermissionsModule,
],
providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder],
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { }
@@ -86,6 +98,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.demoBookingsSeeder.run();
await this.fileUploadSettingsSeeder.run();
}
}

View File

@@ -0,0 +1,153 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTrainScheduling1749400000000 implements MigrationInterface {
name = 'AddTrainScheduling1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
max_wagons_per_train INT NULL,
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.locomotives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NULL,
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
available_from TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
locomotive_id UUID NOT NULL,
total_weight_tons NUMERIC(10,3) NOT NULL,
total_length_meters NUMERIC(10,3) NOT NULL,
wagon_count INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL,
wagon_type_id UUID NOT NULL,
sequence_no INT NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL UNIQUE,
origin_station_id UUID NOT NULL,
destination_station_id UUID NOT NULL,
scheduled_departure_date TIMESTAMPTZ NOT NULL,
scheduled_arrival_date TIMESTAMPTZ NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id),
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
REFERENCES freight.yards(id),
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
REFERENCES freight.yards(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL,
booking_id UUID NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_wagon_id UUID NOT NULL,
booking_id UUID NOT NULL,
allocated_weight_tons NUMERIC(10,3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_status
ON freight.locomotives(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_sets_status
ON freight.train_sets(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
ON freight.train_schedules(scheduled_departure_date, status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
ON freight.wagon_booking_allocations(booking_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
}
}

View File

@@ -60,6 +60,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,678 @@
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) {
return Number(value.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;
}
}

View File

@@ -0,0 +1,265 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Customer } from '../modules/customers/entities/customer.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
const SEED_FLAG = 'SEED_DEMO_BOOKINGS';
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER';
const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
{ code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 },
];
const CONTAINER_TYPES = [
{ code: '20FT', label: '20FT', sizeFt: 20 },
{ code: '40FT', label: '40FT', sizeFt: 40 },
];
const DEMO_BOOKINGS = [
{
reference: 'BKG-CONT-001',
containerCode: '40FT',
quantity: 20,
totalWeightTons: 500,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-002',
containerCode: '20FT',
quantity: 10,
totalWeightTons: 300,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-003',
containerCode: '40FT',
quantity: 15,
totalWeightTons: 450,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-004',
containerCode: '40FT',
quantity: 12,
totalWeightTons: 360,
originCode: 'ADDIS_ABABA',
destinationCode: 'DIRE_DAWA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-005',
containerCode: '20FT',
quantity: 8,
totalWeightTons: 160,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-21T08:00:00.000Z',
},
{
reference: 'BKG-CONT-006',
containerCode: '40FT',
quantity: 80,
totalWeightTons: 3600,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
];
@Injectable()
export class DemoBookingsSeeder {
private readonly logger = new Logger(DemoBookingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true';
if (!shouldSeed) {
this.logger.log(`Skipping demo booking seed because ${SEED_FLAG} is not enabled`);
return;
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WagonType).upsert(
{
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(Locomotive).upsert(
[
{
code: 'LOC-001',
name: 'Demo Locomotive 1',
maxPullWeightTons: 3500,
status: 'AVAILABLE',
},
{
code: 'LOC-002',
name: 'Demo Locomotive 2',
maxPullWeightTons: 2500,
status: 'AVAILABLE',
},
],
{ conflictPaths: { code: true } },
);
await manager.getRepository(Yard).upsert(
YARDS.map((yard) => ({ ...yard, isActive: true })),
{ conflictPaths: { code: true } },
);
await manager.getRepository(ServiceType).upsert(
{
code: SERVICE_TYPE_CODE,
serviceName: 'Rail Container Service',
description: 'Temporary service type for train scheduling demos',
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({
...containerType,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: index + 1,
})),
{ conflictPaths: { code: true } },
);
await manager.getRepository(Customer).upsert(
{
userId: '00000000-0000-0000-0000-000000000111',
firstName: 'Train',
lastName: 'Scheduling',
email: CUSTOMER_EMAIL,
phone: '251900000001',
companyName: 'Train Scheduling Demo Customer',
companyEmail: CUSTOMER_EMAIL,
companyPhone: '251900000001',
companyLocation: 'Addis Ababa',
companyAddress: 'Demo Address',
customerType: 'DEMO',
status: 'ACTIVE',
contactPersonName: 'Train Scheduling',
contactPersonPhone: '251900000001',
tinNumber: '1234567890',
vatNumber: '1234567890',
fanNumber: '1234567890123456',
generalManagerName: 'Demo Manager',
generalManagerEmail: CUSTOMER_EMAIL,
generalManagerPhone: '251900000001',
},
{ conflictPaths: { email: true } },
);
const [serviceType, customer, yards, containerTypes] = await Promise.all([
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }),
manager.getRepository(Yard).find(),
manager.getRepository(ContainerType).find(),
]);
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
const containerTypeByCode = new Map(
containerTypes.map((containerType) => [containerType.code, containerType]),
);
for (const demoBooking of DEMO_BOOKINGS) {
const origin = yardByCode.get(demoBooking.originCode);
const destination = yardByCode.get(demoBooking.destinationCode);
const containerType = containerTypeByCode.get(demoBooking.containerCode);
if (!origin || !destination || !containerType) {
throw new Error(`demo_booking_seed_dependency_missing:${demoBooking.reference}`);
}
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert(
{
reference: demoBooking.reference,
customerId: customer.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 0,
paymentStatus: 'PENDING',
contractType: 'NEW',
serviceTypeId: serviceType.id,
equipmentReturn: 'WITHOUT_RETURN',
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: null,
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
paymentCurrency: 'USD',
allowConsolidation: false,
priorityScore: 0,
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
const booking = await manager.getRepository(Booking).findOneByOrFail({
reference: demoBooking.reference,
});
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
containerTypeId: containerType.id,
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70),
weightLimitRuleId: null,
isOverweight: demoBooking.totalWeightTons > 70,
overweightExcessTons:
demoBooking.totalWeightTons > 70 ? demoBooking.totalWeightTons - 70 : null,
});
}
});
this.logger.log('Seeded demo train scheduling data');
}
}

View File

@@ -7,6 +7,7 @@ import {
Paperclip,
Settings,
SlidersHorizontal,
TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
@@ -29,6 +30,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
@@ -46,6 +48,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <TrainTrack />,
},
...demoItems,
],
},
@@ -199,6 +206,7 @@ const App = () => {
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />

View File

@@ -1,5 +1,6 @@
import type { BookingListFilter } from "@/services/bookings.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { TrainScheduleFilters } from "@/types/trainScheduling";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const QUERY_KEYS = {
@@ -35,6 +36,16 @@ export const QUERY_KEYS = {
byId: (id: string) => ["bookings", "detail", id] as const,
},
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", filters ?? {}] as const,
locomotives: () => ["train-scheduling", "locomotives"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
REFERENCE_DATA: "/bookings/reference-data",
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
@@ -111,6 +112,19 @@ export const URL_CONSTANTS = {
VERIFY: "/api/otp/verify",
},
LOCOMOTIVES: {
BASE: "/locomotives",
},
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`,
},
RULE_ENGINE: {
CARGO_TYPES: "/cargo-types",
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,

View File

@@ -76,7 +76,11 @@ export const useContainerTypeOptions = (
enabled = true,
) =>
useQuery({
queryKey: api.ruleEngine.list.queryKey(),
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
includeNone,
}),
queryFn: () =>
api.ruleEngine.list.call({
resource: "container-types",

View File

@@ -1,11 +1,760 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import toast from 'react-hot-toast';
import { Calendar, RefreshCw, TrainTrack } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
EligibleContainerBooking,
TrainScheduleFilters,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
const inputClassName =
'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950';
const formatDate = (value?: string | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const formatDayInput = (value?: string | null) => {
if (!value) return '';
return value.slice(0, 10);
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
const violations = error.response?.data?.violations;
if (Array.isArray(violations)) return violations.join(', ');
}
return fallback;
};
const deriveFromBooking = (
booking: EligibleContainerBooking | undefined,
stations: YardOption[],
) => {
if (!booking) {
return { originStationId: '', destinationStationId: '', scheduleDate: '' };
}
const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? '';
const destinationStationId =
stations.find((station) => station.name === booking.destination)?.id ?? '';
return {
originStationId,
destinationStationId,
scheduleDate: formatDayInput(booking.preferredDepartureDate),
};
};
const TrainsPage = () => {
const qc = useQueryClient();
const [filters, setFilters] = useState<TrainScheduleFilters>({});
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const [scheduleSearch, setScheduleSearch] = useState('');
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
const stationsQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
queryFn: () => trainSchedulingService.getStations(),
});
const eligibleQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters),
queryFn: () => trainSchedulingService.getEligibleBookings(filters),
});
const locomotivesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
const schedulesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(),
});
const detailQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
enabled: Boolean(detailId),
});
const eligibleItems = eligibleQuery.data?.items ?? [];
const filteredSchedules = useMemo(() => {
const query = scheduleSearch.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((schedule) => {
const matchesStatus =
scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
if (!matchesStatus) {
return false;
}
if (!query) {
return true;
}
const haystack = [
schedule.id,
schedule.origin ?? '',
schedule.destination ?? '',
schedule.locomotive?.code ?? '',
schedule.status,
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
const selectedBookings = useMemo(
() => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)),
[eligibleItems, selectedBookingIds],
);
const summary = useMemo(() => {
const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0);
const wagonsNeeded = Math.ceil(totalWeightTons / 70);
const totalLengthMeters = wagonsNeeded * 14;
const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`));
const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate)));
return {
count: selectedBookings.length,
totalWeightTons,
wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0,
totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0,
route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-',
scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-',
};
}, [selectedBookings]);
const previewMutation = useMutation({
mutationFn: () => {
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.preview({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
});
},
onSuccess: (data) => {
setPreview(data);
toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues');
},
onError: (error) => {
toast.error(parseError(error, 'Failed to preview train schedule'));
},
});
const createMutation = useMutation({
mutationFn: () => {
if (!selectedLocomotiveId) {
throw new Error('Please select a locomotive');
}
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.createSchedule({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
locomotiveId: selectedLocomotiveId,
});
},
onSuccess: (data) => {
toast.success('Train schedule created');
setSelectedBookingIds([]);
setSelectedLocomotiveId('');
setPreview(null);
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to create train schedule'));
},
});
const cancelMutation = useMutation({
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
onSuccess: (data) => {
toast.success('Train schedule cancelled');
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to cancel train schedule'));
},
});
const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => {
setSelectedBookingIds((current) => {
if (checked) {
const next = [...new Set([...current, booking.id])];
if (next.length === 1) {
const defaults = deriveFromBooking(booking, stationsQuery.data ?? []);
setFilters((prev) => ({
...prev,
originStationId: prev.originStationId || defaults.originStationId,
destinationStationId: prev.destinationStationId || defaults.destinationStationId,
scheduleDate: prev.scheduleDate || defaults.scheduleDate,
}));
}
return next;
}
return current.filter((id) => id !== booking.id);
});
setPreview(null);
};
const detail = detailQuery.data;
const isBusy = previewMutation.isPending || createMutation.isPending;
return (
<FeaturePlaceholder
title="Trains"
description="Coordinate train assignments, scheduling visibility, and operational readiness."
/>
<div className="space-y-6 p-6">
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<TrainTrack className="size-7" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
</p>
</div>
</div>
<Button
variant="outline"
className="gap-2"
onClick={() => {
void eligibleQuery.refetch();
void schedulesQuery.refetch();
void locomotivesQuery.refetch();
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="grid gap-6 p-6 xl:grid-cols-[1.8fr,1fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Filters
</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<label className="text-sm font-medium">Origin station</label>
<Select
value={filters.originStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
originStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All origins" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All origins</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Destination station</label>
<Select
value={filters.destinationStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
destinationStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All destinations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All destinations</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Schedule date</label>
<input
className={inputClassName}
type="date"
value={filters.scheduleDate ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
scheduleDate: event.target.value || undefined,
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Booking status</label>
<input
className={inputClassName}
placeholder="APPROVED"
value={filters.status ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
status: event.target.value || undefined,
}))
}
/>
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
<p className="text-sm text-muted-foreground">
Only container bookings not already assigned to a schedule appear here.
</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{eligibleQuery.data?.count ?? 0} bookings
</span>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Select</th>
<th className="px-3 py-3">Booking</th>
<th className="px-3 py-3">Customer</th>
<th className="px-3 py-3">Container</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Origin</th>
<th className="px-3 py-3">Destination</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{eligibleItems.map((booking) => (
<tr key={booking.id} className="hover:bg-muted/20">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedBookingIds.includes(booking.id)}
onChange={(event) => toggleBooking(booking, event.target.checked)}
/>
</td>
<td className="px-3 py-3 font-medium">{booking.reference}</td>
<td className="px-3 py-3">{booking.customer}</td>
<td className="px-3 py-3">{booking.containerType}</td>
<td className="px-3 py-3">{booking.quantity}</td>
<td className="px-3 py-3">{booking.weightTons.toLocaleString()} T</td>
<td className="px-3 py-3">{booking.origin}</td>
<td className="px-3 py-3">{booking.destination}</td>
<td className="px-3 py-3">{formatDate(booking.preferredDepartureDate)}</td>
<td className="px-3 py-3">{booking.status}</td>
</tr>
))}
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No eligible container bookings matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<h2 className="text-lg font-semibold">Schedule builder</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected bookings</p>
<p className="mt-2 text-2xl font-semibold">{summary.count}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total weight</p>
<p className="mt-2 text-2xl font-semibold">{summary.totalWeightTons.toLocaleString()} T</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">{summary.route}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule date</p>
<p className="mt-2 text-sm font-medium">{summary.scheduleDate}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagon type</p>
<p className="mt-2 text-sm font-medium">NW5</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagons / length</p>
<p className="mt-2 text-sm font-medium">
{summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-3">
<Button
className="w-full"
disabled={!selectedBookingIds.length || isBusy}
onClick={() => previewMutation.mutate()}
>
Preview schedule
</Button>
<div className="space-y-2">
<label className="text-sm font-medium">Locomotive</label>
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
<SelectTrigger>
<SelectValue placeholder="Select available locomotive" />
</SelectTrigger>
<SelectContent>
{(locomotivesQuery.data ?? []).map((locomotive) => (
<SelectItem key={locomotive.id} value={locomotive.id}>
{locomotive.code} - {locomotive.maxPullWeightTons}T
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="w-full"
disabled={!preview?.valid || !selectedLocomotiveId || isBusy}
onClick={() => createMutation.mutate()}
>
Create schedule
</Button>
</div>
{preview ? (
<div className="mt-5 space-y-4 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Preview result</h3>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
preview.valid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'
}`}
>
{preview.valid ? 'Valid' : 'Invalid'}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Wagons</p>
<p className="mt-1 font-semibold">{preview.summary.wagonsNeeded}</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Weight</p>
<p className="mt-1 font-semibold">{preview.summary.totalWeightTons} T</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Length</p>
<p className="mt-1 font-semibold">{preview.summary.totalLengthMeters} m</p>
</div>
</div>
{preview.violations.length > 0 ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700 dark:border-rose-950 dark:bg-rose-950/30 dark:text-rose-300">
<ul className="list-disc space-y-1 pl-5">
{preview.violations.map((violation) => (
<li key={violation}>{violation}</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Created schedules</h2>
<p className="text-sm text-muted-foreground">Open a schedule to inspect wagons and allocations.</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{filteredSchedules.length} schedules
</span>
</div>
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
<input
className={inputClassName}
placeholder="Search by schedule, route, locomotive, or status"
value={scheduleSearch}
onChange={(event) => setScheduleSearch(event.target.value)}
/>
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All statuses</SelectItem>
<SelectItem value="DRAFT">DRAFT</SelectItem>
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Schedule</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Route</th>
<th className="px-3 py-3">Locomotive</th>
<th className="px-3 py-3">Bookings</th>
<th className="px-3 py-3">Wagons</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Length</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{filteredSchedules.map((schedule) => (
<tr key={schedule.id} className="hover:bg-muted/20">
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
<td className="px-3 py-3">
{schedule.origin} to {schedule.destination}
</td>
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
<td className="px-3 py-3">{schedule.bookingsCount}</td>
<td className="px-3 py-3">{schedule.wagonCount}</td>
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
<td className="px-3 py-3">{schedule.status}</td>
<td className="px-3 py-3">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
View
</Button>
{schedule.status !== 'CANCELLED' ? (
<Button
variant="outline"
size="sm"
onClick={() => cancelMutation.mutate(schedule.id)}
>
Cancel
</Button>
) : null}
</div>
</td>
</tr>
))}
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No train schedules matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
</div>
</section>
<Dialog open={Boolean(detailId)} onOpenChange={(open) => (!open ? setDetailId(null) : null)}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Train schedule detail</DialogTitle>
<DialogDescription>
Inspect the selected schedule, locomotive, wagons, and booking allocations.
</DialogDescription>
</DialogHeader>
{detail ? (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Departure</p>
<p className="mt-2 text-sm font-medium">{formatDate(detail.scheduledDepartureDate)}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Status</p>
<p className="mt-2 text-sm font-medium">{detail.status}</p>
</div>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Locomotive</h3>
<p className="mt-2 text-sm text-muted-foreground">
{detail.trainSet?.locomotive
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)`
: 'No locomotive attached'}
</p>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
<div className="mt-4 space-y-4">
{(detail.trainSet?.wagons ?? []).map((wagon) => (
<div key={wagon.id} className="rounded-xl border border-border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
</p>
<p className="text-sm text-muted-foreground">
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
</p>
</div>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Allocated weight</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{wagon.allocations.map((allocation) => (
<tr key={allocation.id}>
<td className="px-3 py-2">{allocation.bookingReference ?? allocation.bookingId}</td>
<td className="px-3 py-2">{allocation.allocatedWeightTons} T</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Reference</th>
<th className="px-3 py-2">Customer</th>
<th className="px-3 py-2">Weight</th>
<th className="px-3 py-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{detail.bookings.map((booking) => (
<tr key={booking.id}>
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
<td className="px-3 py-2">{booking.weightTons} T</td>
<td className="px-3 py-2">{booking.status ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Loading schedule detail...</p>
)}
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { api as client } from '../auth/http';
import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
LocomotiveRecord,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
interface BookingReferenceDataResponse {
yard?: YardOption[];
}
export const trainSchedulingService = {
getEligibleBookings: async (
filters?: TrainScheduleFilters,
): Promise<EligibleContainerBookingsResponse> => {
const response = await client.get<EligibleContainerBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS,
{ params: filters },
);
return unwrap(response.data);
},
preview: async (
payload: TrainSchedulePreviewPayload,
): Promise<TrainSchedulePreviewResponse> => {
const response = await client.post<TrainSchedulePreviewResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW,
payload,
);
return unwrap(response.data);
},
createSchedule: async (
payload: CreateTrainSchedulePayload,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
payload,
);
return unwrap(response.data);
},
listSchedules: async (): Promise<TrainScheduleListItem[]> => {
const response = await client.get<TrainScheduleListItem[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
);
return unwrap(response.data);
},
getScheduleById: async (id: string): Promise<TrainScheduleDetail> => {
const response = await client.get<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id),
);
return unwrap(response.data);
},
cancelSchedule: async (id: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id),
{},
);
return unwrap(response.data);
},
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
params: { status: 'AVAILABLE' },
});
return unwrap(response.data);
},
getStations: async (): Promise<YardOption[]> => {
const response = await client.get<BookingReferenceDataResponse>(
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
);
const data = unwrap(response.data);
return data.yard ?? [];
},
};

View File

@@ -0,0 +1,154 @@
export interface YardOption {
id: string;
name: string;
code: string;
country?: string;
}
export interface EligibleContainerBooking {
id: string;
reference: string;
customer: string;
containerType: string;
quantity: number;
weightTons: number;
origin: string;
destination: string;
preferredDepartureDate: string;
status: string;
}
export interface EligibleContainerBookingsResponse {
count: number;
items: EligibleContainerBooking[];
}
export interface WagonPlanAllocation {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
}
export interface WagonPlanRow {
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonPlanAllocation[];
}
export interface TrainSchedulePreviewResponse {
valid: boolean;
violations: string[];
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
bookingIds: string[];
wagonPlan: WagonPlanRow[];
}
export interface LocomotiveRecord {
id: string;
code: string;
name?: string | null;
maxPullWeightTons: number;
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
availableFrom?: string | null;
}
export interface TrainScheduleListItem {
id: string;
scheduleDate: string;
origin: string | null;
destination: string | null;
locomotive:
| {
id: string;
code: string;
name?: string | null;
}
| null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
status: string;
}
export interface TrainScheduleDetail {
id: string;
status: string;
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
originStation?: {
id: string;
label?: string;
code?: string;
} | null;
destinationStation?: {
id: string;
label?: string;
code?: string;
} | null;
trainSet?: {
id: string;
status: string;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
locomotive?: {
id: string;
code: string;
name?: string | null;
status: string;
maxPullWeightTons: number;
} | null;
wagons: Array<{
id: string;
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
wagonType?: {
id: string;
code: string;
name: string;
} | null;
allocations: Array<{
id: string;
bookingId: string;
bookingReference: string | null;
allocatedWeightTons: number;
}>;
}>;
} | null;
bookings: Array<{
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
status: string | null;
}>;
}
export interface TrainScheduleFilters {
originStationId?: string;
destinationStationId?: string;
scheduleDate?: string;
status?: string;
}
export interface TrainSchedulePreviewPayload {
bookingIds: string[];
scheduleDate: string;
originStationId: string;
destinationStationId: string;
}
export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
locomotiveId: string;
}

View File

@@ -50,9 +50,6 @@ export function endpoint<TInput, TResponse>(
if (queryKeyBuilder && input !== undefined) {
return queryKeyBuilder(input as TInput);
}
if (queryKeyBuilder && input === undefined) {
return queryKeyBuilder(undefined as TInput);
}
return input === undefined
? [service, action]
: [service, action, input];
@@ -124,4 +121,4 @@ export function unwrap<T>(response: { data: T } | T): T {
}
return response as T;
}
}