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:
marshal
2026-06-14 01:32:39 +03:00
parent b73bf2154e
commit 87b0ce6339
45 changed files with 1249 additions and 520 deletions

View File

@@ -14,4 +14,9 @@ export default registerAs("app", () => ({
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
},
cbeExchange: {
apiUrl: process.env.CBE_EXCHANGE_API_URL ?? "",
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
},
}));

View File

@@ -37,7 +37,7 @@ export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationI
{
name: 'removed_at',
type: 'timestamptz',
default: () => 'NOW()',
default: 'NOW()',
isNullable: false,
},
{
@@ -48,13 +48,13 @@ export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationI
{
name: 'created_at',
type: 'timestamptz',
default: () => 'NOW()',
default: 'NOW()',
isNullable: false,
},
{
name: 'updated_at',
type: 'timestamptz',
default: () => 'NOW()',
default: 'NOW()',
isNullable: false,
},
{

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface {
name = 'WagonLocomotiveYardLink1782000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_wagon_current_yard'
) THEN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagon_current_yard"
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_wagon_current_yard_id"
ON freight.wagons ("current_yard_id");
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS readiness;`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_locomotive_current_yard'
) THEN
ALTER TABLE freight.locomotives
ADD CONSTRAINT "FK_locomotive_current_yard"
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_locomotive_current_yard_id"
ON freight.locomotives ("current_yard_id");
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS readiness;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
ON freight.wagons (readiness)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
ON freight.locomotives (readiness)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagon_current_yard_id"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_locomotive_current_yard_id"`);
await queryRunner.query(`
ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS "FK_wagon_current_yard";
`);
await queryRunner.query(`
ALTER TABLE freight.locomotives DROP CONSTRAINT IF EXISTS "FK_locomotive_current_yard";
`);
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS "current_yard_id";`);
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS "current_yard_id";`);
}
}

View File

@@ -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');
});
});

View File

@@ -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,
});
}
}

View File

@@ -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],
})

View File

@@ -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;
}
}

View File

@@ -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))

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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:

View File

@@ -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' })

View File

