mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
Replace wagon/locomotive readiness with yard tracking, assign unassigned bookings from origin-yard fleet, standardize rates on USD with CBE ETB conversion, and update fleet/scheduling UI
This commit is contained in:
@@ -2,22 +2,24 @@ import { BookingPricingService } from './booking-pricing.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkEtb: Rate = {
|
||||
id: 'rate-intercity-bulk-etb',
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
rateType: 'INTERCITY_BULK',
|
||||
currency: 'ETB',
|
||||
rateValue: 1900,
|
||||
currency: 'USD',
|
||||
rateValue: 35,
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerEtb: Rate = {
|
||||
id: 'rate-intercity-container-etb',
|
||||
const intercityContainerUsd: Rate = {
|
||||
id: 'rate-intercity-container-usd',
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
currency: 'ETB',
|
||||
rateValue: 25000,
|
||||
currency: 'USD',
|
||||
rateValue: 400,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
@@ -26,11 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]),
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||||
};
|
||||
cbeExchangeService = {
|
||||
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
@@ -39,10 +45,11 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
cbeExchangeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices domestic bulk using INTERCITY_BULK and cargo tons', async () => {
|
||||
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
freightType: 'BULK',
|
||||
@@ -57,16 +64,42 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].amount).toBe(1900 * 120);
|
||||
expect(result.lineItems[0].currency).toBe('ETB');
|
||||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||||
});
|
||||
|
||||
it('prices domestic container using INTERCITY_CONTAINER fallback', async () => {
|
||||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||||
const booking = {
|
||||
id: 'b-1-usd',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].currency).toBe('USD');
|
||||
expect(result.lineItems[0].amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-2',
|
||||
freightType: 'CONTAINER',
|
||||
@@ -83,12 +116,14 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
@@ -40,6 +41,7 @@ export class BookingPricingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
private readonly cbeExchangeService: CbeExchangeService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -80,6 +82,10 @@ export class BookingPricingService {
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
@@ -95,14 +101,16 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
amount: convertedAmount,
|
||||
currency: paymentCurrency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += mod.calculatedAmount;
|
||||
total += convertedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
@@ -263,7 +271,9 @@ export class BookingPricingService {
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = booking.paymentCurrency;
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
const rateType =
|
||||
@@ -284,34 +294,36 @@ export class BookingPricingService {
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: rate.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const amount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: fallback.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -65,6 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CbeExchangeService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class CbeExchangeService {
|
||||
private readonly logger = new Logger(CbeExchangeService.name);
|
||||
private cachedRate: number | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Returns the current CBE USD→ETB exchange rate.
|
||||
* Fetches live from CBE_EXCHANGE_API_URL, caches for CBE_EXCHANGE_CACHE_TTL_MS,
|
||||
* and falls back to CBE_EXCHANGE_FALLBACK_RATE when the API is unreachable.
|
||||
*/
|
||||
async getUsdToEtbRate(): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
const apiUrl = this.configService.get<string>('app.cbeExchange.apiUrl') ?? '';
|
||||
const fallbackRate = this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
|
||||
const cacheTtlMs = this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
|
||||
|
||||
if (!apiUrl) {
|
||||
this.logger.warn(
|
||||
`CBE_EXCHANGE_API_URL not configured — using fallback rate ${fallbackRate} ETB/USD`,
|
||||
);
|
||||
return fallbackRate;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`CBE API responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const rate = this.parseRate(json);
|
||||
|
||||
if (!rate || !Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Invalid rate value parsed from CBE API response: ${rate}`);
|
||||
}
|
||||
|
||||
this.cachedRate = rate;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.logger.log(`CBE USD→ETB rate refreshed: ${rate}`);
|
||||
return rate;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to fetch CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
|
||||
);
|
||||
|
||||
if (this.cachedRate !== null) {
|
||||
this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`);
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
return fallbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the USD→ETB selling rate from the CBE API JSON response.
|
||||
* CBE API typically returns an array of currency objects.
|
||||
* Adjust this method if the API shape differs.
|
||||
*
|
||||
* Expected shape (one common format):
|
||||
* [ { currency: "USD", selling: "130.50", ... }, ... ]
|
||||
*/
|
||||
private parseRate(json: unknown): number | null {
|
||||
if (Array.isArray(json)) {
|
||||
const usdEntry = json.find(
|
||||
(entry: unknown) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(
|
||||
(entry as Record<string, unknown>)['currency'] === 'USD' ||
|
||||
(entry as Record<string, unknown>)['Currency'] === 'USD'
|
||||
),
|
||||
) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!usdEntry) return null;
|
||||
|
||||
const selling =
|
||||
usdEntry['selling'] ??
|
||||
usdEntry['Selling'] ??
|
||||
usdEntry['sellingRate'] ??
|
||||
usdEntry['rate'] ??
|
||||
usdEntry['Rate'];
|
||||
|
||||
return selling !== undefined ? Number(selling) : null;
|
||||
}
|
||||
|
||||
if (typeof json === 'object' && json !== null) {
|
||||
const obj = json as Record<string, unknown>;
|
||||
const selling =
|
||||
obj['selling'] ??
|
||||
obj['Selling'] ??
|
||||
obj['sellingRate'] ??
|
||||
obj['usdToEtb'] ??
|
||||
obj['rate'];
|
||||
return selling !== undefined ? Number(selling) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
@@ -28,10 +27,10 @@ export class CreateLocomotiveDto {
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' })
|
||||
@ApiPropertyOptional({ description: 'Current yard location' })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
@@ -18,8 +17,8 @@ export class FilterLocomotivesDto {
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES })
|
||||
@ApiPropertyOptional({ description: 'Filter by current yard' })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
@@ -13,20 +13,13 @@ export const LOCOMOTIVE_STATUSES = [
|
||||
|
||||
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||
|
||||
/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */
|
||||
export const LOCOMOTIVE_READINESS_VALUES = [
|
||||
WagonReadiness.ImportReady,
|
||||
WagonReadiness.ExportReady,
|
||||
] as const;
|
||||
|
||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@Index(['status'])
|
||||
@Index(['readiness'])
|
||||
@Index(['currentYardId'])
|
||||
export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
@@ -46,8 +39,12 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
|
||||
readiness!: LocomotiveReadiness;
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
@@ -3,11 +3,9 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveReadiness,
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
@@ -24,8 +22,9 @@ export class LocomotivesService {
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}),
|
||||
...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
|
||||
},
|
||||
relations: { currentYard: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -42,7 +41,7 @@ export class LocomotivesService {
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
readiness: (dto.readiness as LocomotiveReadiness) ?? WagonReadiness.ImportReady,
|
||||
currentYardId: dto.currentYardId ?? null,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
@@ -52,7 +51,9 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Locomotive> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
const locomotive = await this.locomotivesRepository.findById(id, {
|
||||
relations: { currentYard: true },
|
||||
});
|
||||
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
@@ -76,10 +77,10 @@ export class LocomotivesService {
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
readiness:
|
||||
dto.readiness === undefined
|
||||
? locomotive.readiness
|
||||
: (dto.readiness as LocomotiveReadiness),
|
||||
currentYardId:
|
||||
dto.currentYardId === undefined
|
||||
? locomotive.currentYardId
|
||||
: (dto.currentYardId ?? null),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength,
|
||||
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const CURRENCIES = ['USD'] as const;
|
||||
|
||||
export class CreateRateDto {
|
||||
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
|
||||
|
||||
@@ -51,7 +51,7 @@ export class RatesService {
|
||||
rateType: dto.rateType as Rate['rateType'],
|
||||
containerTypeId: dto.containerTypeId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
currency: dto.currency,
|
||||
currency: dto.currency ?? 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||
status: 'DRAFT',
|
||||
@@ -71,7 +71,7 @@ export class RatesService {
|
||||
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
|
||||
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
|
||||
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
|
||||
if (dto.currency) updates.currency = dto.currency;
|
||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
||||
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignUnassignedBookingDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableLocomotivesQueryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' })
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to filter locomotives at the origin yard' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
|
||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.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';
|
||||
@@ -79,7 +80,7 @@ export class TrainSchedulingController {
|
||||
@Get('available-locomotives')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: 'List AVAILABLE locomotives filtered by route corridor readiness',
|
||||
summary: 'List AVAILABLE locomotives at the route origin yard',
|
||||
})
|
||||
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
|
||||
@@ -213,6 +214,18 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getUnassignedBookings(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/assign-unassigned-booking')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)',
|
||||
})
|
||||
assignUnassignedBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignUnassignedBookingDto,
|
||||
) {
|
||||
return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId);
|
||||
}
|
||||
|
||||
@Get('schedules/:id/composition-removals')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get removal log for a schedule' })
|
||||
@@ -315,7 +328,7 @@ export class TrainSchedulingController {
|
||||
|
||||
@Post('schedules/:id/arrive')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' })
|
||||
@ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' })
|
||||
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.arriveSchedule(id);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { 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 { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
|
||||
const nw5 = {
|
||||
@@ -25,7 +26,7 @@ const locomotive = {
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
};
|
||||
|
||||
const cw3 = {
|
||||
@@ -96,6 +97,7 @@ describe('TrainSchedulingService', () => {
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
findByIdsForScheduling: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateSchedulingFields: jest.fn(),
|
||||
};
|
||||
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
|
||||
@@ -152,14 +154,14 @@ describe('TrainSchedulingService', () => {
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
...Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `wagon-cw3-${index}`,
|
||||
wagonTypeId: cw3.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
];
|
||||
@@ -193,7 +195,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: `wagon-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
@@ -534,14 +536,14 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects pin when wagon readiness does not match schedule direction', async () => {
|
||||
it('rejects pin when wagon is not at the schedule origin yard', async () => {
|
||||
const scheduleId = 'sched-1';
|
||||
const slotId = 'slot-1';
|
||||
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
direction: 'IMPORT',
|
||||
originStationId: 'yard-origin',
|
||||
trainSet: {
|
||||
wagons: [{ id: slotId, physicalWagonId: null }],
|
||||
},
|
||||
@@ -555,7 +557,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: 'wagon-1',
|
||||
wagonNumber: 'WGN-001',
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ExportReady,
|
||||
currentYardId: 'yard-other',
|
||||
currentTrainScheduleId: null,
|
||||
}),
|
||||
update: jest.fn(),
|
||||
@@ -578,7 +580,7 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
|
||||
it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
|
||||
const exportBooking = makeBooking(
|
||||
'exp-1',
|
||||
'BKG-EXP',
|
||||
@@ -598,14 +600,16 @@ describe('TrainSchedulingService', () => {
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ ...locomotive, currentYardId: 'yard-addis' },
|
||||
]);
|
||||
|
||||
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
wagonNumber: `WGN-${index}`,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-djibouti',
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
@@ -614,7 +618,7 @@ describe('TrainSchedulingService', () => {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
|
||||
return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
@@ -631,7 +635,7 @@ describe('TrainSchedulingService', () => {
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
|
||||
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -723,14 +727,160 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('getUnassignedBookings', () => {
|
||||
const scheduleId = 'sched-unassigned-1';
|
||||
const trainSetId = 'train-set-unassigned';
|
||||
const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
|
||||
const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);
|
||||
|
||||
const buildScheduleGraph = () => ({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
trainSet: {
|
||||
id: trainSetId,
|
||||
locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
|
||||
wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
bookingsRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
...assignedBooking,
|
||||
trainScheduleId: scheduleId,
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
},
|
||||
{
|
||||
...unassignedBooking,
|
||||
trainScheduleId: scheduleId,
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
|
||||
});
|
||||
|
||||
it('allows assign when train slots are full but origin yard has matching wagons', async () => {
|
||||
const yardFleet = [
|
||||
{
|
||||
id: 'wagon-pinned',
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: scheduleId,
|
||||
},
|
||||
...Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `wagon-yard-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
];
|
||||
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||||
const map = new Map([
|
||||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||||
]);
|
||||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.getUnassignedBookings(scheduleId);
|
||||
|
||||
expect(result.bookings).toHaveLength(1);
|
||||
expect(result.bookings[0].id).toBe(unassignedBooking.id);
|
||||
expect(result.bookings[0].canAssign).toBe(true);
|
||||
expect(result.bookings[0].blockReason).toBeNull();
|
||||
expect(
|
||||
result.fleetAtOrigin.some(
|
||||
(row: { wagonTypeCode: string; available: number }) =>
|
||||
row.wagonTypeCode === 'NW5' && row.available >= 2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks assign when origin yard lacks wagons of the required type', async () => {
|
||||
const yardFleet = [
|
||||
{
|
||||
id: 'wagon-pinned',
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: scheduleId,
|
||||
},
|
||||
];
|
||||
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||||
const map = new Map([
|
||||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||||
]);
|
||||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.getUnassignedBookings(scheduleId);
|
||||
|
||||
expect(result.bookings).toHaveLength(1);
|
||||
expect(result.bookings[0].canAssign).toBe(false);
|
||||
expect(result.bookings[0].blockReason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
|
||||
it('returns locomotives at the route origin yard', async () => {
|
||||
const routeId = 'route-export';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
}),
|
||||
@@ -740,23 +890,28 @@ describe('TrainSchedulingService', () => {
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
});
|
||||
|
||||
it('returns all available locomotives on domestic routes', async () => {
|
||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
}),
|
||||
@@ -766,8 +921,8 @@ describe('TrainSchedulingService', () => {
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
selectBookingsWithinFleetCap,
|
||||
summarizeFleetWarnings,
|
||||
totalAssignedWeight,
|
||||
wagonsRequiredForBooking,
|
||||
type DeferredBookingRow,
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
@@ -78,11 +79,6 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import {
|
||||
flipReadiness,
|
||||
requiredWagonReadiness,
|
||||
wagonReadinessMatchesSchedule,
|
||||
} from './wagon-readiness.util';
|
||||
import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
@@ -124,6 +120,26 @@ export interface WagonAllocationAttemptResult {
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface CompositionUnassignedBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
freightType: string | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
status: string | null;
|
||||
schedulingStatus: string | null;
|
||||
wagonsRequired: number;
|
||||
requiredWagonTypeCode: string;
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
}
|
||||
|
||||
export interface UnassignedBookingsResponse {
|
||||
fleetAtOrigin: FleetAvailabilityRow[];
|
||||
bookings: CompositionUnassignedBookingRow[];
|
||||
}
|
||||
|
||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
@@ -273,9 +289,9 @@ export class TrainSchedulingService {
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) {
|
||||
if (lockedLocomotive.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`,
|
||||
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,7 +478,7 @@ export class TrainSchedulingService {
|
||||
await this.autoPinWagonsForSchedule(
|
||||
manager,
|
||||
scheduleId,
|
||||
schedule.direction ?? null,
|
||||
schedule.originStationId,
|
||||
savedWagons,
|
||||
);
|
||||
});
|
||||
@@ -584,9 +600,9 @@ export class TrainSchedulingService {
|
||||
`Wagon ${physicalWagon.wagonNumber} is not available`,
|
||||
);
|
||||
}
|
||||
if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) {
|
||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`,
|
||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,8 +862,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a dispatched train arrived: close out the schedule, flip readiness on the
|
||||
* locomotive + wagons (they have repositioned), and free the assets for re-use.
|
||||
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
||||
* and wagons to the destination yard, and free the assets for re-use.
|
||||
*/
|
||||
async arriveSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
@@ -858,7 +874,6 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||||
}
|
||||
|
||||
const isDomestic = schedule.direction === 'DOMESTIC';
|
||||
const now = new Date();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -882,7 +897,7 @@ export class TrainSchedulingService {
|
||||
if (loco) {
|
||||
await manager.getRepository(Locomotive).update(loco.id, {
|
||||
status: 'AVAILABLE',
|
||||
readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness),
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -897,7 +912,7 @@ export class TrainSchedulingService {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: WagonStatus.Available,
|
||||
readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness),
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1047,11 +1062,15 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (
|
||||
bookings.some(
|
||||
(b) =>
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
b.originYardId !== dto.originStationId ||
|
||||
b.destinationYardId !== dto.destinationStationId,
|
||||
)
|
||||
b.destinationYardId !== dto.destinationStationId
|
||||
);
|
||||
})
|
||||
) {
|
||||
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
||||
}
|
||||
@@ -1102,8 +1121,8 @@ export class TrainSchedulingService {
|
||||
: buildBulkWagonPlan(bookings, wagonType);
|
||||
}
|
||||
|
||||
const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings);
|
||||
const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId);
|
||||
const originYardId = dto.originStationId;
|
||||
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
|
||||
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
||||
fleetAvailability = computeFleetAvailability(
|
||||
demandPlan,
|
||||
@@ -1132,7 +1151,7 @@ export class TrainSchedulingService {
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
wagonPlan,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
targetScheduleId,
|
||||
)),
|
||||
);
|
||||
@@ -1189,26 +1208,43 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
|
||||
if (!availableLocomotives.length) {
|
||||
const readinessHint = requiredWagonReadiness(scheduleDirection);
|
||||
violations.push(
|
||||
readinessHint
|
||||
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule`
|
||||
: 'No available locomotive exists for scheduling',
|
||||
);
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
)
|
||||
) {
|
||||
violations.push('No available locomotive can support the total train weight and length');
|
||||
let assignedLocomotive: Locomotive | null = null;
|
||||
if (targetScheduleId) {
|
||||
const targetSchedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||||
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
|
||||
}
|
||||
|
||||
if (assignedLocomotive) {
|
||||
if (assignedLocomotive.currentYardId !== originYardId) {
|
||||
violations.push(
|
||||
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
|
||||
);
|
||||
} else if (
|
||||
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
|
||||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
|
||||
) {
|
||||
violations.push(
|
||||
'Assigned locomotive cannot support the total train weight and length',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => l.currentYardId === originYardId);
|
||||
if (!availableLocomotives.length) {
|
||||
violations.push('No available locomotive at the schedule origin yard');
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
)
|
||||
) {
|
||||
violations.push('No available locomotive can support the total train weight and length');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1353,26 +1389,8 @@ export class TrainSchedulingService {
|
||||
];
|
||||
}
|
||||
|
||||
private async resolveScheduleDirection(
|
||||
targetScheduleId: string | undefined,
|
||||
bookings: Booking[],
|
||||
): Promise<string | null> {
|
||||
if (targetScheduleId) {
|
||||
const schedule = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (schedule?.direction) return schedule.direction;
|
||||
}
|
||||
|
||||
const booking = bookings[0];
|
||||
if (!booking) return null;
|
||||
|
||||
return deriveScheduleDirection(
|
||||
booking.originYard ?? { country: null },
|
||||
booking.destinationYard ?? { country: null },
|
||||
);
|
||||
}
|
||||
|
||||
private async countFleetAvailability(
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||
const [wagons, wagonTypes] = await Promise.all([
|
||||
@@ -1387,7 +1405,7 @@ export class TrainSchedulingService {
|
||||
? wagon.currentTrainScheduleId === targetScheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
|
||||
if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue;
|
||||
if (wagon.currentYardId !== originYardId) continue;
|
||||
|
||||
const typeId = wagon.wagonTypeId;
|
||||
const code = typeCodeById.get(typeId) ?? typeId;
|
||||
@@ -1418,7 +1436,7 @@ export class TrainSchedulingService {
|
||||
private async autoPinWagonsForSchedule(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
slots: TrainSetWagon[],
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
@@ -1438,7 +1456,7 @@ export class TrainSchedulingService {
|
||||
planSlots,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
@@ -1453,7 +1471,7 @@ export class TrainSchedulingService {
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
@@ -1474,7 +1492,7 @@ export class TrainSchedulingService {
|
||||
/** Pre-assign check: every planned slot must have a matching physical wagon. */
|
||||
private async validatePhysicalFleetForPlan(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<string[]> {
|
||||
if (!wagonPlan.length) return [];
|
||||
@@ -1488,7 +1506,7 @@ export class TrainSchedulingService {
|
||||
})),
|
||||
wagons,
|
||||
targetScheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1496,24 +1514,22 @@ export class TrainSchedulingService {
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
const required = requiredWagonReadiness(scheduleDirection);
|
||||
const readinessLabel = required ?? 'any readiness';
|
||||
|
||||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||||
const physical = this.pickPhysicalWagonForSlot(
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
|
||||
`No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1527,7 +1543,7 @@ export class TrainSchedulingService {
|
||||
slot: { wagonTypeId: string },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
): Wagon | undefined {
|
||||
return wagons.find((wagon) => {
|
||||
@@ -1537,7 +1553,7 @@ export class TrainSchedulingService {
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
|
||||
return wagon.currentYardId === originYardId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1853,7 +1869,7 @@ export class TrainSchedulingService {
|
||||
id: schedule.trainSet.locomotive.id,
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name ?? null,
|
||||
readiness: schedule.trainSet.locomotive.readiness ?? null,
|
||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||
}
|
||||
: null,
|
||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||
@@ -1871,47 +1887,80 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
|
||||
/** AVAILABLE locomotives at the route's origin yard. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
const requiredReadiness = requiredWagonReadiness(direction);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
|
||||
if (!requiredReadiness) {
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
|
||||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||||
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
||||
* returns schedules whose route passes through both yards in the correct order.
|
||||
*/
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
...(originYardId ? { originStationId: originYardId } : {}),
|
||||
...(destinationYardId ? { destinationStationId: destinationYardId } : {}),
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
route: true,
|
||||
route: { milestones: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
return schedules
|
||||
|
||||
const filteredSchedules = schedules
|
||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||
.filter((s) => {
|
||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||
const milestones = s.route?.milestones ?? [];
|
||||
const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
|
||||
const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];
|
||||
|
||||
// Remove duplicates while preserving order (in case origin/destination appears in milestones)
|
||||
const uniqueStopYardIds: string[] = [];
|
||||
for (const yardId of stopYardIds) {
|
||||
if (!uniqueStopYardIds.includes(yardId)) {
|
||||
uniqueStopYardIds.push(yardId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check origin yard filter
|
||||
if (originYardId) {
|
||||
if (!uniqueStopYardIds.includes(originYardId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check destination yard filter
|
||||
if (destinationYardId) {
|
||||
if (!uniqueStopYardIds.includes(destinationYardId)) {
|
||||
return false;
|
||||
}
|
||||
// Ensure destination comes after origin (if both are specified)
|
||||
if (originYardId) {
|
||||
const originIndex = uniqueStopYardIds.indexOf(originYardId);
|
||||
const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
|
||||
if (destIndex <= originIndex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((s) => this.mapScheduleListItem(s));
|
||||
|
||||
return filteredSchedules;
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
@@ -1971,7 +2020,7 @@ export class TrainSchedulingService {
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name,
|
||||
status: schedule.trainSet.locomotive.status,
|
||||
readiness: schedule.trainSet.locomotive.readiness ?? null,
|
||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||
maxPullWeightTons: roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||
),
|
||||
@@ -2050,6 +2099,102 @@ export class TrainSchedulingService {
|
||||
return SchedulingStatus.Eligible;
|
||||
}
|
||||
|
||||
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
|
||||
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign bookings to schedule in status ${schedule.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not linked to this schedule');
|
||||
}
|
||||
if (!this.isReadyToLoadBooking(booking)) {
|
||||
throw new BadRequestException('Booking is not paid and ready to load');
|
||||
}
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.has(bookingId)) {
|
||||
throw new BadRequestException('Booking is already assigned to a wagon');
|
||||
}
|
||||
|
||||
const allBookingIds = [...wagonAssignedIds, bookingId];
|
||||
const previewDto = {
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
|
||||
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
false,
|
||||
limits,
|
||||
scheduleId,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException({
|
||||
message: 'Booking validation failed',
|
||||
violations: validation.violations,
|
||||
warnings: validation.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
if (!validation.bookings.some((b) => b.id === bookingId)) {
|
||||
const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
|
||||
throw new BadRequestException({
|
||||
message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
|
||||
violations: validation.violations,
|
||||
warnings: validation.warnings,
|
||||
deferredBookings: validation.deferredBookings,
|
||||
});
|
||||
}
|
||||
|
||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
|
||||
(m) => m.bookingId === bookingId,
|
||||
);
|
||||
if (missingForBooking) {
|
||||
throw new BadRequestException({
|
||||
message: missingForBooking.issue,
|
||||
violations: [missingForBooking.issue],
|
||||
});
|
||||
}
|
||||
|
||||
const assignableSet = new Set(validation.bookings.map((b) => b.id));
|
||||
const assignPlacements = placementsForBookings(placements, assignableSet, units);
|
||||
const needsPlacements = containerBookings.length > 0;
|
||||
|
||||
return this.assignBookingsToSchedule(
|
||||
scheduleId,
|
||||
{
|
||||
bookingIds: validation.bookings.map((b) => b.id),
|
||||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
@@ -2331,7 +2476,7 @@ export class TrainSchedulingService {
|
||||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||||
}
|
||||
|
||||
async getUnassignedBookings(scheduleId: string): Promise<any[]> {
|
||||
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -2339,13 +2484,213 @@ export class TrainSchedulingService {
|
||||
|
||||
const allBookings = await this.bookingsRepository.findAll({
|
||||
where: { trainScheduleId: scheduleId },
|
||||
select: ['id', 'reference', 'freightType', 'priorityScore', 'cargoTotalWeightVgm', 'status', 'schedulingStatus'],
|
||||
select: [
|
||||
'id',
|
||||
'reference',
|
||||
'freightType',
|
||||
'priorityScore',
|
||||
'cargoTotalWeightVgm',
|
||||
'status',
|
||||
'schedulingStatus',
|
||||
'paymentStatus',
|
||||
'isGovernment',
|
||||
],
|
||||
});
|
||||
|
||||
const allocatedBookingIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
|
||||
const unassigned = allBookings.filter((b: any) => !allocatedBookingIds.has(b.id));
|
||||
return unassigned.sort((a: any, b: any) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
|
||||
const unassigned = allBookings
|
||||
.filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b))
|
||||
.sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
|
||||
|
||||
const fleetCounts = await this.countFleetAvailability(
|
||||
schedule.originStationId,
|
||||
scheduleId,
|
||||
);
|
||||
const fleetByTypeId = new Map(
|
||||
fleetCounts.map((row) => [
|
||||
row.wagonTypeId,
|
||||
{ code: row.wagonTypeCode, available: row.available },
|
||||
]),
|
||||
);
|
||||
const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({
|
||||
wagonTypeId: row.wagonTypeId,
|
||||
wagonTypeCode: row.wagonTypeCode,
|
||||
needed: 0,
|
||||
available: row.available,
|
||||
shortfall: 0,
|
||||
}));
|
||||
|
||||
const bookings = await Promise.all(
|
||||
unassigned.map(async (b) => {
|
||||
const assignability = await this.previewUnassignedBookingAssignability(
|
||||
schedule,
|
||||
wagonAssignedIds,
|
||||
b as Booking,
|
||||
fleetByTypeId,
|
||||
);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? null,
|
||||
freightType: b.freightType ?? null,
|
||||
priorityScore: b.priorityScore ?? 0,
|
||||
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
|
||||
status: b.status ?? null,
|
||||
schedulingStatus: b.schedulingStatus ?? null,
|
||||
...assignability,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { fleetAtOrigin, bookings };
|
||||
}
|
||||
|
||||
private async previewUnassignedBookingAssignability(
|
||||
schedule: TrainSchedule,
|
||||
wagonAssignedIds: Set<string>,
|
||||
booking: Booking,
|
||||
fleetByTypeId: Map<string, { code: string; available: number }>,
|
||||
): Promise<{
|
||||
wagonsRequired: number;
|
||||
requiredWagonTypeCode: string;
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
}> {
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
return {
|
||||
wagonsRequired: 0,
|
||||
requiredWagonTypeCode: '',
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'Schedule has no locomotive',
|
||||
};
|
||||
}
|
||||
|
||||
const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
|
||||
let wagonType: WagonType;
|
||||
try {
|
||||
wagonType = await this.resolveWagonType(freightType, [booking.id]);
|
||||
} catch {
|
||||
return {
|
||||
wagonsRequired: 0,
|
||||
requiredWagonTypeCode: '',
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'No suitable wagon type found',
|
||||
};
|
||||
}
|
||||
|
||||
const bulkCapacity =
|
||||
freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined;
|
||||
const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
|
||||
const resolvedBooking = fullBooking ?? booking;
|
||||
const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
|
||||
const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0;
|
||||
|
||||
const allBookingIds = [...wagonAssignedIds, booking.id];
|
||||
const previewDto = {
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
try {
|
||||
validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
false,
|
||||
limits,
|
||||
schedule.id,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
wagonsRequired,
|
||||
requiredWagonTypeCode: wagonType.code,
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: err instanceof Error ? err.message : 'Validation failed',
|
||||
};
|
||||
}
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
wagonsRequired,
|
||||
requiredWagonTypeCode: wagonType.code,
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: validation.violations[0] ?? 'Booking validation failed',
|
||||
};
|
||||
}
|
||||
|
||||
const fittingIds = new Set(validation.bookings.map((b) => b.id));
|
||||
if (!fittingIds.has(booking.id)) {
|
||||
const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
|
||||
const yardShortfall =
|
||||
yardWagonsAvailable < wagonsRequired
|
||||
? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
|
||||
: null;
|
||||
return {
|
||||
wagonsRequired,
|
||||
requiredWagonTypeCode: wagonType.code,
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason:
|
||||
deferred?.reason ??
|
||||
yardShortfall ??
|
||||
`Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`,
|
||||
};
|
||||
}
|
||||
|
||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
if (containerBookings.some((b) => b.id === booking.id)) {
|
||||
const units = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
const missing = findMissingContainerNumberIssues(units, placements).find(
|
||||
(m) => m.bookingId === booking.id,
|
||||
);
|
||||
if (missing) {
|
||||
return {
|
||||
wagonsRequired,
|
||||
requiredWagonTypeCode: wagonType.code,
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: missing.issue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wagonsRequired,
|
||||
requiredWagonTypeCode: wagonType.code,
|
||||
yardWagonsAvailable,
|
||||
canAssign: true,
|
||||
blockReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
|
||||
private isReadyToLoadBooking(booking: {
|
||||
status: string;
|
||||
paymentStatus?: string | null;
|
||||
isGovernment?: boolean;
|
||||
}): boolean {
|
||||
if (booking.status === 'EXPIRED') return false;
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
return false;
|
||||
}
|
||||
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
|
||||
if (booking.isGovernment) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async getCompositionRemovals(scheduleId: string): Promise<any[]> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */
|
||||
export function requiredWagonReadiness(
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
): WagonReadiness | null {
|
||||
@@ -8,6 +9,7 @@ export function requiredWagonReadiness(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */
|
||||
export function wagonReadinessMatchesSchedule(
|
||||
wagonReadiness: WagonReadiness | string,
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
@@ -18,9 +20,7 @@ export function wagonReadinessMatchesSchedule(
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train
|
||||
* reaches its destination: the asset has repositioned, so it is now ready for
|
||||
* the opposite direction. Direction-agnostic so it handles round trips.
|
||||
* @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival.
|
||||
*/
|
||||
export function flipReadiness(
|
||||
readiness: WagonReadiness | string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
|
||||
|
||||
export class CreateWagonDto {
|
||||
@@ -30,8 +30,8 @@ export class CreateWagonDto {
|
||||
status?: WagonStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WagonReadiness)
|
||||
readiness?: WagonReadiness;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
@@ -14,10 +14,10 @@ export class ListWagonsQueryDto {
|
||||
@IsEnum(WagonStatus)
|
||||
status?: WagonStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: WagonReadiness })
|
||||
@ApiPropertyOptional({ description: 'Filter by current yard' })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonReadiness)
|
||||
readiness?: WagonReadiness;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const WAGON_STATUSES = [
|
||||
WagonStatus.Available,
|
||||
@@ -14,16 +15,10 @@ export const WAGON_STATUSES = [
|
||||
WagonStatus.Retired,
|
||||
] as const;
|
||||
|
||||
export const WAGON_READINESS_VALUES = [
|
||||
WagonReadiness.ImportReady,
|
||||
WagonReadiness.ExportReady,
|
||||
] as const;
|
||||
|
||||
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||
export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number];
|
||||
|
||||
@Entity({ name: 'wagons', schema: 'freight' })
|
||||
@Index(['readiness'])
|
||||
@Index(['currentYardId'])
|
||||
export class Wagon extends BaseEntity {
|
||||
@Column({ unique: true, name: 'wagon_number' })
|
||||
wagonNumber!: string;
|
||||
@@ -46,8 +41,12 @@ export class Wagon extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||
status!: WagonStatusType;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
|
||||
readiness!: WagonReadinessType;
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||
@@ -24,11 +24,11 @@ export class WagonsService {
|
||||
const wagon = this.wagonRepo.create({
|
||||
...dto,
|
||||
status: dto.status ?? WagonStatus.Available,
|
||||
readiness: dto.readiness ?? WagonReadiness.ImportReady,
|
||||
});
|
||||
// Convert undefined to null for nullable fields
|
||||
if (dto.trainId === undefined) wagon.trainId = null;
|
||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
||||
if (dto.currentYardId === undefined) wagon.currentYardId = null;
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class WagonsService {
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
const filters: FindOptionsWhere<Wagon> = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.readiness ? { readiness: query.readiness } : {}),
|
||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
||||
...(trainId ? { trainId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
};
|
||||
@@ -51,13 +51,14 @@ export class WagonsService {
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'readiness', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||
? (query.sortBy as keyof Wagon)
|
||||
: 'wagonNumber';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
return this.wagonRepo.find({
|
||||
where: search ? where : filters,
|
||||
relations: { currentYard: true },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
@@ -65,7 +66,10 @@ export class WagonsService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id } });
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
relations: { currentYard: true },
|
||||
});
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
|
||||
return wagon;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user