booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -0,0 +1,21 @@
import { deriveScheduleDirection } from './derive-schedule-direction.util';
describe('deriveScheduleDirection', () => {
it('returns IMPORT when origin is Djibouti', () => {
expect(
deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }),
).toBe('IMPORT');
});
it('returns EXPORT when destination is Djibouti and origin is not', () => {
expect(
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }),
).toBe('EXPORT');
});
it('returns DOMESTIC for intra-Ethiopia routes', () => {
expect(
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }),
).toBe('DOMESTIC');
});
});

View File

@@ -0,0 +1,19 @@
import type { ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
export function deriveScheduleDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
if (originCountry === 'Djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';
}

View File

@@ -0,0 +1,86 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class ContainerPlacementDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingContainerId!: string;
@ApiProperty({ minimum: 0 })
@IsInt()
@Min(0)
unitIndex!: number;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
sequenceNo!: number;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
containerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sealNumber?: string;
}
export class AssignBookingsDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' })
@IsOptional()
@IsBoolean()
forceAssign?: boolean;
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerPlacementDto)
containerPlacements?: ContainerPlacementDto[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -13,4 +14,25 @@ export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
}

View File

@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
export class GetEligibleBookingsDto {
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
freightType?: 'CONTAINER' | 'BULK';
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional()
@IsOptional()
schedulingStatus?: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
export class GetEligibleBulkBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
schedulingStatus?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@@ -12,8 +12,7 @@ export class GetEligibleContainerBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
@IsDateString()
scheduleDate?: string;
schedulingStatus?: string;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class PinWagonAssignmentDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
trainSetWagonId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
physicalWagonId!: string;
}
export class PinWagonsDto {
@ApiProperty({ type: [PinWagonAssignmentDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PinWagonAssignmentDto)
assignments!: PinWagonAssignmentDto[];
}

View File

@@ -0,0 +1,3 @@
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {}

View File

@@ -1,22 +1,3 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
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;
}
export class PreviewContainerTrainScheduleDto extends PreviewTrainScheduleDto {}

View File

@@ -0,0 +1,61 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
} from 'class-validator';
export class PreviewTrainScheduleDto {
@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;
@ApiPropertyOptional({
format: 'uuid',
description: 'Allow bookings already assigned to this schedule (re-assign / reschedule)',
})
@IsOptional()
@IsUUID()
targetScheduleId?: string;
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
}

View File