@@ -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);

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AssignUnassignedBookingDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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[]> {

View File

@@ -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,

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -19,7 +19,7 @@ import { Container } from "../modules/container-management/entities/container.en
import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Wagon } from "../modules/wagons/entities/wagon.entity";
import { WagonReadiness, WagonStatus } from "@edr/types";
import { WagonStatus } from "@edr/types";
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
@@ -499,7 +499,7 @@ export class DemoBookingsSeeder {
}
const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" });
if (nw5) {
if (nw5 && djibouti && addis) {
await manager.getRepository(Wagon).upsert(
Array.from({ length: 20 }, (_, index) => ({
wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`,
@@ -509,10 +509,7 @@ export class DemoBookingsSeeder {
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Available,
readiness:
index % 2 === 0
? WagonReadiness.ImportReady
: WagonReadiness.ExportReady,
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
notes: "Demo wagon for train scheduling",
trainSetWagonId: null,
currentTrainScheduleId: null,
@@ -521,6 +518,19 @@ export class DemoBookingsSeeder {
);
}
if (djibouti) {
await manager.getRepository(Locomotive).update(
{ code: "LOC-001" },
{ currentYardId: djibouti.id },
);
}
if (addis) {
await manager.getRepository(Locomotive).update(
{ code: "LOC-002" },
{ currentYardId: addis.id },
);
}
const ft20 = containerTypeByCode.get("20FT");
const ft40 = containerTypeByCode.get("40FT");
if (ft20 && ft40) {

View File

@@ -430,20 +430,6 @@ export class PricingDataSeeder {
rateValue: 1200,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 45000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 67000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
@@ -458,34 +444,6 @@ export class PricingDataSeeder {
rateValue: 900,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 34000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 50000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 20000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 30000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
@@ -493,13 +451,6 @@ export class PricingDataSeeder {
rateValue: 1000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 56000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
@@ -508,17 +459,24 @@ export class PricingDataSeeder {
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 42000,
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "USD",
rateValue: 350,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 550,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: null,
currency: "ETB",
rateValue: 25000,
currency: "USD",
rateValue: 400,
rateUnit: "PER_CONTAINER",
},
{
@@ -528,13 +486,6 @@ export class PricingDataSeeder {
rateValue: 35,
rateUnit: "PER_TON",
},
{
rateType: "INTERCITY_BULK",
containerTypeId: null,
currency: "ETB",
rateValue: 1900,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
@@ -542,13 +493,6 @@ export class PricingDataSeeder {
rateValue: 50,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
@@ -556,13 +500,6 @@ export class PricingDataSeeder {
rateValue: 40,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2200,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
@@ -570,13 +507,6 @@ export class PricingDataSeeder {
rateValue: 25,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
currency: "ETB",
rateValue: 1400,
rateUnit: "PER_TON",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
@@ -584,13 +514,6 @@ export class PricingDataSeeder {
rateValue: 150,
rateUnit: "FLAT",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 8500,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
@@ -598,13 +521,6 @@ export class PricingDataSeeder {
rateValue: 200,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 11000,
rateUnit: "FLAT",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
@@ -612,13 +528,6 @@ export class PricingDataSeeder {
rateValue: 100,
rateUnit: "PER_CONTAINER",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
currency: "ETB",
rateValue: 5500,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
@@ -626,13 +535,6 @@ export class PricingDataSeeder {
rateValue: 50,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_CONTAINER",
},
];
const entities = rateData.map((d) =>
@@ -662,15 +564,10 @@ export class PricingDataSeeder {
};
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
const consolidRateUsd = findRate("LASHING", "USD");
const consolidRateEtb = findRate("LASHING", "ETB");
await surRepo.createQueryBuilder().delete().execute();
await surRepo.save([
@@ -678,35 +575,35 @@ export class PricingDataSeeder {
code: "HAZARDOUS_CARGO",
label: "Hazardous Cargo",
triggerCondition: "CARGO_FLAG_HAZARDOUS",
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
rateId: hazardRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "REEFER_CARGO",
label: "Reefer Cargo",
triggerCondition: "CARGO_FLAG_REEFER",
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
rateId: reeferRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "OVERWEIGHT_CARGO",
label: "Overweight Cargo",
triggerCondition: "VGM_EXCEEDS_LIMIT",
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
rateId: overweightRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "SHIPPING_LINE_FEE",
label: "Shipping Line Fee",
triggerCondition: "SHIPPING_LINE_MAPPED",
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
rateId: shipLineRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "CONSOLIDATION_FEE",
label: "Consolidation Fee",
triggerCondition: "CONSOLIDATION_ENABLED",
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
rateId: consolidRateUsd?.id,
isActive: true,
}),
]);
@@ -773,10 +670,10 @@ export class PricingDataSeeder {
},
{
reference: "BKG-PRICE-003",
description: "20FT container import + shipping line (ETB)",
description: "20FT container import + shipping line (USD)",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
@@ -788,7 +685,7 @@ export class PricingDataSeeder {
containers: [
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
],
expectedBaseRate: 45000,
expectedBaseRate: 800,
expectedSurcharges: ["SHIPPING_LINE_FEE"],
},
{

View File

@@ -111,7 +111,10 @@ const FleetCardGrid = ({
</Text>
{subtitle != null && subtitle !== "" ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{String(subtitle)}
{presentation.subtitleKey === "currentYard" ||
presentation.subtitleKey === "currentYardId"
? formatFleetCell(subtitle, "entityLabel", presentation.subtitleKey)
: String(subtitle)}
</Text>
) : null}
</Stack>

View File

@@ -522,9 +522,7 @@ export function AllocateBookingWizard({
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}

View File

@@ -18,7 +18,7 @@ import { Freight } from "@edr/types";
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
import type { Wagon } from "@/services/wagon.service";
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
@@ -35,6 +35,7 @@ export function PinWagonsForm({
onSubmit: (assignments: PinWagonAssignment[]) => void;
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? [];
const [assignments, setAssignments] = useState<Record<string, string>>({});
@@ -43,7 +44,7 @@ export function PinWagonsForm({
for (const wagon of availableWagons) {
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
if (
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
!wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinnedOnSlot,
})
) {
@@ -58,7 +59,7 @@ export function PinWagonsForm({
map.set(typeId, list);
}
return map;
}, [availableWagons, schedule.direction, slots]);
}, [availableWagons, originYardId, slots]);
const runAutoFill = useCallback(
(preserveManual = false) => {

View File

@@ -50,30 +50,20 @@ export const CompositionBookingTabs = ({
const unassignedQuery = useUnassignedBookings(scheduleId);
const removalsQuery = useCompositionRemovals(scheduleId);
const { assignedCount, freeWagons, freeWeightTons } = useMemo(() => {
const { assignedCount } = useMemo(() => {
const wagons = scheduleDetail.trainSet?.wagons ?? [];
const ids = new Set<string>();
let usedWeight = 0;
let empty = 0;
for (const w of wagons) {
const allocs = w.allocations ?? [];
if (allocs.length === 0) empty += 1;
for (const a of allocs) {
for (const a of w.allocations ?? []) {
ids.add(a.bookingId);
usedWeight += a.allocatedWeightTons ?? 0;
}
}
const maxWeight = scheduleDetail.trainSet?.locomotive?.maxPullWeightTons ?? null;
return {
assignedCount: ids.size,
freeWagons: empty,
freeWeightTons: maxWeight != null ? Math.max(0, maxWeight - usedWeight) : null,
};
}, [scheduleDetail.trainSet?.wagons, scheduleDetail.trainSet?.locomotive?.maxPullWeightTons]);
return { assignedCount: ids.size };
}, [scheduleDetail.trainSet?.wagons]);
const counts: Record<TabKey, number> = {
assigned: assignedCount,
unassigned: unassignedQuery.data?.length ?? 0,
unassigned: unassignedQuery.data?.bookings?.length ?? 0,
payment: awaitingPayment.length,
expired: expired.length,
removed: removalsQuery.data?.length ?? 0,
@@ -188,8 +178,6 @@ export const CompositionBookingTabs = ({
scheduleId={scheduleId}
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
freeWagons={freeWagons}
freeWeightTons={freeWeightTons}
/>
</Tabs.Panel>

View File

@@ -1,25 +1,26 @@
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { AlertTriangle, Container as ContainerIcon, Plus, TrainFront, Weight } from "lucide-react";
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
import {
useUnassignedBookings,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
import type { BookingDetailData } from "./BookingDetailModal";
interface UnassignedBookingsPanelProps {
scheduleId: string;
selectedBookingId?: string | null;
onSelect: (booking: BookingDetailData) => void;
/** Empty wagon slots currently available on the train. */
freeWagons: number;
/** Remaining pull-weight headroom in tons, or null when no locomotive limit. */
freeWeightTons: number | null;
}
const parseError = (error: unknown): string | null => {
if (error && typeof error === "object" && "response" in error) {
const resp = (error as { response?: { data?: { message?: unknown } } }).response;
const resp = (error as {
response?: { data?: { message?: unknown; violations?: string[] } };
}).response;
const violations = resp?.data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join("; ");
const msg = resp?.data?.message;
if (Array.isArray(msg)) return msg.join(", ");
if (typeof msg === "string") return msg;
@@ -27,29 +28,65 @@ const parseError = (error: unknown): string | null => {
return null;
};
const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRow[] }) => {
if (!fleetAtOrigin.length) {
return (
<Group gap={5} wrap="nowrap" px="sm" py={6} style={{ borderRadius: 8, background: "var(--mantine-color-red-0)", border: "1px solid var(--mantine-color-red-2)" }}>
<MapPin size={13} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={700} c="red.7">
No wagons at origin yard
</Text>
</Group>
);
}
return (
<Group
gap="xs"
wrap="wrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
</Group>
);
};
export const UnassignedBookingsPanel = ({
scheduleId,
selectedBookingId,
onSelect,
freeWagons,
freeWeightTons,
}: UnassignedBookingsPanelProps) => {
const { toast } = useToast();
const unassignedQuery = useUnassignedBookings(scheduleId);
const assignMutation = useScheduleMutations(scheduleId).assign;
const assignMutation = useScheduleMutations(scheduleId).assignUnassigned;
const handleAssign = async (bookingId: string, reference: string | null) => {
try {
await assignMutation.mutateAsync({
id: scheduleId,
payload: { bookingIds: [bookingId] },
bookingId,
});
toast({ title: `Assigned ${reference ?? "booking"} to the train` });
} catch (err) {
toast({
title: "Could not assign booking",
description:
parseError(err) ?? "No free wagon or not enough space for this booking.",
description: parseError(err) ?? "Assignment failed — check yard fleet and train limits.",
variant: "destructive",
});
}
@@ -63,7 +100,8 @@ export const UnassignedBookingsPanel = ({
);
}
const bookings = unassignedQuery.data ?? [];
const bookings = unassignedQuery.data?.bookings ?? [];
const fleetAtOrigin = unassignedQuery.data?.fleetAtOrigin ?? [];
if (bookings.length === 0) {
return (
@@ -81,53 +119,15 @@ export const UnassignedBookingsPanel = ({
);
}
const noFreeWagon = freeWagons <= 0;
return (
<Stack gap="xs">
{/* Capacity availability banner */}
<Group
justify="space-between"
wrap="nowrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: noFreeWagon ? "var(--mantine-color-red-0)" : "var(--mantine-color-green-0)",
border: `1px solid ${
noFreeWagon ? "var(--mantine-color-red-2)" : "var(--mantine-color-green-1)"
}`,
}}
>
<Group gap={5} wrap="nowrap">
<TrainFront
size={13}
color={noFreeWagon ? "var(--mantine-color-red-6)" : "var(--mantine-color-green-7)"}
/>
<Text size="xs" fw={700} c={noFreeWagon ? "red.7" : "green.8"}>
{freeWagons} free wagon{freeWagons === 1 ? "" : "s"}
</Text>
</Group>
{freeWeightTons != null ? (
<Group gap={5} wrap="nowrap">
<Weight size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{freeWeightTons.toFixed(1)} T headroom
</Text>
</Group>
) : null}
</Group>
<YardFleetBanner fleetAtOrigin={fleetAtOrigin} />
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = booking.cargoTotalWeightVgm ?? 0;
const overWeight = freeWeightTons != null && weight > freeWeightTons;
const fits = !noFreeWagon && !overWeight;
const blockReason = noFreeWagon
? "No free wagon on this train"
: overWeight
? "Exceeds remaining weight headroom"
: null;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;
return (
<Card
@@ -141,7 +141,7 @@ export const UnassignedBookingsPanel = ({
reference: booking.reference,
company: null,
freightType: booking.freightType,
weightTons: booking.cargoTotalWeightVgm ?? null,
weightTons: Number.isFinite(weight) ? weight : null,
status: booking.status,
priorityScore: booking.priorityScore,
})
@@ -172,9 +172,9 @@ export const UnassignedBookingsPanel = ({
{booking.freightType}
</Badge>
<Group gap={3} wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-6)" />
<TrainFront size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed">
{weight.toFixed(1)} T
{booking.wagonsRequired}× {booking.requiredWagonTypeCode}
</Text>
</Group>
</Group>
@@ -202,7 +202,7 @@ export const UnassignedBookingsPanel = ({
}}
loading={
assignMutation.isPending &&
assignMutation.variables?.payload.bookingIds?.[0] === booking.id
assignMutation.variables?.bookingId === booking.id
}
leftSection={<Plus size={12} />}
>

View File

@@ -1,10 +1,11 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { Plus } from "lucide-react";
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
import { Freight } from "@edr/types";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
export function AssignWagonDialog({ trainId }: { trainId: string }) {
@@ -12,6 +13,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [wagonId, setWagonId] = useState<string | null>(null);
const [sequence, setSequence] = useState<number | "">("");
const { data: wagons } = useWagons();
const { data: yards = [] } = useRouteYards();
const assign = useAssignWagonToTrain();
const { toast } = useToast();
@@ -19,9 +21,19 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
);
const yardLabelById = useMemo(
() =>
new Map(
yards.map((y) => [y.id, y.label ?? y.code ?? y.id]),
),
[yards],
);
const wagonOptions = available.map((w) => ({
value: w.id,
label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`,
label: w.currentYardId
? `${w.wagonNumber} (${yardLabelById.get(w.currentYardId) ?? "yard"})`
: `${w.wagonNumber} (no yard)`,
}));
const handleAssign = async () => {

View File

@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/batch-board/${scheduleId}`,
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,

View File

@@ -197,6 +197,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
onSuccess: invalidate,
});
const assignUnassigned = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.assignUnassignedBooking(id, bookingId),
onSuccess: invalidate,
});
const unassign = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.unassignBooking(id, bookingId),
@@ -240,6 +246,7 @@ export const useScheduleMutations = (scheduleId?: string) => {
create,
preview,
assign,
assignUnassigned,
unassign,
pin,
finalize,

View File

@@ -16,6 +16,7 @@ import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import {
@@ -49,12 +50,12 @@ const FleetResourcePage = () => {
if (slug !== "wagons" && slug !== "locomotives") return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const readiness = listFilterValues.readiness;
const currentYardId = listFilterValues.currentYardId;
if (status && status !== "ALL") {
filters.status = status as FleetListFilters["status"];
(filters as { status?: string }).status = status;
}
if (readiness && readiness !== "ALL") {
filters.readiness = readiness as FleetListFilters["readiness"];
if (currentYardId && currentYardId !== "ALL") {
filters.currentYardId = currentYardId;
}
if (slug === "wagons" && search.trim()) {
filters.search = search.trim();
@@ -70,6 +71,7 @@ const FleetResourcePage = () => {
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -98,18 +100,6 @@ const FleetResourcePage = () => {
];
}, [allRows, hasStatusColumn, usesServerListFilters]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => ({
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...filter.options.map((opt) => ({ value: opt.value, label: opt.label })),
],
}));
}, [config?.listFilters, listFilterValues]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
@@ -128,14 +118,41 @@ const FleetResourcePage = () => {
(c) => ({ value: c.id, label: c.containerNumber }),
);
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers]);
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => {
const dynamicOpts = filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: [];
const staticOpts =
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
return {
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...opts,
],
};
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -146,6 +163,7 @@ const FleetResourcePage = () => {
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -158,7 +176,12 @@ const FleetResourcePage = () => {
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading;
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
@@ -222,7 +245,7 @@ const FleetResourcePage = () => {
});
return base;
}, [config]);
}, [config, dynamicOptions.yards]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";

View File

@@ -1,5 +1,4 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
@@ -20,7 +19,8 @@ export type FleetDynamicOptions =
| "containerTypes"
| "cargoTypes"
| "wagons"
| "containers";
| "containers"
| "yards";
export interface FleetResourceColumn {
id: string;
@@ -35,10 +35,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
}
export interface FleetListFilterDef {
key: "status" | "readiness" | "wagonTypeId" | "trainId";
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
label: string;
options: Array<{ value: string; label: string }>;
options?: Array<{ value: string; label: string }>;
allLabel?: string;
dynamicOptions?: FleetDynamicOptions;
}
export interface FleetResourceConfig {
@@ -93,10 +94,6 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
const WAGON_READINESS_OPTIONS = [
{ label: "Import ready", value: Freight.WagonReadiness.ImportReady },
{ label: "Export ready", value: Freight.WagonReadiness.ExportReady },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
@@ -122,19 +119,19 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{
key: "readiness",
label: "Readiness",
allLabel: "All readiness",
options: WAGON_READINESS_OPTIONS,
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardSubtitleKey: "readiness",
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
cardSubtitleKey: "currentYard",
searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
@@ -144,7 +141,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" },
@@ -156,7 +153,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
readiness: Freight.WagonReadiness.ImportReady,
currentYardId: "",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
@@ -225,20 +222,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
options: WAGON_STATUS_OPTIONS,
},
{
key: "readiness",
label: "Readiness",
allLabel: "All readiness",
options: WAGON_READINESS_OPTIONS,
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "readiness",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
@@ -246,7 +243,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
@@ -255,7 +252,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
readiness: Freight.WagonReadiness.ImportReady,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",
},

View File

@@ -296,9 +296,12 @@ const RuleEngineResourcePage = () => {
};
const handleFormSubmit = (values: Record<string, unknown>) => {
const payload =
config.slug === "rates" ? { ...values, currency: "USD" } : values;
if (editing?.id) {
update.mutate(
{ id: editing.id, payload: values },
{ id: editing.id, payload },
{
onSuccess: () => {
setFormOpen(false);
@@ -307,7 +310,7 @@ const RuleEngineResourcePage = () => {
},
);
} else {
create.mutate(values, {
create.mutate(payload, {
onSuccess: () => {
setFormOpen(false);
setEditing(null);

View File

@@ -110,10 +110,7 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m
value: v,
}));
const CURRENCIES = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
const CURRENCIES = [{ label: "USD", value: "USD" }];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
id: key,
@@ -430,7 +427,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
options: TRADE_DIRECTIONS,
},
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },

View File

@@ -90,7 +90,7 @@ export default function TrainScheduleTrackPage() {
onSuccess: () => {
toast({
title: isFinal
? "Train arrived — assets freed, readiness flipped"
? "Train arrived — assets freed, moved to destination yard"
: "Checkpoint logged",
});
},

View File

@@ -785,11 +785,11 @@ export default function TrainScheduleV2DetailPage() {
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
hint={
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
? "Export-ready"
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
? "Import-ready"
: undefined
schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set"
}
accent="#F2A516"
graph="area"

View File

@@ -89,15 +89,13 @@ export default function TrainScheduleV2ListPage() {
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveReadinessHint = useMemo(() => {
const locomotiveYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const origin = selectedRoute.originYard?.country?.trim();
const dest = selectedRoute.destinationYard?.country?.trim();
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only";
if (dest === "Djibouti" && origin !== "Djibouti") {
return "Export corridor — export-ready locomotives only";
}
return "Domestic corridor — any readiness";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
return `Only locomotives currently at ${originLabel} are shown`;
}, [selectedRoute]);
useEffect(() => {
@@ -525,7 +523,7 @@ export default function TrainScheduleV2ListPage() {
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveReadinessHint}
{locomotiveYardHint}
</Text>
) : null}
<TextInput
@@ -542,9 +540,7 @@ export default function TrainScheduleV2ListPage() {
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}

View File

@@ -1,5 +1,3 @@
import type { Freight } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -13,7 +11,7 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
readiness?: Freight.WagonReadiness;
currentYardId?: string;
}
export interface Locomotive {
@@ -22,7 +20,8 @@ export interface Locomotive {
name?: string | null;
locomotiveType: LocomotiveType;
status: LocomotiveStatus;
readiness: Freight.WagonReadiness;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
powerKw?: number | null;
@@ -41,7 +40,7 @@ export const locomotivesService = {
getAll: (filters: LocomotiveListFilters = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -8,6 +8,7 @@ import type {
AssignBookingsPayload,
CompositionRemovalEntry,
CompositionUnassignedBooking,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
@@ -162,6 +163,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
assignUnassignedBooking: async (
scheduleId: string,
bookingId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ASSIGN_UNASSIGNED_BOOKING(scheduleId),
{ bookingId },
);
return unwrap(response.data);
},
assignBookings: async (
scheduleId: string,
payload: AssignBookingsPayload,
@@ -374,8 +386,8 @@ export const trainSchedulingService = {
getUnassignedBookings: async (
scheduleId: string,
): Promise<CompositionUnassignedBooking[]> => {
const response = await client.get<CompositionUnassignedBooking[]>(
): Promise<UnassignedBookingsResponse> => {
const response = await client.get<UnassignedBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.UNASSIGNED_BOOKINGS(scheduleId),
);
return unwrap(response.data);

View File

@@ -11,14 +11,15 @@ export interface Wagon {
tareWeight: number;
maxPayloadWeight: number;
status: Freight.WagonStatus;
readiness: Freight.WagonReadiness;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
notes?: string;
}
export interface WagonListFilters {
search?: string;
status?: Freight.WagonStatus;
readiness?: Freight.WagonReadiness;
currentYardId?: string;
wagonTypeId?: string;
trainId?: string;
}
@@ -28,7 +29,7 @@ export const wagonService = {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
const qs = params.toString();

View File

@@ -127,8 +127,6 @@ export interface TrainSchedulePreviewResponse {
containerSlotSequenceNos?: number[];
}
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
export interface LocomotiveRecord {
id: string;
code: string;
@@ -136,7 +134,7 @@ export interface LocomotiveRecord {
maxPullWeightTons: number;
maxTrainLengthMeters: number;
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
readiness?: Readiness | null;
currentYardId?: string | null;
locomotiveType?: "DIESEL" | "ELECTRIC";
}
@@ -153,7 +151,7 @@ export interface TrainScheduleListItem {
id: string;
code: string;
name?: string | null;
readiness?: Readiness | null;
currentYardId?: string | null;
}
| null;
wagonCount: number;
@@ -352,7 +350,7 @@ export interface TrainScheduleDetail {
code: string;
name?: string | null;
status: string;
readiness?: Readiness | null;
currentYardId?: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
@@ -496,6 +494,16 @@ export interface CompositionUnassignedBooking {
cargoTotalWeightVgm: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
}
export interface UnassignedBookingsResponse {
fleetAtOrigin: FleetAvailabilityRow[];
bookings: CompositionUnassignedBooking[];
}
export interface CompositionRemovalEntry {

View File

@@ -2,31 +2,30 @@ import { Freight } from "@edr/types";
import type { Wagon } from "@/services/wagon.service";
export function wagonMatchesScheduleDirection(
wagon: Pick<Wagon, "status" | "readiness">,
scheduleDirection?: string | null,
/** Check if a wagon is available for a schedule based on its current yard.
* A wagon is eligible if:
* 1. It is available (or pinned to this schedule)
* 2. It is physically located at the schedule's origin yard
*/
export function wagonMatchesScheduleOrigin(
wagon: Pick<Wagon, "id" | "status" | "currentYardId">,
originYardId?: string | null,
options?: { allowPinned?: boolean },
): boolean {
if (options?.allowPinned) return true;
if (wagon.status !== Freight.WagonStatus.Available) return false;
if (!scheduleDirection || scheduleDirection === "DOMESTIC") return true;
if (scheduleDirection === "IMPORT") {
return wagon.readiness === Freight.WagonReadiness.ImportReady;
}
if (scheduleDirection === "EXPORT") {
return wagon.readiness === Freight.WagonReadiness.ExportReady;
}
return true;
if (!originYardId) return true;
return wagon.currentYardId === originYardId;
}
export function filterWagonsForSchedule(
wagons: Wagon[],
scheduleDirection?: string | null,
originYardId?: string | null,
pinnedWagonIds?: Set<string>,
): Wagon[] {
return wagons.filter((wagon) => {
const isPinned = pinnedWagonIds?.has(wagon.id) ?? false;
return wagonMatchesScheduleDirection(wagon, scheduleDirection, {
return wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinned,
});
});