mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
Merge pull request #94 from Tria-plc/freight/feature/add_train_scheduling
Freight/feature/add train scheduling reviewed
This commit is contained in:
@@ -13,6 +13,11 @@ import { BookingsModule } from "./modules/bookings/bookings.module";
|
|||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
import { TrainsModule } from "./modules/trains/trains.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 { CustomersModule } from "./modules/customers/customers.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
@@ -30,6 +35,7 @@ import {
|
|||||||
} from "./seed/edr-freight.seed";
|
} from "./seed/edr-freight.seed";
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
|
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -60,6 +66,11 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
TrainsModule,
|
TrainsModule,
|
||||||
|
LocomotivesModule,
|
||||||
|
WagonTypesModule,
|
||||||
|
TrainSetsModule,
|
||||||
|
TrainSchedulesModule,
|
||||||
|
TrainSchedulingModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
@@ -72,13 +83,14 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
|
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -86,6 +98,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
await this.demoUsersSeeder.run();
|
||||||
|
await this.demoBookingsSeeder.run();
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddTrainScheduling1749400000000 implements MigrationInterface {
|
||||||
|
name = 'AddTrainScheduling1749400000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_types (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||||
|
length_meters NUMERIC(10,3) NOT NULL,
|
||||||
|
max_wagons_per_train INT NULL,
|
||||||
|
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.locomotives (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NULL,
|
||||||
|
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
|
||||||
|
available_from TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_sets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
locomotive_id UUID NOT NULL,
|
||||||
|
total_weight_tons NUMERIC(10,3) NOT NULL,
|
||||||
|
total_length_meters NUMERIC(10,3) NOT NULL,
|
||||||
|
wagon_count INT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
|
||||||
|
REFERENCES freight.locomotives(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_set_id UUID NOT NULL,
|
||||||
|
wagon_type_id UUID NOT NULL,
|
||||||
|
sequence_no INT NOT NULL,
|
||||||
|
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||||
|
length_meters NUMERIC(10,3) NOT NULL,
|
||||||
|
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
|
||||||
|
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
|
||||||
|
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
|
||||||
|
REFERENCES freight.wagon_types(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_set_id UUID NOT NULL UNIQUE,
|
||||||
|
origin_station_id UUID NOT NULL,
|
||||||
|
destination_station_id UUID NOT NULL,
|
||||||
|
scheduled_departure_date TIMESTAMPTZ NOT NULL,
|
||||||
|
scheduled_arrival_date TIMESTAMPTZ NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
|
||||||
|
REFERENCES freight.train_sets(id),
|
||||||
|
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
|
||||||
|
REFERENCES freight.yards(id),
|
||||||
|
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
|
||||||
|
REFERENCES freight.yards(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_schedule_id UUID NOT NULL,
|
||||||
|
booking_id UUID NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
|
||||||
|
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
|
||||||
|
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
|
||||||
|
REFERENCES freight.bookings(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_set_wagon_id UUID NOT NULL,
|
||||||
|
booking_id UUID NOT NULL,
|
||||||
|
allocated_weight_tons NUMERIC(10,3) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
|
||||||
|
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
|
||||||
|
REFERENCES freight.bookings(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_locomotives_status
|
||||||
|
ON freight.locomotives(status);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_sets_status
|
||||||
|
ON freight.train_sets(status);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
|
||||||
|
ON freight.train_schedules(scheduled_departure_date, status);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
|
||||||
|
ON freight.wagon_booking_allocations(booking_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
|||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService],
|
exports: [BookingsService, BookingsRepository],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Locomotive } from './entities/locomotive.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Locomotive)
|
||||||
|
repository: Repository<Locomotive>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
|
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
|
||||||
|
import { LocomotivesRepository } from './locomotives.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocomotivesService {
|
||||||
|
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
|
||||||
|
|
||||||
|
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||||
|
return this.locomotivesRepository.findAll({
|
||||||
|
where: filter.status
|
||||||
|
? { status: filter.status as LocomotiveStatus }
|
||||||
|
: undefined,
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Locomotive> {
|
||||||
|
const locomotive = await this.locomotivesRepository.findById(id);
|
||||||
|
|
||||||
|
if (!locomotive) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return locomotive;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainScheduleBookingsRepository extends BaseRepository<TrainScheduleBooking> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainScheduleBooking)
|
||||||
|
repository: Repository<TrainScheduleBooking>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrainSchedule } from './entities/train-schedule.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainSchedule)
|
||||||
|
repository: Repository<TrainSchedule>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WagonBookingAllocationsRepository extends BaseRepository<WagonBookingAllocation> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WagonBookingAllocation)
|
||||||
|
repository: Repository<WagonBookingAllocation>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
|
const nw5 = {
|
||||||
|
id: 'wagon-type-1',
|
||||||
|
code: 'NW5',
|
||||||
|
name: 'Flat Wagon',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const locomotive = {
|
||||||
|
id: 'loc-1',
|
||||||
|
code: 'LOC-001',
|
||||||
|
maxPullWeightTons: 3500,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeBooking = (
|
||||||
|
id: string,
|
||||||
|
reference: string,
|
||||||
|
weight: number,
|
||||||
|
quantity: number,
|
||||||
|
containerCode: string,
|
||||||
|
scheduledDate = '2026-06-20T08:00:00.000Z',
|
||||||
|
originYardId = 'yard-origin',
|
||||||
|
destinationYardId = 'yard-destination',
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
reference,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTotalWeightVgm: weight,
|
||||||
|
scheduledDate: new Date(scheduledDate),
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
status: 'APPROVED',
|
||||||
|
customer: { companyName: 'Demo Customer' },
|
||||||
|
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
||||||
|
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
||||||
|
bookingContainers: [
|
||||||
|
{
|
||||||
|
quantity,
|
||||||
|
containerType: { code: containerCode, label: containerCode },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TrainSchedulingService', () => {
|
||||||
|
let service: TrainSchedulingService;
|
||||||
|
let dataSource: {
|
||||||
|
getRepository: jest.Mock;
|
||||||
|
transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
let locomotivesRepository: {
|
||||||
|
findById: jest.Mock;
|
||||||
|
};
|
||||||
|
let wagonTypesRepository: {
|
||||||
|
findAll: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataSource = {
|
||||||
|
getRepository: jest.fn(),
|
||||||
|
transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
locomotivesRepository = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
wagonTypesRepository = {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new TrainSchedulingService(
|
||||||
|
dataSource as never,
|
||||||
|
locomotivesRepository as never,
|
||||||
|
wagonTypesRepository as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes the expected valid preview for Group A', async () => {
|
||||||
|
const bookings = [
|
||||||
|
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
|
||||||
|
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
|
||||||
|
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
|
||||||
|
];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
|
if (entity?.name === 'Booking') {
|
||||||
|
return { find: jest.fn().mockResolvedValue(bookings) };
|
||||||
|
}
|
||||||
|
if (entity?.name === 'TrainScheduleBooking') {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
if (entity?.name === 'Locomotive') {
|
||||||
|
return {
|
||||||
|
count: jest.fn().mockResolvedValue(2),
|
||||||
|
find: jest.fn().mockResolvedValue([locomotive]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: bookings.map((booking) => booking.id),
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
expect(result.summary).toEqual({
|
||||||
|
totalBookings: 3,
|
||||||
|
totalWeightTons: 1250,
|
||||||
|
wagonType: 'NW5',
|
||||||
|
wagonsNeeded: 18,
|
||||||
|
totalLengthMeters: 252,
|
||||||
|
});
|
||||||
|
expect(result.wagonPlan).toHaveLength(18);
|
||||||
|
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
|
||||||
|
bookingId: 'b1',
|
||||||
|
bookingReference: 'BKG-CONT-001',
|
||||||
|
allocatedWeightTons: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags the overweight booking as invalid', async () => {
|
||||||
|
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
|
if (entity?.name === 'Booking') {
|
||||||
|
return { find: jest.fn().mockResolvedValue(bookings) };
|
||||||
|
}
|
||||||
|
if (entity?.name === 'TrainScheduleBooking') {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
if (entity?.name === 'Locomotive') {
|
||||||
|
return {
|
||||||
|
count: jest.fn().mockResolvedValue(1),
|
||||||
|
find: jest.fn().mockResolvedValue([locomotive]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: ['b6'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.summary.totalWeightTons).toBe(3600);
|
||||||
|
expect(result.violations).toContain(
|
||||||
|
'Total booking weight 3600T exceeds max train weight 3500T',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a schedule transactionally when validation passes', async () => {
|
||||||
|
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
|
||||||
|
const validation = {
|
||||||
|
valid: true,
|
||||||
|
violations: [],
|
||||||
|
bookings,
|
||||||
|
wagonType: nw5,
|
||||||
|
summary: {
|
||||||
|
totalBookings: 1,
|
||||||
|
totalWeightTons: 140,
|
||||||
|
wagonType: 'NW5',
|
||||||
|
wagonsNeeded: 2,
|
||||||
|
totalLengthMeters: 28,
|
||||||
|
},
|
||||||
|
wagonPlan: [
|
||||||
|
{
|
||||||
|
sequenceNo: 1,
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
assignedWeightTons: 70,
|
||||||
|
allocations: [
|
||||||
|
{
|
||||||
|
bookingId: 'b1',
|
||||||
|
bookingReference: 'BKG-CONT-001',
|
||||||
|
allocatedWeightTons: 70,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequenceNo: 2,
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
assignedWeightTons: 70,
|
||||||
|
allocations: [
|
||||||
|
{
|
||||||
|
bookingId: 'b1',
|
||||||
|
bookingReference: 'BKG-CONT-001',
|
||||||
|
allocatedWeightTons: 70,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const lockedLocomotiveRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(locomotive),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const trainScheduleRepo = {
|
||||||
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||||||
|
};
|
||||||
|
const trainScheduleBookingRepo = {
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const trainSetWagonRepo = {
|
||||||
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'wagon-1', sequenceNo: 1 },
|
||||||
|
{ id: 'wagon-2', sequenceNo: 2 },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const wagonAllocRepo = {
|
||||||
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const trainSetRepo = {
|
||||||
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity: { name?: string }) => {
|
||||||
|
switch (entity?.name) {
|
||||||
|
case 'Locomotive':
|
||||||
|
return lockedLocomotiveRepo;
|
||||||
|
case 'TrainSchedule':
|
||||||
|
return trainScheduleRepo;
|
||||||
|
case 'TrainScheduleBooking':
|
||||||
|
return trainScheduleBookingRepo;
|
||||||
|
case 'TrainSetWagon':
|
||||||
|
return trainSetWagonRepo;
|
||||||
|
case 'WagonBookingAllocation':
|
||||||
|
return wagonAllocRepo;
|
||||||
|
case 'TrainSet':
|
||||||
|
return trainSetRepo;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected transaction repository ${entity?.name}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
|
||||||
|
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||||
|
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
|
||||||
|
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||||
|
callback(manager),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.createContainerTrainSchedule({
|
||||||
|
bookingIds: ['b1'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
locomotiveId: 'loc-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||||
|
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||||
|
expect(trainSetWagonRepo.save).toHaveBeenCalled();
|
||||||
|
expect(wagonAllocRepo.save).toHaveBeenCalled();
|
||||||
|
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
||||||
|
expect(result).toEqual({ id: 'schedule-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||||
|
const validation = {
|
||||||
|
valid: true,
|
||||||
|
violations: [],
|
||||||
|
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
|
||||||
|
wagonType: nw5,
|
||||||
|
summary: {
|
||||||
|
totalBookings: 1,
|
||||||
|
totalWeightTons: 70,
|
||||||
|
wagonType: 'NW5',
|
||||||
|
wagonsNeeded: 1,
|
||||||
|
totalLengthMeters: 14,
|
||||||
|
},
|
||||||
|
wagonPlan: [
|
||||||
|
{
|
||||||
|
sequenceNo: 1,
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
assignedWeightTons: 70,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn(() => ({
|
||||||
|
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
|
||||||
|
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||||
|
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||||
|
callback(manager),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createContainerTrainSchedule({
|
||||||
|
bookingIds: ['b1'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
locomotiveId: 'loc-1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity';
|
||||||
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||||
|
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||||
|
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||||
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
|
|
||||||
|
const DEFAULT_WAGON_TYPE_CODE = 'NW5';
|
||||||
|
const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||||
|
const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
|
|
||||||
|
type EligibleBookingItem = {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
customer: string;
|
||||||
|
containerType: string;
|
||||||
|
quantity: number;
|
||||||
|
weightTons: number;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
preferredDepartureDate: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WagonAllocationRecord = {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
allocatedWeightTons: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WagonPlanRecord = {
|
||||||
|
sequenceNo: number;
|
||||||
|
capacityTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
assignedWeightTons: number;
|
||||||
|
allocations: WagonAllocationRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidationResult = {
|
||||||
|
valid: boolean;
|
||||||
|
violations: string[];
|
||||||
|
bookings: Booking[];
|
||||||
|
wagonType: WagonType;
|
||||||
|
summary: {
|
||||||
|
totalBookings: number;
|
||||||
|
totalWeightTons: number;
|
||||||
|
wagonType: string;
|
||||||
|
wagonsNeeded: number;
|
||||||
|
totalLengthMeters: number;
|
||||||
|
};
|
||||||
|
wagonPlan: WagonPlanRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainSchedulingService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource()
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly locomotivesRepository: LocomotivesRepository,
|
||||||
|
private readonly wagonTypesRepository: WagonTypesRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
||||||
|
const bookingRepository = this.dataSource.getRepository(Booking);
|
||||||
|
const queryBuilder = bookingRepository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.customer', 'customer')
|
||||||
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
|
||||||
|
.where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
|
||||||
|
.andWhere('scheduleBooking.id IS NULL');
|
||||||
|
|
||||||
|
if (query.originStationId) {
|
||||||
|
queryBuilder.andWhere('booking.originYardId = :originStationId', {
|
||||||
|
originStationId: query.originStationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.destinationStationId) {
|
||||||
|
queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||||
|
destinationStationId: query.destinationStationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.scheduleDate) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
|
||||||
|
{ scheduleDate: this.toUtcDateKey(query.scheduleDate) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
queryBuilder.andWhere('booking.status = :status', { status: query.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings = await queryBuilder
|
||||||
|
.orderBy('booking.scheduled_date', 'ASC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const items: EligibleBookingItem[] = bookings.map((booking) => ({
|
||||||
|
id: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer',
|
||||||
|
containerType: booking.bookingContainers
|
||||||
|
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
|
||||||
|
.join(', ') ?? 'Container',
|
||||||
|
quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
|
||||||
|
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
|
||||||
|
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||||
|
destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||||
|
preferredDepartureDate: booking.scheduledDate.toISOString(),
|
||||||
|
status: booking.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: items.length,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
|
||||||
|
const validation = await this.validateContainerBookingsForScheduling(dto);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: validation.valid,
|
||||||
|
violations: validation.violations,
|
||||||
|
summary: validation.summary,
|
||||||
|
bookingIds: validation.bookings.map((booking) => booking.id),
|
||||||
|
wagonPlan: validation.wagonPlan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||||
|
const validation = await this.validateContainerBookingsForScheduling(dto);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'train_schedule_invalid',
|
||||||
|
violations: validation.violations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const locomotive = await this.selectOrValidateLocomotive(
|
||||||
|
dto.locomotiveId,
|
||||||
|
validation.summary.totalWeightTons,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdSchedule = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const locomotiveRepository = manager.getRepository(Locomotive);
|
||||||
|
const lockedLocomotive = await locomotiveRepository.findOne({
|
||||||
|
where: { id: locomotive.id },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lockedLocomotive) {
|
||||||
|
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lockedLocomotive.status !== 'AVAILABLE') {
|
||||||
|
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({
|
||||||
|
where: { bookingId: In(validation.bookings.map((booking) => booking.id)) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingScheduleCount > 0) {
|
||||||
|
throw new BadRequestException('One or more bookings are already scheduled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trainSet = await this.buildTrainSet(
|
||||||
|
manager,
|
||||||
|
lockedLocomotive,
|
||||||
|
validation.wagonType,
|
||||||
|
validation.summary.totalWeightTons,
|
||||||
|
validation.summary.totalLengthMeters,
|
||||||
|
validation.wagonPlan,
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule = manager.getRepository(TrainSchedule).create({
|
||||||
|
trainSetId: trainSet.id,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule);
|
||||||
|
|
||||||
|
const scheduleBookings = validation.bookings.map((booking) =>
|
||||||
|
manager.getRepository(TrainScheduleBooking).create({
|
||||||
|
trainScheduleId: savedSchedule.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.getRepository(TrainScheduleBooking).save(scheduleBookings);
|
||||||
|
|
||||||
|
const savedWagons = await manager.getRepository(TrainSetWagon).find({
|
||||||
|
where: { trainSetId: trainSet.id },
|
||||||
|
order: { sequenceNo: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon]));
|
||||||
|
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
|
||||||
|
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
|
||||||
|
|
||||||
|
if (!wagon) {
|
||||||
|
throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wagonPlan.allocations.map((allocation) =>
|
||||||
|
manager.getRepository(WagonBookingAllocation).create({
|
||||||
|
trainSetWagonId: wagon.id,
|
||||||
|
bookingId: allocation.bookingId,
|
||||||
|
allocatedWeightTons: allocation.allocatedWeightTons,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(WagonBookingAllocation).save(allocationRows);
|
||||||
|
|
||||||
|
await locomotiveRepository.update(lockedLocomotive.id, {
|
||||||
|
status: 'ASSIGNED',
|
||||||
|
});
|
||||||
|
|
||||||
|
return savedSchedule.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getContainerTrainScheduleById(createdSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateContainerBookingsForScheduling(
|
||||||
|
dto: PreviewContainerTrainScheduleDto,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const bookingIds = [...new Set(dto.bookingIds)];
|
||||||
|
|
||||||
|
if (!bookingIds.length) {
|
||||||
|
throw new BadRequestException('At least one booking is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [wagonType] = await this.wagonTypesRepository.findAll({
|
||||||
|
where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wagonType) {
|
||||||
|
throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings = await this.loadBookingsForScheduling(bookingIds);
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
|
if (bookings.length !== bookingIds.length) {
|
||||||
|
const foundIds = new Set(bookings.map((booking) => booking.id));
|
||||||
|
const missing = bookingIds.filter((id) => !foundIds.has(id));
|
||||||
|
violations.push(`Bookings not found: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||||
|
where: { bookingId: In(bookingIds) },
|
||||||
|
select: { bookingId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scheduledLinks.length > 0) {
|
||||||
|
violations.push('One or more selected bookings are already assigned to a train schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER');
|
||||||
|
if (nonContainerBookings.length > 0) {
|
||||||
|
violations.push('Only CONTAINER bookings are supported for train scheduling');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
|
||||||
|
const routeMismatch = bookings.some(
|
||||||
|
(booking) =>
|
||||||
|
booking.originYardId !== dto.originStationId ||
|
||||||
|
booking.destinationYardId !== dto.destinationStationId,
|
||||||
|
);
|
||||||
|
if (routeMismatch) {
|
||||||
|
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMismatch = bookings.some(
|
||||||
|
(booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
|
||||||
|
);
|
||||||
|
if (dateMismatch) {
|
||||||
|
violations.push('Selected bookings must share the same schedule date');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size;
|
||||||
|
if (uniqueOriginCount > 1) {
|
||||||
|
violations.push('Selected bookings must share the same origin station');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size;
|
||||||
|
if (uniqueDestinationCount > 1) {
|
||||||
|
violations.push('Selected bookings must share the same destination station');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueDateCount = new Set(
|
||||||
|
bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
|
||||||
|
).size;
|
||||||
|
if (uniqueDateCount > 1) {
|
||||||
|
violations.push('Selected bookings must share the same preferred departure date');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWeightTons = this.roundTons(
|
||||||
|
bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType));
|
||||||
|
const totalLengthMeters = this.roundTons(
|
||||||
|
wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
|
||||||
|
violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
|
||||||
|
violations.push(
|
||||||
|
`Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
wagonType.maxWagonsPerTrain != null &&
|
||||||
|
wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
|
||||||
|
) {
|
||||||
|
violations.push(
|
||||||
|
`Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({
|
||||||
|
where: { status: 'AVAILABLE' as LocomotiveStatus },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (availableLocomotiveCount === 0) {
|
||||||
|
violations.push('No available locomotive exists for scheduling');
|
||||||
|
} else {
|
||||||
|
const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({
|
||||||
|
where: { status: 'AVAILABLE' },
|
||||||
|
});
|
||||||
|
const canPull = capableLocomotives.some(
|
||||||
|
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
||||||
|
);
|
||||||
|
if (!canPull) {
|
||||||
|
violations.push('No available locomotive can pull the total weight');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: violations.length === 0,
|
||||||
|
violations,
|
||||||
|
bookings,
|
||||||
|
wagonType,
|
||||||
|
summary: {
|
||||||
|
totalBookings: bookings.length,
|
||||||
|
totalWeightTons,
|
||||||
|
wagonType: wagonType.code,
|
||||||
|
wagonsNeeded: wagonPlan.length,
|
||||||
|
totalLengthMeters,
|
||||||
|
},
|
||||||
|
wagonPlan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] {
|
||||||
|
const wagonCapacityTons = Number(wagonType.capacityTons);
|
||||||
|
const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
|
||||||
|
let remainingWeight = this.roundTons(totalBookingWeightTons);
|
||||||
|
|
||||||
|
return Array.from({ length: wagonsNeeded }, (_, index) => {
|
||||||
|
const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight));
|
||||||
|
remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sequenceNo: index + 1,
|
||||||
|
capacityTons: wagonCapacityTons,
|
||||||
|
lengthMeters: this.roundTons(Number(wagonType.lengthMeters)),
|
||||||
|
assignedWeightTons,
|
||||||
|
allocations: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) {
|
||||||
|
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
||||||
|
|
||||||
|
if (!locomotive) {
|
||||||
|
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locomotive.status !== 'AVAILABLE') {
|
||||||
|
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return locomotive;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildTrainSet(
|
||||||
|
manager: EntityManager,
|
||||||
|
locomotive: Locomotive,
|
||||||
|
wagonType: WagonType,
|
||||||
|
totalWeightTons: number,
|
||||||
|
totalLengthMeters: number,
|
||||||
|
wagonPlan: WagonPlanRecord[],
|
||||||
|
) {
|
||||||
|
const trainSet = manager.getRepository(TrainSet).create({
|
||||||
|
locomotiveId: locomotive.id,
|
||||||
|
totalWeightTons,
|
||||||
|
totalLengthMeters,
|
||||||
|
wagonCount: wagonPlan.length,
|
||||||
|
status: 'ASSIGNED',
|
||||||
|
});
|
||||||
|
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
|
||||||
|
|
||||||
|
const wagons = wagonPlan.map((wagon) =>
|
||||||
|
manager.getRepository(TrainSetWagon).create({
|
||||||
|
trainSetId: savedTrainSet.id,
|
||||||
|
wagonTypeId: wagonType.id,
|
||||||
|
sequenceNo: wagon.sequenceNo,
|
||||||
|
capacityTons: wagon.capacityTons,
|
||||||
|
lengthMeters: wagon.lengthMeters,
|
||||||
|
assignedWeightTons: wagon.assignedWeightTons,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(TrainSetWagon).save(wagons);
|
||||||
|
|
||||||
|
return savedTrainSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] {
|
||||||
|
const remaining = bookings.map((booking) => ({
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
|
||||||
|
}));
|
||||||
|
let bookingIndex = 0;
|
||||||
|
|
||||||
|
return baseWagonPlan.map((wagon) => {
|
||||||
|
let wagonRemaining = this.roundTons(wagon.capacityTons);
|
||||||
|
const allocations: WagonAllocationRecord[] = [];
|
||||||
|
let assignedWeightTons = 0;
|
||||||
|
|
||||||
|
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
||||||
|
const booking = remaining[bookingIndex];
|
||||||
|
const allocatedWeightTons = this.roundTons(
|
||||||
|
Math.min(wagonRemaining, booking.remainingWeightTons),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allocatedWeightTons <= 0) {
|
||||||
|
bookingIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
bookingId: booking.bookingId,
|
||||||
|
bookingReference: booking.bookingReference,
|
||||||
|
allocatedWeightTons,
|
||||||
|
});
|
||||||
|
booking.remainingWeightTons = this.roundTons(
|
||||||
|
booking.remainingWeightTons - allocatedWeightTons,
|
||||||
|
);
|
||||||
|
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
|
||||||
|
assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons);
|
||||||
|
|
||||||
|
if (booking.remainingWeightTons <= 0) {
|
||||||
|
bookingIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...wagon,
|
||||||
|
assignedWeightTons,
|
||||||
|
allocations,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContainerTrainSchedules() {
|
||||||
|
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||||
|
relations: {
|
||||||
|
trainSet: { locomotive: true },
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
scheduleBookings: true,
|
||||||
|
},
|
||||||
|
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return schedules.map((schedule) => ({
|
||||||
|
id: schedule.id,
|
||||||
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
|
destination:
|
||||||
|
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||||
|
locomotive: schedule.trainSet?.locomotive
|
||||||
|
? {
|
||||||
|
id: schedule.trainSet.locomotive.id,
|
||||||
|
code: schedule.trainSet.locomotive.code,
|
||||||
|
name: schedule.trainSet.locomotive.name ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||||
|
totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||||
|
totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||||
|
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||||||
|
status: schedule.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContainerTrainScheduleById(id: string) {
|
||||||
|
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: {
|
||||||
|
trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } },
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: schedule.id,
|
||||||
|
status: schedule.status,
|
||||||
|
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||||
|
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||||
|
originStation: schedule.originStation,
|
||||||
|
destinationStation: schedule.destinationStation,
|
||||||
|
trainSet: schedule.trainSet
|
||||||
|
? {
|
||||||
|
id: schedule.trainSet.id,
|
||||||
|
status: schedule.trainSet.status,
|
||||||
|
wagonCount: schedule.trainSet.wagonCount,
|
||||||
|
totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)),
|
||||||
|
totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)),
|
||||||
|
locomotive: schedule.trainSet.locomotive
|
||||||
|
? {
|
||||||
|
id: schedule.trainSet.locomotive.id,
|
||||||
|
code: schedule.trainSet.locomotive.code,
|
||||||
|
name: schedule.trainSet.locomotive.name,
|
||||||
|
status: schedule.trainSet.locomotive.status,
|
||||||
|
maxPullWeightTons: this.roundTons(
|
||||||
|
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
wagons:
|
||||||
|
[...(schedule.trainSet.wagons ?? [])]
|
||||||
|
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||||
|
.map((wagon) => ({
|
||||||
|
id: wagon.id,
|
||||||
|
sequenceNo: wagon.sequenceNo,
|
||||||
|
capacityTons: this.roundTons(Number(wagon.capacityTons)),
|
||||||
|
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
|
||||||
|
assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)),
|
||||||
|
wagonType: wagon.wagonType
|
||||||
|
? {
|
||||||
|
id: wagon.wagonType.id,
|
||||||
|
code: wagon.wagonType.code,
|
||||||
|
name: wagon.wagonType.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
allocations:
|
||||||
|
wagon.allocations?.map((allocation) => ({
|
||||||
|
id: allocation.id,
|
||||||
|
bookingId: allocation.bookingId,
|
||||||
|
bookingReference: allocation.booking?.reference ?? null,
|
||||||
|
allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)),
|
||||||
|
})) ?? [],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
bookings:
|
||||||
|
schedule.scheduleBookings?.map((scheduleBooking) => ({
|
||||||
|
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
|
||||||
|
reference: scheduleBooking.booking?.reference ?? null,
|
||||||
|
customer:
|
||||||
|
scheduleBooking.booking?.customer?.companyName ??
|
||||||
|
scheduleBooking.booking?.customer?.email ??
|
||||||
|
null,
|
||||||
|
weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)),
|
||||||
|
status: scheduleBooking.booking?.status ?? null,
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelTrainSchedule(id: string) {
|
||||||
|
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { trainSet: { locomotive: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(TrainSchedule).update(schedule.id, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (schedule.trainSetId) {
|
||||||
|
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedule.trainSet?.locomotiveId) {
|
||||||
|
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getContainerTrainScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBookingsForScheduling(bookingIds: string[]) {
|
||||||
|
return this.dataSource.getRepository(Booking).find({
|
||||||
|
where: { id: In(bookingIds) },
|
||||||
|
relations: {
|
||||||
|
customer: true,
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
bookingContainers: { containerType: true },
|
||||||
|
},
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUtcDateKey(value: Date | string) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private roundTons(value: number | string | null | undefined) {
|
||||||
|
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericValue)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(numericValue.toFixed(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrainSetWagon } from './entities/train-set-wagon.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainSetWagonsRepository extends BaseRepository<TrainSetWagon> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainSetWagon)
|
||||||
|
repository: Repository<TrainSetWagon>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrainSet } from './entities/train-set.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainSetsRepository extends BaseRepository<TrainSet> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainSet)
|
||||||
|
repository: Repository<TrainSet>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WagonType } from './entities/wagon-type.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WagonTypesRepository extends BaseRepository<WagonType> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WagonType)
|
||||||
|
repository: Repository<WagonType>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WagonType } from './entities/wagon-type.entity';
|
||||||
|
import { WagonTypesRepository } from './wagon-types.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WagonTypesService {
|
||||||
|
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<WagonType> {
|
||||||
|
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
|
||||||
|
|
||||||
|
if (!wagonType) {
|
||||||
|
throw new NotFoundException(`Wagon type ${code} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wagonType;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
Normal file
274
apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
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-007',
|
||||||
|
containerCode: '20FT',
|
||||||
|
quantity: 6,
|
||||||
|
totalWeightTons: 180,
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
TrainTrack,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
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 FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
@@ -46,6 +48,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Train scheduling",
|
||||||
|
href: "/dashboard/operations/train-scheduling",
|
||||||
|
icon: <TrainTrack />,
|
||||||
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -199,6 +206,7 @@ const App = () => {
|
|||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
|
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
||||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
export const QUERY_KEYS = {
|
export const QUERY_KEYS = {
|
||||||
@@ -35,6 +36,16 @@ export const QUERY_KEYS = {
|
|||||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
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: {
|
RULE_ENGINE: {
|
||||||
ROOT: ["rule-engine"] as const,
|
ROOT: ["rule-engine"] as const,
|
||||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
BASE: "/bookings",
|
BASE: "/bookings",
|
||||||
|
REFERENCE_DATA: "/bookings/reference-data",
|
||||||
BY_ID: (id: string) => `/bookings/${id}`,
|
BY_ID: (id: string) => `/bookings/${id}`,
|
||||||
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
||||||
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
|
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
|
||||||
@@ -111,6 +112,19 @@ export const URL_CONSTANTS = {
|
|||||||
VERIFY: "/api/otp/verify",
|
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: {
|
RULE_ENGINE: {
|
||||||
CARGO_TYPES: "/cargo-types",
|
CARGO_TYPES: "/cargo-types",
|
||||||
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,
|
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ export const useContainerTypeOptions = (
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
) =>
|
) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: api.ruleEngine.list.queryKey(),
|
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
|
||||||
|
page: 1,
|
||||||
|
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
|
||||||
|
includeNone,
|
||||||
|
}),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.ruleEngine.list.call({
|
api.ruleEngine.list.call({
|
||||||
resource: "container-types",
|
resource: "container-types",
|
||||||
|
|||||||
@@ -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 TrainsPage = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [filters, setFilters] = useState<TrainScheduleFilters>({});
|
||||||
|
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||||
|
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||||
|
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const [scheduleSearch, setScheduleSearch] = useState('');
|
||||||
|
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
|
||||||
|
|
||||||
|
const stationsQuery = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
|
||||||
|
queryFn: () => trainSchedulingService.getStations(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligibleQuery = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters),
|
||||||
|
queryFn: () => trainSchedulingService.getEligibleBookings(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
const locomotivesQuery = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
|
||||||
|
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schedulesQuery = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
|
||||||
|
queryFn: () => trainSchedulingService.listSchedules(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
|
||||||
|
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
|
||||||
|
enabled: Boolean(detailId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligibleItems = eligibleQuery.data?.items ?? [];
|
||||||
|
const filteredSchedules = useMemo(() => {
|
||||||
|
const query = scheduleSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (schedulesQuery.data ?? []).filter((schedule) => {
|
||||||
|
const matchesStatus =
|
||||||
|
scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
|
||||||
|
|
||||||
|
if (!matchesStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
schedule.id,
|
||||||
|
schedule.origin ?? '',
|
||||||
|
schedule.destination ?? '',
|
||||||
|
schedule.locomotive?.code ?? '',
|
||||||
|
schedule.status,
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
|
||||||
|
const selectedBookings = useMemo(
|
||||||
|
() => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)),
|
||||||
|
[eligibleItems, selectedBookingIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0);
|
||||||
|
const wagonsNeeded = Math.ceil(totalWeightTons / 70);
|
||||||
|
const totalLengthMeters = wagonsNeeded * 14;
|
||||||
|
const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`));
|
||||||
|
const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: selectedBookings.length,
|
||||||
|
totalWeightTons,
|
||||||
|
wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0,
|
||||||
|
totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0,
|
||||||
|
route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-',
|
||||||
|
scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-',
|
||||||
|
};
|
||||||
|
}, [selectedBookings]);
|
||||||
|
|
||||||
|
const previewMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
|
||||||
|
throw new Error('Please select origin, destination, and schedule date');
|
||||||
|
}
|
||||||
|
return trainSchedulingService.preview({
|
||||||
|
bookingIds: selectedBookingIds,
|
||||||
|
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
|
||||||
|
originStationId: filters.originStationId,
|
||||||
|
destinationStationId: filters.destinationStationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPreview(data);
|
||||||
|
toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(parseError(error, 'Failed to preview train schedule'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!selectedLocomotiveId) {
|
||||||
|
throw new Error('Please select a locomotive');
|
||||||
|
}
|
||||||
|
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
|
||||||
|
throw new Error('Please select origin, destination, and schedule date');
|
||||||
|
}
|
||||||
|
return trainSchedulingService.createSchedule({
|
||||||
|
bookingIds: selectedBookingIds,
|
||||||
|
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
|
||||||
|
originStationId: filters.originStationId,
|
||||||
|
destinationStationId: filters.destinationStationId,
|
||||||
|
locomotiveId: selectedLocomotiveId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success('Train schedule created');
|
||||||
|
setSelectedBookingIds([]);
|
||||||
|
setSelectedLocomotiveId('');
|
||||||
|
setPreview(null);
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
|
||||||
|
setDetailId(data.id);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(parseError(error, 'Failed to create train schedule'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success('Train schedule cancelled');
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
|
||||||
|
setDetailId(data.id);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(parseError(error, 'Failed to cancel train schedule'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => {
|
||||||
|
setSelectedBookingIds((current) => {
|
||||||
|
if (checked) {
|
||||||
|
const next = [...new Set([...current, booking.id])];
|
||||||
|
if (next.length === 1) {
|
||||||
|
const defaults = deriveFromBooking(booking, stationsQuery.data ?? []);
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
originStationId: prev.originStationId || defaults.originStationId,
|
||||||
|
destinationStationId: prev.destinationStationId || defaults.destinationStationId,
|
||||||
|
scheduleDate: prev.scheduleDate || defaults.scheduleDate,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return current.filter((id) => id !== booking.id);
|
||||||
|
});
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
const isBusy = previewMutation.isPending || createMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeaturePlaceholder
|
<div className="space-y-6 p-6">
|
||||||
title="Trains"
|
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
|
||||||
description="Coordinate train assignments, scheduling visibility, and operational readiness."
|
|
||||||
/>
|
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
|
||||||
|
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
||||||
|
<TrainTrack className="size-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
void eligibleQuery.refetch();
|
||||||
|
void schedulesQuery.refetch();
|
||||||
|
void locomotivesQuery.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 p-6 xl:grid-cols-[1.8fr,1fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Calendar className="size-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Filters
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Origin station</label>
|
||||||
|
<Select
|
||||||
|
value={filters.originStationId ?? ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
originStationId: value === '__all__' ? undefined : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All origins" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All origins</SelectItem>
|
||||||
|
{(stationsQuery.data ?? []).map((station) => (
|
||||||
|
<SelectItem key={station.id} value={station.id}>
|
||||||
|
{station.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Destination station</label>
|
||||||
|
<Select
|
||||||
|
value={filters.destinationStationId ?? ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
destinationStationId: value === '__all__' ? undefined : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All destinations" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All destinations</SelectItem>
|
||||||
|
{(stationsQuery.data ?? []).map((station) => (
|
||||||
|
<SelectItem key={station.id} value={station.id}>
|
||||||
|
{station.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Schedule date</label>
|
||||||
|
<input
|
||||||
|
className={inputClassName}
|
||||||
|
type="date"
|
||||||
|
value={filters.scheduleDate ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
scheduleDate: event.target.value || undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Booking status</label>
|
||||||
|
<input
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="APPROVED"
|
||||||
|
value={filters.status ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
status: event.target.value || undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Only container bookings not already assigned to a schedule appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{eligibleQuery.data?.count ?? 0} bookings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||||
|
<table className="min-w-full divide-y divide-border text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-3">Select</th>
|
||||||
|
<th className="px-3 py-3">Booking</th>
|
||||||
|
<th className="px-3 py-3">Customer</th>
|
||||||
|
<th className="px-3 py-3">Container</th>
|
||||||
|
<th className="px-3 py-3">Qty</th>
|
||||||
|
<th className="px-3 py-3">Weight</th>
|
||||||
|
<th className="px-3 py-3">Origin</th>
|
||||||
|
<th className="px-3 py-3">Destination</th>
|
||||||
|
<th className="px-3 py-3">Departure</th>
|
||||||
|
<th className="px-3 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border bg-card">
|
||||||
|
{eligibleItems.map((booking) => (
|
||||||
|
<tr key={booking.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedBookingIds.includes(booking.id)}
|
||||||
|
onChange={(event) => toggleBooking(booking, event.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 font-medium">{booking.reference}</td>
|
||||||
|
<td className="px-3 py-3">{booking.customer}</td>
|
||||||
|
<td className="px-3 py-3">{booking.containerType}</td>
|
||||||
|
<td className="px-3 py-3">{booking.quantity}</td>
|
||||||
|
<td className="px-3 py-3">{booking.weightTons.toLocaleString()} T</td>
|
||||||
|
<td className="px-3 py-3">{booking.origin}</td>
|
||||||
|
<td className="px-3 py-3">{booking.destination}</td>
|
||||||
|
<td className="px-3 py-3">{formatDate(booking.preferredDepartureDate)}</td>
|
||||||
|
<td className="px-3 py-3">{booking.status}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
|
||||||
|
No eligible container bookings matched the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Schedule builder</h2>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected bookings</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{summary.count}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total weight</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{summary.totalWeightTons.toLocaleString()} T</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">{summary.route}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule date</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">{summary.scheduleDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagon type</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">NW5</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagons / length</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedBookingIds.length || isBusy}
|
||||||
|
onClick={() => previewMutation.mutate()}
|
||||||
|
>
|
||||||
|
Preview schedule
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Locomotive</label>
|
||||||
|
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select available locomotive" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(locomotivesQuery.data ?? []).map((locomotive) => (
|
||||||
|
<SelectItem key={locomotive.id} value={locomotive.id}>
|
||||||
|
{locomotive.code} - {locomotive.maxPullWeightTons}T
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!preview?.valid || !selectedLocomotiveId || isBusy}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
Create schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<div className="mt-5 space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">Preview result</h3>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
preview.valid
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preview.valid ? 'Valid' : 'Invalid'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Wagons</p>
|
||||||
|
<p className="mt-1 font-semibold">{preview.summary.wagonsNeeded}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Weight</p>
|
||||||
|
<p className="mt-1 font-semibold">{preview.summary.totalWeightTons} T</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Length</p>
|
||||||
|
<p className="mt-1 font-semibold">{preview.summary.totalLengthMeters} m</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview.violations.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700 dark:border-rose-950 dark:bg-rose-950/30 dark:text-rose-300">
|
||||||
|
<ul className="list-disc space-y-1 pl-5">
|
||||||
|
{preview.violations.map((violation) => (
|
||||||
|
<li key={violation}>{violation}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Created schedules</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Open a schedule to inspect wagons and allocations.</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{filteredSchedules.length} schedules
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
|
||||||
|
<input
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Search by schedule, route, locomotive, or status"
|
||||||
|
value={scheduleSearch}
|
||||||
|
onChange={(event) => setScheduleSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">All statuses</SelectItem>
|
||||||
|
<SelectItem value="DRAFT">DRAFT</SelectItem>
|
||||||
|
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
|
||||||
|
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
|
||||||
|
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
|
||||||
|
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||||
|
<table className="min-w-full divide-y divide-border text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-3">Schedule</th>
|
||||||
|
<th className="px-3 py-3">Departure</th>
|
||||||
|
<th className="px-3 py-3">Route</th>
|
||||||
|
<th className="px-3 py-3">Locomotive</th>
|
||||||
|
<th className="px-3 py-3">Bookings</th>
|
||||||
|
<th className="px-3 py-3">Wagons</th>
|
||||||
|
<th className="px-3 py-3">Weight</th>
|
||||||
|
<th className="px-3 py-3">Length</th>
|
||||||
|
<th className="px-3 py-3">Status</th>
|
||||||
|
<th className="px-3 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border bg-card">
|
||||||
|
{filteredSchedules.map((schedule) => (
|
||||||
|
<tr key={schedule.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
|
||||||
|
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
{schedule.origin} to {schedule.destination}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
|
||||||
|
<td className="px-3 py-3">{schedule.bookingsCount}</td>
|
||||||
|
<td className="px-3 py-3">{schedule.wagonCount}</td>
|
||||||
|
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
|
||||||
|
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
|
||||||
|
<td className="px-3 py-3">{schedule.status}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
{schedule.status !== 'CANCELLED' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => cancelMutation.mutate(schedule.id)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
|
||||||
|
No train schedules matched the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(detailId)} onOpenChange={(open) => (!open ? setDetailId(null) : null)}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Train schedule detail</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Inspect the selected schedule, locomotive, wagons, and booking allocations.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-border p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
|
||||||
|
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Departure</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">{formatDate(detail.scheduledDepartureDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
|
||||||
|
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Status</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">{detail.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border p-4">
|
||||||
|
<h3 className="text-lg font-semibold">Locomotive</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{detail.trainSet?.locomotive
|
||||||
|
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)`
|
||||||
|
: 'No locomotive attached'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border p-4">
|
||||||
|
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{(detail.trainSet?.wagons ?? []).map((wagon) => (
|
||||||
|
<div key={wagon.id} className="rounded-xl border border-border p-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table className="min-w-full divide-y divide-border text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Booking</th>
|
||||||
|
<th className="px-3 py-2">Allocated weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border bg-card">
|
||||||
|
{wagon.allocations.map((allocation) => (
|
||||||
|
<tr key={allocation.id}>
|
||||||
|
<td className="px-3 py-2">{allocation.bookingReference ?? allocation.bookingId}</td>
|
||||||
|
<td className="px-3 py-2">{allocation.allocatedWeightTons} T</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border p-4">
|
||||||
|
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
|
||||||
|
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table className="min-w-full divide-y divide-border text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Reference</th>
|
||||||
|
<th className="px-3 py-2">Customer</th>
|
||||||
|
<th className="px-3 py-2">Weight</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border bg-card">
|
||||||
|
{detail.bookings.map((booking) => (
|
||||||
|
<tr key={booking.id}>
|
||||||
|
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
|
||||||
|
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2">{booking.weightTons} T</td>
|
||||||
|
<td className="px-3 py-2">{booking.status ?? '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading schedule detail...</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { api as client } from '../auth/http';
|
||||||
|
import { unwrap } from '@/utils/endpoint';
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
import type {
|
||||||
|
CreateTrainSchedulePayload,
|
||||||
|
EligibleContainerBookingsResponse,
|
||||||
|
LocomotiveRecord,
|
||||||
|
TrainScheduleDetail,
|
||||||
|
TrainScheduleFilters,
|
||||||
|
TrainScheduleListItem,
|
||||||
|
TrainSchedulePreviewPayload,
|
||||||
|
TrainSchedulePreviewResponse,
|
||||||
|
YardOption,
|
||||||
|
} from '@/types/trainScheduling';
|
||||||
|
|
||||||
|
interface BookingReferenceDataResponse {
|
||||||
|
yard?: YardOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trainSchedulingService = {
|
||||||
|
getEligibleBookings: async (
|
||||||
|
filters?: TrainScheduleFilters,
|
||||||
|
): Promise<EligibleContainerBookingsResponse> => {
|
||||||
|
const response = await client.get<EligibleContainerBookingsResponse>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS,
|
||||||
|
{ params: filters },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
preview: async (
|
||||||
|
payload: TrainSchedulePreviewPayload,
|
||||||
|
): Promise<TrainSchedulePreviewResponse> => {
|
||||||
|
const response = await client.post<TrainSchedulePreviewResponse>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
createSchedule: async (
|
||||||
|
payload: CreateTrainSchedulePayload,
|
||||||
|
): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
listSchedules: async (): Promise<TrainScheduleListItem[]> => {
|
||||||
|
const response = await client.get<TrainScheduleListItem[]>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getScheduleById: async (id: string): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.get<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelSchedule: async (id: string): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
|
||||||
|
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
|
||||||
|
params: { status: 'AVAILABLE' },
|
||||||
|
});
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getStations: async (): Promise<YardOption[]> => {
|
||||||
|
const response = await client.get<BookingReferenceDataResponse>(
|
||||||
|
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
|
||||||
|
);
|
||||||
|
const data = unwrap(response.data);
|
||||||
|
return data.yard ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
154
apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
Normal file
154
apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
export interface YardOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EligibleContainerBooking {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
customer: string;
|
||||||
|
containerType: string;
|
||||||
|
quantity: number;
|
||||||
|
weightTons: number;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
preferredDepartureDate: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EligibleContainerBookingsResponse {
|
||||||
|
count: number;
|
||||||
|
items: EligibleContainerBooking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonPlanAllocation {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
allocatedWeightTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonPlanRow {
|
||||||
|
sequenceNo: number;
|
||||||
|
capacityTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
assignedWeightTons: number;
|
||||||
|
allocations: WagonPlanAllocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainSchedulePreviewResponse {
|
||||||
|
valid: boolean;
|
||||||
|
violations: string[];
|
||||||
|
summary: {
|
||||||
|
totalBookings: number;
|
||||||
|
totalWeightTons: number;
|
||||||
|
wagonType: string;
|
||||||
|
wagonsNeeded: number;
|
||||||
|
totalLengthMeters: number;
|
||||||
|
};
|
||||||
|
bookingIds: string[];
|
||||||
|
wagonPlan: WagonPlanRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocomotiveRecord {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name?: string | null;
|
||||||
|
maxPullWeightTons: number;
|
||||||
|
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
|
||||||
|
availableFrom?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainScheduleListItem {
|
||||||
|
id: string;
|
||||||
|
scheduleDate: string;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
locomotive:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
wagonCount: number;
|
||||||
|
totalWeightTons: number;
|
||||||
|
totalLengthMeters: number;
|
||||||
|
bookingsCount: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainScheduleDetail {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDepartureDate: string;
|
||||||
|
scheduledArrivalDate?: string | null;
|
||||||
|
originStation?: {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
code?: string;
|
||||||
|
} | null;
|
||||||
|
destinationStation?: {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
code?: string;
|
||||||
|
} | null;
|
||||||
|
trainSet?: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
wagonCount: number;
|
||||||
|
totalWeightTons: number;
|
||||||
|
totalLengthMeters: number;
|
||||||
|
locomotive?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name?: string | null;
|
||||||
|
status: string;
|
||||||
|
maxPullWeightTons: number;
|
||||||
|
} | null;
|
||||||
|
wagons: Array<{
|
||||||
|
id: string;
|
||||||
|
sequenceNo: number;
|
||||||
|
capacityTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
assignedWeightTons: number;
|
||||||
|
wagonType?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
allocations: Array<{
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
allocatedWeightTons: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
bookings: Array<{
|
||||||
|
id: string;
|
||||||
|
reference: string | null;
|
||||||
|
customer: string | null;
|
||||||
|
weightTons: number;
|
||||||
|
status: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainScheduleFilters {
|
||||||
|
originStationId?: string;
|
||||||
|
destinationStationId?: string;
|
||||||
|
scheduleDate?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainSchedulePreviewPayload {
|
||||||
|
bookingIds: string[];
|
||||||
|
scheduleDate: string;
|
||||||
|
originStationId: string;
|
||||||
|
destinationStationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
|
||||||
|
locomotiveId: string;
|
||||||
|
}
|
||||||
@@ -50,9 +50,6 @@ export function endpoint<TInput, TResponse>(
|
|||||||
if (queryKeyBuilder && input !== undefined) {
|
if (queryKeyBuilder && input !== undefined) {
|
||||||
return queryKeyBuilder(input as TInput);
|
return queryKeyBuilder(input as TInput);
|
||||||
}
|
}
|
||||||
if (queryKeyBuilder && input === undefined) {
|
|
||||||
return queryKeyBuilder(undefined as TInput);
|
|
||||||
}
|
|
||||||
return input === undefined
|
return input === undefined
|
||||||
? [service, action]
|
? [service, action]
|
||||||
: [service, action, input];
|
: [service, action, input];
|
||||||
|
|||||||
Reference in New Issue
Block a user