@@ -0,0 +1,40 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
export class UpdateTrainSchedulingGlobalRulesDto {
@ApiPropertyOptional({ example: 760 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ example: 3500 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ example: 53 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({ example: 30 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
max20ftContainerWeightTons?: number;
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
max20ftPairWeightDiffTons?: number;
}

View File

@@ -0,0 +1,44 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({
name: 'max_train_length_meters',
type: 'numeric',
precision: 10,
scale: 2,
default: 760,
})
maxTrainLengthMeters!: number;
@Column({
name: 'max_train_weight_tons',
type: 'numeric',
precision: 10,
scale: 3,
default: 3500,
})
maxTrainWeightTons!: number;
@Column({ name: 'max_wagons_per_train', type: 'int', default: 53 })
maxWagonsPerTrain!: number;
@Column({
name: 'max_20ft_container_weight_tons',
type: 'numeric',
precision: 8,
scale: 3,
default: 30,
})
max20ftContainerWeightTons!: number;
@Column({
name: 'max_20ft_pair_weight_diff_tons',
type: 'numeric',
precision: 8,
scale: 3,
default: 10,
})
max20ftPairWeightDiffTons!: number;
}

View File

@@ -0,0 +1,127 @@
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
computeFleetAvailability,
selectBookingsWithinFleetCap,
sortBookingsForScheduling,
summarizeFleetWarnings,
wagonsRequiredForBooking,
} from './fleet-plan.util';
import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util';
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const makeBooking = (
id: string,
extra: Partial<Booking> = {},
): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
isGovernment: false,
priorityScore: 0,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
cargoTotalWeightVgm: 50,
bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }],
...extra,
}) as Booking;
describe('fleet-plan.util', () => {
it('sorts bookings government first, then priority, then date', () => {
const bookings = [
makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }),
makeBooking('gov', { isGovernment: true, priorityScore: 0 }),
makeBooking('prio', { priorityScore: 10 }),
];
const sorted = sortBookingsForScheduling(bookings);
expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']);
});
it('computes fleet availability with shortfall', () => {
const plan: WagonPlanSlot[] = buildContainerWagonPlan(
[
makeBooking('b1', {
bookingContainers: [
{ id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
],
}),
],
nw5,
);
const fleetByTypeId = new Map([[nw5.id, 1]]);
const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']]));
const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5');
expect(nw5Row?.needed).toBe(2);
expect(nw5Row?.available).toBe(1);
expect(nw5Row?.shortfall).toBe(1);
});
it('defers lower-priority bookings when fleet is insufficient', () => {
const high = makeBooking('high', {
priorityScore: 100,
bookingContainers: [
{ id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
],
});
const low = makeBooking('low', {
priorityScore: 1,
bookingContainers: [
{ id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
],
});
const fleet = new Map([[nw5.id, 2]]);
const { fitting, deferred } = selectBookingsWithinFleetCap(
[low, high],
fleet,
() => nw5.id,
);
expect(fitting.map((b) => b.id)).toEqual(['high']);
expect(deferred).toHaveLength(1);
expect(deferred[0]?.id).toBe('low');
expect(deferred[0]?.reason).toContain('2');
});
it('summarizes fleet shortage warnings', () => {
const warnings = summarizeFleetWarnings(
[
{
wagonTypeId: nw5.id,
wagonTypeCode: 'NW5',
needed: 5,
available: 2,
shortfall: 3,
},
],
[{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }],
);
expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true);
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
});
it('counts wagons required per booking from container lines', () => {
const booking = makeBooking('b1', {
bookingContainers: [
{ id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
{ id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
],
});
expect(wagonsRequiredForBooking(booking)).toBe(2);
});
});

View File

@@ -0,0 +1,168 @@
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
roundTons,
type WagonPlanSlot,
} from './wagon-plan.util';
export type FleetAvailabilityRow = {
wagonTypeId: string;
wagonTypeCode: string;
needed: number;
available: number;
shortfall: number;
};
export type DeferredBookingRow = {
id: string;
reference: string;
reason: string;
};
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
return [...bookings].sort((a, b) => {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
});
}
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
if (booking.freightType === 'BULK') {
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
return Math.max(1, Math.ceil(weight / capacity));
}
const lineSlots = (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
return Math.max(1, lineSlots);
}
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
const map = new Map<string, { code: string; count: number }>();
for (const slot of wagonPlan) {
const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 };
existing.count += 1;
map.set(slot.wagonTypeId, existing);
}
return map;
}
export function computeFleetAvailability(
demandPlan: WagonPlanSlot[],
fleetByTypeId: Map<string, number>,
fleetTypeCodes: Map<string, string>,
): FleetAvailabilityRow[] {
const neededByType = countSlotsByType(demandPlan);
const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]);
return [...typeIds].map((wagonTypeId) => {
const needed = neededByType.get(wagonTypeId)?.count ?? 0;
const available = fleetByTypeId.get(wagonTypeId) ?? 0;
return {
wagonTypeId,
wagonTypeCode:
neededByType.get(wagonTypeId)?.code ??
fleetTypeCodes.get(wagonTypeId) ??
wagonTypeId,
needed,
available,
shortfall: Math.max(0, needed - available),
};
}).filter((row) => row.needed > 0 || row.available > 0);
}
export function selectBookingsWithinFleetCap(
bookings: Booking[],
fleetByTypeId: Map<string, number>,
resolveWagonTypeId: (booking: Booking) => string,
bulkWagonCapacity?: number,
): { fitting: Booking[]; deferred: DeferredBookingRow[] } {
const remaining = new Map(fleetByTypeId);
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
for (const booking of sortBookingsForScheduling(bookings)) {
const typeId = resolveWagonTypeId(booking);
const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity);
const available = remaining.get(typeId) ?? 0;
if (available >= needed) {
remaining.set(typeId, available - needed);
fitting.push(booking);
continue;
}
deferred.push({
id: booking.id,
reference: booking.reference,
reason:
available > 0
? `Needs ${needed} wagons but only ${available} available for this type`
: `No available wagons for required type (${needed} needed)`,
});
}
return { fitting, deferred };
}
export function buildCappedWagonPlan(params: {
bookings: Booking[];
resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED';
containerWagonType: WagonType;
bulkWagonType: WagonType;
}): WagonPlanSlot[] {
const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params;
if (resolvedMode === 'MIXED') {
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
return buildMixedWagonPlan(
containerBookings,
bulkBookings,
containerWagonType,
bulkWagonType,
);
}
if (resolvedMode === 'BULK') {
return buildBulkWagonPlan(bookings, bulkWagonType);
}
return buildContainerWagonPlan(bookings, containerWagonType);
}
export function summarizeFleetWarnings(
fleetAvailability: FleetAvailabilityRow[],
deferred: DeferredBookingRow[],
): string[] {
const warnings: string[] = [];
for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) {
warnings.push(
`Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`,
);
}
if (deferred.length) {
warnings.push(
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
);
}
return warnings;
}
export function totalAssignedWeight(bookings: Booking[]): number {
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
}

