mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions
This commit is contained in:
@@ -152,6 +152,18 @@ export class BookingsController {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
|
||||
async uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
|
||||
@@ -62,6 +62,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -502,6 +502,21 @@ export class BookingsService {
|
||||
return this.findById(booking.id);
|
||||
}
|
||||
|
||||
/** Upload documents for a DRAFT booking. */
|
||||
async uploadDocuments(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== 'DRAFT') {
|
||||
throw new BadRequestException(
|
||||
'Documents can only be uploaded for DRAFT bookings',
|
||||
);
|
||||
}
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== 'DRAFT') {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
@@ -11,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -22,7 +26,10 @@ interface CurrentIamUser {
|
||||
@ApiTags('Companies')
|
||||
@Controller('companies')
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companiesService: CompaniesService) {}
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
@Get('getInfo')
|
||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||
@@ -31,6 +38,22 @@ export class CompaniesController {
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
||||
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
return this.companiesService.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||
async createWithProfile(
|
||||
@@ -105,6 +128,17 @@ export class CompaniesController {
|
||||
await this.companiesService.deleteCompany(id);
|
||||
}
|
||||
|
||||
@Post(':companyId/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
||||
async uploadDocuments(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
) {
|
||||
return this.filesService.uploadMany(companyId, 'companies', files);
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
async createProfile(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
exports: [CompaniesService],
|
||||
|
||||
@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
@@ -103,6 +105,42 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
|
||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.businessLicense = dto.fanNumber;
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
}
|
||||
|
||||
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
|
||||
export class ProfileResponseDto {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyEmail: string | null;
|
||||
companyPhone: string | null;
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
tinNumber: string;
|
||||
vatNumber: string | null;
|
||||
fanNumber: string | null;
|
||||
|
||||
contactPersonName: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
|
||||
poaName: string | null;
|
||||
poaPhone: string | null;
|
||||
poaEmail: string | null;
|
||||
poaLocation: string | null;
|
||||
poaAddress: string | null;
|
||||
|
||||
profileId: string;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyEmail = company.email ?? null;
|
||||
this.companyPhone = company.phone ?? null;
|
||||
this.companyLocation = company.country;
|
||||
this.companyAddress = company.address ?? null;
|
||||
this.tinNumber = company.tin;
|
||||
this.vatNumber = company.vatNumber ?? null;
|
||||
this.fanNumber = company.fanNumber ?? null;
|
||||
this.profileId = profile.id;
|
||||
|
||||
const attrs = company.attributes ?? {};
|
||||
this.contactPersonName = attrs.contactPersonName ?? null;
|
||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||
this.poaName = attrs.poaName ?? null;
|
||||
this.poaPhone = attrs.poaPhone ?? null;
|
||||
this.poaEmail = attrs.poaEmail ?? null;
|
||||
this.poaLocation = attrs.poaLocation ?? null;
|
||||
this.poaAddress = attrs.poaAddress ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
companyEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
companyLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPersonName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPersonPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalManagerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
generalManagerEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalManagerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
poaEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
}
|
||||
@@ -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,814 @@
|
||||
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.company?.name ?? booking.company?.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: { company: 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?.company?.name ??
|
||||
scheduleBooking.booking?.company?.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: {
|
||||
company: 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user