From 8b3aaad5fd94eb1f06b873abe544d61b07c61509 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 4 Jun 2026 16:50:38 +0300 Subject: [PATCH] feat(freight): basics of train scheduling --- apps/edr-freight-api/src/app.module.ts | 15 +- .../1749400000000-AddTrainScheduling.ts | 153 ++++ .../src/modules/bookings/bookings.module.ts | 2 +- .../locomotives/dto/filter-locomotives.dto.ts | 11 + .../locomotives/entities/locomotive.entity.ts | 36 + .../locomotives/locomotives.controller.ts | 18 + .../modules/locomotives/locomotives.module.ts | 15 + .../locomotives/locomotives.repository.ts | 16 + .../locomotives/locomotives.service.ts | 29 + .../entities/train-schedule-booking.entity.ts | 26 + .../entities/train-schedule.entity.ts | 54 ++ .../wagon-booking-allocation.entity.ts | 26 + .../train-schedule-bookings.repository.ts | 16 + .../train-schedules/train-schedules.module.ts | 24 + .../train-schedules.repository.ts | 16 + .../wagon-booking-allocations.repository.ts | 16 + .../create-container-train-schedule.dto.ts | 10 + .../get-eligible-container-bookings.dto.ts | 23 + .../preview-container-train-schedule.dto.ts | 22 + .../train-scheduling.controller.ts | 58 ++ .../train-scheduling.module.ts | 46 ++ .../train-scheduling.service.spec.ts | 328 ++++++++ .../train-scheduling.service.ts | 678 ++++++++++++++++ .../entities/train-set-wagon.entity.ts | 39 + .../train-sets/entities/train-set.entity.ts | 46 ++ .../train-sets/train-set-wagons.repository.ts | 16 + .../modules/train-sets/train-sets.module.ts | 14 + .../train-sets/train-sets.repository.ts | 16 + .../wagon-types/entities/wagon-type.entity.ts | 33 + .../modules/wagon-types/wagon-types.module.ts | 13 + .../wagon-types/wagon-types.repository.ts | 16 + .../wagon-types/wagon-types.service.ts | 19 + .../src/seed/demo-bookings.seeder.ts | 265 ++++++ apps/edr-freight-web/backoffice/src/App.tsx | 8 + .../backoffice/src/constants/QUERY_KEYS.ts | 11 + .../backoffice/src/constants/URLS.ts | 14 + .../src/hooks/rule-engine/useRuleEngine.ts | 6 +- .../src/pages/trains/TrainsPage.tsx | 759 +++++++++++++++++- .../src/services/trainScheduling.service.ts | 87 ++ .../backoffice/src/types/trainScheduling.ts | 154 ++++ .../backoffice/src/utils/endpoint.ts | 5 +- 41 files changed, 3147 insertions(+), 12 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts create mode 100644 apps/edr-freight-api/src/seed/demo-bookings.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/trainScheduling.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 580e77e0c..fff9ac3b5 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); } } diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts new file mode 100644 index 000000000..5e61797da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts @@ -0,0 +1,153 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTrainScheduling1749400000000 implements MigrationInterface { + name = 'AddTrainScheduling1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 1f96176ba..2114a64b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -60,6 +60,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu ContractRendererService, ContractPdfService, ], - exports: [BookingsService], + exports: [BookingsService, BookingsRepository], }) export class BookingsModule {} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts new file mode 100644 index 000000000..3ee26beb9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts new file mode 100644 index 000000000..1676674b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts new file mode 100644 index 000000000..9e64c1e2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts new file mode 100644 index 000000000..264b9cb44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts new file mode 100644 index 000000000..af2a40f50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -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 { + constructor( + @InjectRepository(Locomotive) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts new file mode 100644 index 000000000..946a77d48 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -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 { + return this.locomotivesRepository.findAll({ + where: filter.status + ? { status: filter.status as LocomotiveStatus } + : undefined, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const locomotive = await this.locomotivesRepository.findById(id); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return locomotive; + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts new file mode 100644 index 000000000..4ffecea26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts new file mode 100644 index 000000000..965723f6c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts new file mode 100644 index 000000000..4c78256fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts new file mode 100644 index 000000000..d7360226f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -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 { + constructor( + @InjectRepository(TrainScheduleBooking) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts new file mode 100644 index 000000000..9fa40897a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts new file mode 100644 index 000000000..b6f18eaf2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -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 { + constructor( + @InjectRepository(TrainSchedule) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts new file mode 100644 index 000000000..067dddaf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -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 { + constructor( + @InjectRepository(WagonBookingAllocation) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts new file mode 100644 index 000000000..173b0a6b3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts new file mode 100644 index 000000000..8712b2a1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts new file mode 100644 index 000000000..e3e142a61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts new file mode 100644 index 000000000..1dd01ffba --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts new file mode 100644 index 000000000..dbf79e403 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts new file mode 100644 index 000000000..659a4d6eb --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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) => + 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) => + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts new file mode 100644 index 000000000..0fd0546b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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)); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts new file mode 100644 index 000000000..780bc1977 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts new file mode 100644 index 000000000..9099824d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts new file mode 100644 index 000000000..5296b04a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts @@ -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 { + constructor( + @InjectRepository(TrainSetWagon) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts new file mode 100644 index 000000000..f11052727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts new file mode 100644 index 000000000..a6cadbf2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts @@ -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 { + constructor( + @InjectRepository(TrainSet) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts new file mode 100644 index 000000000..f1bfeedea --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts new file mode 100644 index 000000000..3cb23cc3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts new file mode 100644 index 000000000..001ff0212 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -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 { + constructor( + @InjectRepository(WagonType) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts new file mode 100644 index 000000000..4e15937e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -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 { + const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${code} not found`); + } + + return wagonType; + } +} diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts new file mode 100644 index 000000000..356c0b6cb --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -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'); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 884fde00b..775e17060 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , }, + { + label: "Train scheduling", + href: "/dashboard/operations/train-scheduling", + icon: , + }, ...demoItems, ], }, @@ -199,6 +206,7 @@ const App = () => { path="booking-requests/:id/contract" element={} /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 02fe21ffc..aa1b446d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 174416857..e77768e63 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index a8f2536b9..85dff0cc2 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx index a9d12eb6a..12c9a47ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx @@ -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({}); + const [selectedBookingIds, setSelectedBookingIds] = useState([]); + const [preview, setPreview] = useState(null); + const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(''); + const [detailId, setDetailId] = useState(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 ( - +
+ + +
+
+
+
+ +
+
+

Train Scheduling

+

+ Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives. +

+
+
+ +
+ +
+
+
+
+ +

+ Filters +

+
+
+
+ + +
+
+ + +
+
+ + + setFilters((current) => ({ + ...current, + scheduleDate: event.target.value || undefined, + })) + } + /> +
+
+ + + setFilters((current) => ({ + ...current, + status: event.target.value || undefined, + })) + } + /> +
+
+
+ +
+
+
+

Eligible container bookings

+

+ Only container bookings not already assigned to a schedule appear here. +

+
+ + {eligibleQuery.data?.count ?? 0} bookings + +
+ +
+ + + + + + + + + + + + + + + + + {eligibleItems.map((booking) => ( + + + + + + + + + + + + + ))} + {!eligibleQuery.isLoading && eligibleItems.length === 0 ? ( + + + + ) : null} + +
SelectBookingCustomerContainerQtyWeightOriginDestinationDepartureStatus
+ toggleBooking(booking, event.target.checked)} + /> + {booking.reference}{booking.customer}{booking.containerType}{booking.quantity}{booking.weightTons.toLocaleString()} T{booking.origin}{booking.destination}{formatDate(booking.preferredDepartureDate)}{booking.status}
+ No eligible container bookings matched the current filters. +
+
+
+
+ +
+
+