View File

@@ -1,17 +1,27 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@ApiTags('train-scheduling')
@@ -20,39 +30,176 @@ import { TrainSchedulingService } from './train-scheduling.service';
export class TrainSchedulingController {
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
@Get('global-rules')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
getGlobalRules() {
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
}
@Patch('global-rules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
}
@Get('eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
return this.trainSchedulingService.getEligibleBookings(query);
}
@Get('container/eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Get('bulk/eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bulk bookings' })
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
return this.trainSchedulingService.getEligibleBulkBookings(query);
}
@Post('preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
return this.trainSchedulingService.previewTrainSchedule(dto);
}
@Post('container/preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a container train schedule' })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('bulk/preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a bulk train schedule' })
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
}
@Post('container/schedules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a container train schedule' })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('bulk/schedules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a bulk train schedule' })
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('schedules/:id/assign-bookings')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
assignBookings(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
}
@Post('container/schedules/:id/assign-bookings')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
assignContainerBookings(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
}
@Post('bulk/schedules/:id/assign-bookings')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
assignBulkBookings(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
}
@Delete('schedules/:id/bookings/:bookingId')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
unassignBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
) {
return this.trainSchedulingService.unassignBooking(id, bookingId);
}
@Post('schedules/:id/pin-wagons')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
return this.trainSchedulingService.pinWagons(id, dto);
}
@Post('schedules/:id/finalize')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Finalize a draft train schedule' })
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post('schedules/:id/dispatch')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Dispatch a scheduled train' })
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get('container/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('bulk/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List bulk train schedules' })
getBulkTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get('bulk/schedules/:id')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get bulk train schedule detail' })
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel bulk train schedule' })
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
}

View File

@@ -2,42 +2,40 @@ 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 { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
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 { Wagon } from '../wagons/entities/wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.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,
Route,
Wagon,
Container,
TrainSchedulingGlobalRules,
]),
BookingsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
RuleEngineModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],

View File