Schedule builder

+
+
+

Selected bookings

+

{summary.count}

+
+
+

Total weight

+

{summary.totalWeightTons.toLocaleString()} T

+
+
+

Route

+

{summary.route}

+
+
+

Schedule date

+

{summary.scheduleDate}

+
+
+

Estimated wagon type

+

NW5

+
+
+

Estimated wagons / length

+

+ {summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m +

+
+
+ +
+ + +
+ + +
+ + +
+ + {preview ? ( +
+
+

Preview result

+ + {preview.valid ? 'Valid' : 'Invalid'} + +
+ +
+
+

Wagons

+

{preview.summary.wagonsNeeded}

+
+
+

Weight

+

{preview.summary.totalWeightTons} T

+
+
+

Length

+

{preview.summary.totalLengthMeters} m

+
+
+ + {preview.violations.length > 0 ? ( +
+
    + {preview.violations.map((violation) => ( +
  • {violation}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+ +
+
+
+

Created schedules

+

Open a schedule to inspect wagons and allocations.

+
+ + {filteredSchedules.length} schedules + +
+ +
+ setScheduleSearch(event.target.value)} + /> + +
+ +
+ + + + + + + + + + + + + + + + + {filteredSchedules.map((schedule) => ( + + + + + + + + + + + + + ))} + {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( + + + + ) : null} + +
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} + {schedule.origin} to {schedule.destination} + {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} +
+ + {schedule.status !== 'CANCELLED' ? ( + + ) : null} +
+
+ No train schedules matched the current filters. +
+
+
+
+
+
+ + (!open ? setDetailId(null) : null)}> + + + Train schedule detail + + Inspect the selected schedule, locomotive, wagons, and booking allocations. + + + + {detail ? ( +
+
+
+

Schedule

+

{detail.id}

+
+
+

Departure

+

{formatDate(detail.scheduledDepartureDate)}

+
+
+

Route

+

+ {detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '} + {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'} +

+
+
+

Status

+

{detail.status}

+
+
+ +
+

Locomotive

+

+ {detail.trainSet?.locomotive + ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)` + : 'No locomotive attached'} +

+
+ +
+

Wagons and allocations

+
+ {(detail.trainSet?.wagons ?? []).map((wagon) => ( +
+
+
+

+ Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} +

+

+ {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m +

+
+
+
+ + + + + + + + + {wagon.allocations.map((allocation) => ( + + + + + ))} + +
BookingAllocated weight
{allocation.bookingReference ?? allocation.bookingId}{allocation.allocatedWeightTons} T
+
+
+ ))} +
+
+ +
+

Bookings in schedule

+
+ + + + + + + + + + + {detail.bookings.map((booking) => ( + + + + + + + ))} + +
ReferenceCustomerWeightStatus
{booking.reference ?? booking.id}{booking.customer ?? '-'}{booking.weightTons} T{booking.status ?? '-'}
+
+
+
+ ) : ( +

Loading schedule detail...

+ )} +
+
+
); }; diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts new file mode 100644 index 000000000..e696b7e8b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -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 => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS, + { params: filters }, + ); + return unwrap(response.data); + }, + + preview: async ( + payload: TrainSchedulePreviewPayload, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW, + payload, + ); + return unwrap(response.data); + }, + + createSchedule: async ( + payload: CreateTrainSchedulePayload, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES, + payload, + ); + return unwrap(response.data); + }, + + listSchedules: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES, + ); + return unwrap(response.data); + }, + + getScheduleById: async (id: string): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id), + ); + return unwrap(response.data); + }, + + cancelSchedule: async (id: string): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id), + {}, + ); + return unwrap(response.data); + }, + + getAvailableLocomotives: async (): Promise => { + const response = await client.get(URL_CONSTANTS.LOCOMOTIVES.BASE, { + params: { status: 'AVAILABLE' }, + }); + return unwrap(response.data); + }, + + getStations: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.BOOKINGS.REFERENCE_DATA, + ); + const data = unwrap(response.data); + return data.yard ?? []; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts new file mode 100644 index 000000000..171500a93 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -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; +} diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts index 31748c77d..4a4af69d6 100644 --- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts +++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts @@ -50,9 +50,6 @@ export function endpoint( 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(response: { data: T } | T): T { } return response as T; -} \ No newline at end of file +}