@@ -1,5 +1,10 @@
import { ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
@@ -11,6 +16,7 @@ const nw5 = {
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
};
const locomotive = {
@@ -21,15 +27,29 @@ const locomotive = {
status: 'AVAILABLE',
};
const cw3 = {
id: 'wagon-type-bulk',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
wagonsRequired: number,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
extra: Record<string, unknown> = {},
) => ({
id,
reference,
@@ -39,75 +59,177 @@ const makeBooking = (
originYardId,
destinationYardId,
status: 'PAID',
customer: { companyName: 'Demo Customer' },
schedulingStatus: 'HOLDING',
holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
company: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
id: `${id}-line`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { code: containerCode, label: containerCode },
},
],
...extra,
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let locomotivesRepository: {
findById: jest.Mock;
};
let wagonTypesRepository: {
findAll: jest.Mock;
};
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
let trainSchedulesRepository: Record<string, jest.Mock>;
let trainScheduleBookingsRepository: Record<string, jest.Mock>;
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => {
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
updateSchedulingFields: jest.fn(),
};
locomotivesRepository = {
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
wagonTypesRepository = { findAll: jest.fn() };
trainSchedulesRepository = {
findById: jest.fn(),
};
wagonTypesRepository = {
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
};
trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(),
createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(),
};
wagonBookingAllocationsRepository = {
deleteByTrainSetId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(),
};
wagonAllocationContainerItemsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
wagonAllocationBulkLoadsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
service = new TrainSchedulingService(
dataSource as never,
bookingsRepository as never,
locomotivesRepository as never,
wagonTypesRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
wagonBookingAllocationsRepository as never,
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
);
const defaultFleetWagons = [
...Array.from({ length: 100 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
})),
...Array.from({ length: 50 }, (_, index) => ({
id: `wagon-cw3-${index}`,
wagonTypeId: cw3.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
})),
];
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
});
it('computes the expected valid preview for Group A', async () => {
it('returns fleet availability and defers bookings when fleet is insufficient', 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'),
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const availableWagons = Array.from({ length: 15 }, (_, index) => ({
id: `wagon-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(availableWagons) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((booking) => booking.id),
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0);
expect(result.deferredBookings?.length).toBeGreaterThan(0);
expect(result.summary.wagonsNeeded).toBeLessThan(30);
expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe(
true,
);
});
it('computes slot-based preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
@@ -115,40 +237,50 @@ describe('TrainSchedulingService', () => {
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,
expect(result.summary.wagonsNeeded).toBe(45);
expect(result.wagonPlan).toHaveLength(45);
});
it('returns soft hold warnings without forceAssign', async () => {
const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('soft hold window');
});
it('flags the overweight booking as invalid', async () => {
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
const bookings = [
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
bookingContainers: [
{
id: 'b6-line',
containerTypeId: 'ct-1',
quantity: 80,
wagonsRequired: 80,
vgmPerUnitTons: 45,
isOverweight: true,
containerType: { code: '40FT', label: '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}`);
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
@@ -158,36 +290,70 @@ describe('TrainSchedulingService', () => {
});
expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600);
expect(result.violations).toContain(
'Total booking weight 3600T exceeds max train weight 3500T',
expect(result.violations.some((v) => v.includes('overweight'))).toBe(true);
});
it('allows preview when bookings are already on the target schedule', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
{ bookingId: 'b1', trainScheduleId: 'sched-target' },
]);
trainSchedulesRepository.findById.mockResolvedValue({
id: 'sched-target',
direction: 'IMPORT',
});
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
targetScheduleId: 'sched-target',
});
expect(result.violations).not.toContain(
'One or more selected bookings are already assigned to a train schedule',
);
expect(result.valid).toBe(true);
});
it('allows preview when selected bookings are on different schedule dates', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.violations).not.toContain(
'Selected bookings must share the same schedule date',
);
expect(result.valid).toBe(true);
});
it('rejects bookings that are not in schedulable status', async () => {
const bookings = [
{
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
status: 'APPROVED',
},
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
];
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}`);
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
@@ -239,13 +405,16 @@ describe('TrainSchedulingService', () => {
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
@@ -259,7 +428,62 @@ describe('TrainSchedulingService', () => {
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
expect(result.id).toBe('schedule-1');
});
it('previews mixed container and bulk bookings', async () => {
const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
status: 'PAID',
bookingContainers: [],
cargoType: { code: 'COFFEE' },
};
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
if (where?.code === 'NW5') return [nw5];
return [nw5, cw3];
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c1', 'b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.summary.wagonType).toBe('MIXED');
expect(result.wagonPlan.length).toBeGreaterThan(2);
expect(result.containerUnits).toHaveLength(2);
});
it('previews container bookings without requiring placements', async () => {
const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c2'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.containerUnits).toHaveLength(1);
});
it('rejects create when the locked locomotive is no longer available', async () => {
@@ -296,4 +520,48 @@ describe('TrainSchedulingService', () => {
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects pin when wagon readiness does not match schedule direction', async () => {
const scheduleId = 'sched-1';
const slotId = 'slot-1';
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
trainSet: {
wagons: [{ id: slotId, physicalWagonId: null }],
},
});
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity === Wagon) {
return {
findOne: jest.fn().mockResolvedValue({
id: 'wagon-1',
wagonNumber: 'WGN-001',
status: WagonStatus.Available,
readiness: WagonReadiness.ExportReady,
currentTrainScheduleId: null,
}),
update: jest.fn(),
};
}
if (entity === TrainSetWagon) {
return { update: jest.fn() };
}
throw new Error(`Unexpected repository ${entity?.name}`);
}),
};
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
callback(manager),
);
await expect(
service.pinWagons(scheduleId, {
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
}),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -0,0 +1,202 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
expandBookingContainerUnits,
expandContainerItems,
roundTons,
sumWagonsRequired,
validate20ftContainerRules,
validateContainerPlacements,
} from './wagon-plan.util';
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const makeContainerBooking = (
id: string,
lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>,
): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: lines.reduce(
(sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25),
0,
),
bookingContainers: lines.map((line, index) => ({
id: `${id}-line-${index}`,
containerTypeId: `ct-${index}`,
quantity: line.quantity,
wagonsRequired: line.wagonsRequired,
vgmPerUnitTons: line.vgmPerUnitTons ?? 25,
})),
}) as Booking;
describe('wagon-plan.util', () => {
it('uses slot-based planning: 2×20ft = 1 wagon slot', () => {
const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(1);
expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container);
});
it('uses slot-based planning: 1×40ft = 1 wagon slot', () => {
const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(1);
});
it('sums wagons across multiple container lines', () => {
const booking = makeContainerBooking('b3', [
{ quantity: 2, wagonsRequired: 1 },
{ quantity: 1, wagonsRequired: 1 },
]);
expect(sumWagonsRequired(booking)).toBe(2);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(2);
});
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
expect(sumWagonsRequired(booking)).toBe(3);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(3);
// Verify sequence numbers are 1, 2, 3
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
});
it('expands container items per quantity', () => {
const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]);
const items = expandContainerItems(booking, 'alloc-1');
expect(items).toHaveLength(3);
expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1');
});
it('rounds tons to three decimal places', () => {
expect(roundTons(1.23456)).toBe(1.235);
expect(roundTons('bad')).toBe(0);
});
it('builds mixed plan with container block before bulk', () => {
const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3);
expect(plan).toHaveLength(4);
expect(plan[0]?.slotLoadType).toBe('CONTAINER');
expect(plan[2]?.slotLoadType).toBe('BULK');
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]);
});
it('expands booking container units for UI rows', () => {
const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]);
const units = expandBookingContainerUnits([booking]);
expect(units).toHaveLength(3);
expect(units[1]?.unitIndex).toBe(1);
expect(units[1]?.bookingContainerId).toBe('c2-line-0');
});
it('validates required placements per container unit', () => {
const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]);
const plan = buildContainerWagonPlan([booking], nw5);
const violations = validateContainerPlacements([booking], plan, []);
expect(violations.some((v) => v.includes('required'))).toBe(true);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: plan[index]?.sequenceNo ?? 1,
containerNumber: `CNTR-${index + 1}`,
}));
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
});
it('rejects 20ft container over max individual weight', () => {
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${index + 1}`,
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
});
it('rejects 20ft pair when weight difference exceeds limit', () => {
const booking = makeContainerBooking('c21', [
{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 },
]);
booking.bookingContainers![0]!.vgmPerUnitTons = 25;
const units = expandBookingContainerUnits([booking]);
units[1]!.grossWeightTons = 10;
const placements = units.map((unit) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${unit.unitIndex}`,
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('weight difference'))).toBe(true);
});
it('builds bulk-only plan as degenerate mixed case', () => {
const bulkBooking = {
id: 'b2',
reference: 'BKG-BULK-2',
freightType: 'BULK',
cargoTotalWeightVgm: 60,
bookingContainers: [],
} as unknown as Booking;
const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3);
expect(plan).toHaveLength(1);
expect(plan[0]?.slotLoadType).toBe('BULK');
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,551 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;
export const MAX_TEU_SLOTS_PER_WAGON = 2;
export type TrainLimitConfig = {
maxWeightTons?: number;
maxLengthMeters?: number;
maxWagonsPerTrain?: number;
max20ftContainerWeightTons?: number;
max20ftPairWeightDiffTons?: number;
};
export type ContainerPlacementRules = {
max20ftContainerWeightTons?: number;
max20ftPairWeightDiffTons?: number;
};
export type WagonAllocationRecord = {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
loadType: AllocationLoadType;
};
export type SlotLoadType = 'CONTAINER' | 'BULK';
export type WagonPlanSlot = {
sequenceNo: number;
wagonTypeId: string;
wagonTypeCode: string;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonAllocationRecord[];
slotLoadType?: SlotLoadType;
};
export type ContainerUnitRow = {
bookingId: string;
bookingReference: string;
bookingContainerId: string;
unitIndex: number;
containerTypeId: string;
containerTypeCode: string;
label: string;
grossWeightTons: number;
sizeFt?: number;
wagonsPerUnit?: number;
containersPerWagon?: number;
teuSlots?: number;
};
export type ContainerPlacementInput = {
bookingContainerId: string;
unitIndex: number;
sequenceNo: number;
containerId?: string;
containerNumber?: string;
sealNumber?: string;
};
export function roundTons(value: number | string | null | undefined): number {
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
if (!Number.isFinite(numericValue)) return 0;
return Number(numericValue.toFixed(3));
}
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
export function teuSlotsForSizeFt(sizeFt: number): number {
return sizeFt >= 40 ? 2 : 1;
}
export function containersPerWagonFromType(wagonsPerUnit: number): number {
const wpu = Number(wagonsPerUnit);
if (!wpu || wpu <= 0) return 1;
return Math.max(1, Math.round(1 / wpu));
}
function lineWagonsRequired(line: {
quantity?: number | null;
wagonsRequired?: number | null;
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
}): number {
const qty = Number(line.quantity ?? 0);
if (qty <= 0) return 0;
const wpu = Number(line.containerType?.wagonsPerUnit);
if (Number.isFinite(wpu) && wpu > 0) {
return Math.ceil(qty * wpu);
}
return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1)));
}
/**
* Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required.
*/
export function buildContainerWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
const totalSlots = bookings.reduce((sum, booking) => {
const lineSlots = (booking.bookingContainers ?? []).reduce(
(lineSum, line) => lineSum + lineWagonsRequired(line),
0,
);
return sum + Math.max(lineSlots, 1);
}, 0);
const slots = Math.max(1, Math.ceil(totalSlots));
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: Number(wagonType.capacityTons),
lengthMeters: Number(wagonType.lengthMeters),
assignedWeightTons: 0,
allocations: [],
}));
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Container).map((slot) => ({
...slot,
slotLoadType: 'CONTAINER' as SlotLoadType,
}));
}
/**
* Build weight-based wagon plan for BULK bookings.
*/
export function buildBulkWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
const totalWeight = roundTons(
bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0),
);
const capacity = Number(wagonType.capacityTons);
const slots = Math.max(1, Math.ceil(totalWeight / capacity));
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: capacity,
lengthMeters: Number(wagonType.lengthMeters),
assignedWeightTons: 0,
allocations: [],
}));
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({
...slot,
slotLoadType: 'BULK' as SlotLoadType,
}));
}
/**
* Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers.
*/
export function buildMixedWagonPlan(
containerBookings: Booking[],
bulkBookings: Booking[],
containerWagonType: WagonType,
bulkWagonType: WagonType,
): WagonPlanSlot[] {
const containerPlan = containerBookings.length
? buildContainerWagonPlan(containerBookings, containerWagonType)
: [];
const bulkPlan = bulkBookings.length
? buildBulkWagonPlan(bulkBookings, bulkWagonType)
: [];
const tagged: WagonPlanSlot[] = [
...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })),
...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })),
];
if (!tagged.length) {
return [
{
sequenceNo: 1,
wagonTypeId: containerWagonType.id,
wagonTypeCode: containerWagonType.code,
capacityTons: Number(containerWagonType.capacityTons),
lengthMeters: Number(containerWagonType.lengthMeters),
assignedWeightTons: 0,
allocations: [],
slotLoadType: 'CONTAINER',
},
];
}
return tagged.map((slot, index) => ({
...slot,
sequenceNo: index + 1,
}));
}
export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] {
const rows: ContainerUnitRow[] = [];
for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) {
for (const line of booking.bookingContainers ?? []) {
const qty = Number(line.quantity ?? 0);
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt);
for (let i = 0; i < qty; i += 1) {
rows.push({
bookingId: booking.id,
bookingReference: booking.reference,
bookingContainerId: line.id,
unitIndex: i,
containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(line.vgmPerUnitTons),
sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon,
teuSlots,
});
}
}
}
return rows;
}
export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] {
return wagonPlan
.filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some(
(a) => a.loadType === AllocationLoadType.Container,
))
.map((slot) => slot.sequenceNo);
}
function allocateBookingsToSlots(
bookings: Booking[],
basePlan: WagonPlanSlot[],
loadType: AllocationLoadType,
): WagonPlanSlot[] {
const remaining = bookings.map((booking) => ({
bookingId: booking.id,
bookingReference: booking.reference,
remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
}));
let bookingIndex = 0;
return basePlan.map((slot) => {
let wagonRemaining = roundTons(slot.capacityTons);
const allocations: WagonAllocationRecord[] = [];
let assignedWeightTons = 0;
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
const allocatedWeightTons = roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
bookingIndex += 1;
continue;
}
allocations.push({
bookingId: booking.bookingId,
bookingReference: booking.bookingReference,
allocatedWeightTons,
loadType,
});
booking.remainingWeightTons = roundTons(
booking.remainingWeightTons - allocatedWeightTons,
);
wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons);
assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons);
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
}
}
return { ...slot, assignedWeightTons, allocations };
});
}
export function expandContainerItems(
booking: Booking,
allocationId: string,
): Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string;
grossWeightTons: number;
positionOnWagon: number | null;
}> {
const items: Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string;
grossWeightTons: number;
positionOnWagon: number | null;
}> = [];
for (const line of booking.bookingContainers ?? []) {
const qty = Number(line.quantity ?? 0);
for (let i = 0; i < qty; i += 1) {
items.push({
wagonBookingAllocationId: allocationId,
bookingContainerId: line.id,
containerTypeId: line.containerTypeId ?? '',
grossWeightTons: Number(line.vgmPerUnitTons),
positionOnWagon: qty > 1 ? i + 1 : null,
});
}
}
return items;
}
export function sumWagonsRequired(booking: Booking): number {
if (booking.freightType === 'BULK') {
return 1;
}
return (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
}
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
const violations: string[] = [];
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
if (slot.assignedWeightTons > slot.capacityTons) {
violations.push(
`Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`,
);
}
}
return violations;
}
export function validateTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonType: WagonType,
limits?: TrainLimitConfig,
): string[] {
const violations: string[] = [];
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const maxWagonsPerTrain =
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
const totalWeightTons = roundTons(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (totalWeightTons > maxWeightTons) {
violations.push(
`Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`,
);
}
if (totalLengthMeters > maxLengthMeters) {
violations.push(
`Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`,
);
}
if (wagonPlan.length > maxWagonsPerTrain) {
violations.push(
`Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`,
);
}
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
return violations;
}
export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: WagonType[],
limits?: TrainLimitConfig,
): string[] {
const maxWagonsPerTrain =
limits?.maxWagonsPerTrain ??
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
return validateTrainLimits(
wagonPlan,
{ maxWagonsPerTrain } as WagonType,
{ ...limits, maxWagonsPerTrain },
);
}
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const maxEach = rules?.max20ftContainerWeightTons;
const maxDiff = rules?.max20ftPairWeightDiffTons;
if (maxEach == null && maxDiff == null) return violations;
const placementByUnit = new Map(
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
);
const weightsBySlot = new Map<number, number[]>();
for (const unit of units) {
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
if (maxEach != null && unit.grossWeightTons > maxEach) {
violations.push(
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
);
}
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
if (!placement?.sequenceNo) continue;
const list = weightsBySlot.get(placement.sequenceNo) ?? [];
list.push(unit.grossWeightTons);
weightsBySlot.set(placement.sequenceNo, list);
}
if (maxDiff != null) {
for (const [sequenceNo, weights] of weightsBySlot.entries()) {
if (weights.length < 2) continue;
const diff = Math.abs(weights[0]! - weights[1]!);
if (diff > maxDiff) {
violations.push(
`Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`,
);
}
}
}
return violations;
}
export function validateContainerPlacements(
containerBookings: Booking[],
wagonPlan: WagonPlanSlot[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const units = expandBookingContainerUnits(containerBookings);
if (!units.length) return violations;
const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan));
const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`));
const placementKeys = new Set<string>();
const containerNumbers = new Set<string>();
if (!placements.length) {
violations.push('Container placements are required for container bookings');
return violations;
}
for (const placement of placements) {
const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`;
if (!unitKeys.has(unitKey)) {
violations.push(
`Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`,
);
continue;
}
if (placementKeys.has(unitKey)) {
violations.push(`Duplicate placement for container unit ${unitKey}`);
}
placementKeys.add(unitKey);
if (!containerSlots.has(placement.sequenceNo)) {
violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`);
}
const hasInventory = Boolean(placement.containerId);
const hasManual = Boolean(placement.containerNumber?.trim());
if (!hasInventory && !hasManual) {
violations.push(
`Container unit ${unitKey} requires an existing container or a new container number`,
);
}
if (hasManual) {
const normalized = placement.containerNumber!.trim().toUpperCase();
if (containerNumbers.has(normalized)) {
violations.push(`Duplicate container number ${normalized}`);
}
containerNumbers.add(normalized);
}
}
for (const unit of units) {
const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`;
if (!placementKeys.has(unitKey)) {
violations.push(`Missing placement for ${unit.label}`);
}
}
const slotTeuUsed = new Map<number, number>();
const slotWeightUsed = new Map<number, number>();
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
for (const placement of placements) {
const unit = units.find(
(u) =>
u.bookingContainerId === placement.bookingContainerId &&
u.unitIndex === placement.unitIndex,
);
if (!unit) continue;
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0;
if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) {
violations.push(
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
);
} else {
slotTeuUsed.set(placement.sequenceNo, usedTeu + teu);
}
const slot = slotBySeq.get(placement.sequenceNo);
if (slot) {
const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons;
slotWeightUsed.set(placement.sequenceNo, weight);
if (weight > slot.capacityTons) {
violations.push(
`Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`,
);
}
}
}
violations.push(...validate20ftContainerRules(units, placements, rules));
return violations;
}

View File

@@ -0,0 +1,35 @@
import { WagonReadiness } from '@edr/types';
import {
requiredWagonReadiness,
wagonReadinessMatchesSchedule,
} from './wagon-readiness.util';
describe('wagonReadinessMatchesSchedule', () => {
it('requires IMPORT_READY for IMPORT schedules', () => {
expect(requiredWagonReadiness('IMPORT')).toBe(WagonReadiness.ImportReady);
expect(
wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'IMPORT'),
).toBe(true);
expect(
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'IMPORT'),
).toBe(false);
});
it('requires EXPORT_READY for EXPORT schedules', () => {
expect(requiredWagonReadiness('EXPORT')).toBe(WagonReadiness.ExportReady);
expect(
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'EXPORT'),
).toBe(true);
expect(
wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'EXPORT'),
).toBe(false);
});
it('allows any readiness for DOMESTIC schedules', () => {
expect(requiredWagonReadiness('DOMESTIC')).toBeNull();
expect(
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'DOMESTIC'),
).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
export function requiredWagonReadiness(
direction: ScheduleTradeDirection | string | null | undefined,
): WagonReadiness | null {
if (direction === 'IMPORT') return WagonReadiness.ImportReady;
if (direction === 'EXPORT') return WagonReadiness.ExportReady;
return null;
}
export function wagonReadinessMatchesSchedule(
wagonReadiness: WagonReadiness | string,
direction: ScheduleTradeDirection | string | null | undefined,
): boolean {
const required = requiredWagonReadiness(direction);
if (!required) return true;
return wagonReadiness === required;
}

View File

@@ -0,0 +1,49 @@
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
COFFEE: 'KW2',
GRAIN: 'KW2',
WHEAT: 'KW2',
SORGHUM: 'KW2',
CORN: 'KW2',
FERTILIZER: 'PW2',
SUGAR: 'PW2',
COAL: 'KW3',
STEEL: 'CW3',
ORE: 'CW3',
};
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
/**
* Resolve wagon type code from cargo type code for bulk freight.
*/
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
const normalized = cargoTypeCode.trim().toUpperCase();
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
}
/**
* Pick the best matching wagon type entity for bulk cargo.
*/
export function pickBulkWagonType(
wagonTypes: WagonType[],
cargoTypeCode?: string | null,
): WagonType | undefined {
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
if (direct) return direct;
return wagonTypes.find(
(wt) =>
wt.isActive &&
!wt.supportsContainer &&
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
);
}
export function getDefaultContainerWagonTypeCode(): string {
return DEFAULT_CONTAINER_WAGON_TYPE;
}