auto allocation and batch managemnt, tracking the train

This commit is contained in:
marshal
2026-06-12 11:42:46 +03:00
parent 8618ea2aa8
commit ef0abf1c41
61 changed files with 3541 additions and 378 deletions

View File

@@ -14,7 +14,8 @@
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts"
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
},
"dependencies": {
"@edr/api-common": "workspace:*",

View File

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

View File

@@ -0,0 +1,20 @@
import type { ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */
export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
if (originCountry === 'Djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface {
name = 'AddSelectedForBatchStatus1781000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL
`);
await queryRunner.query(`
UPDATE freight.bookings
SET
status = 'SELECTED_FOR_BATCH',
selected_for_batch_at = COALESCE(
payment_deadline - INTERVAL '5 minutes',
updated_at
)
WHERE status = 'AWAITING_PAYMENT'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.bookings
SET status = 'AWAITING_PAYMENT'
WHERE status = 'SELECTED_FOR_BATCH'
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS selected_for_batch_at
`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings).
*/
export class AddDomesticWeightLimitTradeDirection1781000000004
implements MigrationInterface
{
name = 'AddDomesticWeightLimitTradeDirection1781000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN undefined_object THEN
BEGIN
ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
EXCEPTION
WHEN duplicate_object THEN NULL;
END;
END $$;
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values safely.
}
}

View File

@@ -1,5 +1,7 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -20,6 +22,7 @@ import { assertBookingStatus } from './booking-status.util';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
@Injectable()
export class BookingContractService {
@@ -33,6 +36,8 @@ export class BookingContractService {
private readonly viewModelBuilder: ContractViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) {}
buildContractSummary(booking: Booking): string {
@@ -203,6 +208,9 @@ export class BookingContractService {
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
if (role === 'STAFF' && updated?.trainScheduleId) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {
await this.upsertContractPdf(
bookingId,

View File

@@ -22,7 +22,7 @@ export class BookingPaymentService {
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'AWAITING_PAYMENT', '']);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {

View File

@@ -0,0 +1,94 @@
import { BookingPricingService } from './booking-pricing.service';
import type { Booking } from './entities/booking.entity';
import type { Rate } from '../rule-engine/entities/rate.entity';
describe('BookingPricingService — domestic corridor', () => {
const intercityBulkEtb: Rate = {
id: 'rate-intercity-bulk-etb',
rateType: 'INTERCITY_BULK',
currency: 'ETB',
rateValue: 1900,
rateUnit: 'PER_TON',
status: 'LIVE',
containerTypeId: null,
} as Rate;
const intercityContainerEtb: Rate = {
id: 'rate-intercity-container-etb',
rateType: 'INTERCITY_CONTAINER',
currency: 'ETB',
rateValue: 25000,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: null,
} as Rate;
let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock };
beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]),
};
service = new BookingPricingService(
bookingsRepository as never,
{} as never,
{} as never,
ratesService as never,
{} as never,
);
});
it('prices domestic bulk using INTERCITY_BULK and cargo tons', async () => {
const booking = {
id: 'b-1',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
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 }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
expect(result.lineItems[0].amount).toBe(1900 * 120);
});
it('prices domestic container using INTERCITY_CONTAINER fallback', async () => {
const booking = {
id: 'b-2',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 50,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
});
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
});
});

View File

@@ -275,6 +275,8 @@ export class BookingPricingService {
? isBulk
? 'BULK_EXPORT'
: 'CONTAINER_EXPORT'
: isBulk
? 'INTERCITY_BULK'
: 'INTERCITY_CONTAINER';
const lines: PriceLineItemDto[] = [];
@@ -301,7 +303,10 @@ export class BookingPricingService {
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const amount = this.amountForRate(fallback, 1, wagonCount);
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);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// import { CustomersModule } from '../customers/customers.module';
@@ -29,6 +29,7 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@Module({
imports: [
@@ -42,6 +43,7 @@ import { PaymentModule } from '../payment/payment.module';
BookingContractSignature,
]),
PaymentModule,
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
CompaniesModule,

View File

@@ -720,6 +720,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
findBatchPool(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
@@ -748,13 +749,33 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status = 'AWAITING_PAYMENT'`)
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.getMany();
}
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(
TrainScheduleBooking,
'scheduleBooking',
'scheduleBooking.booking_id = booking.id',
)
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status = 'PAID'`)
.andWhere('scheduleBooking.id IS NULL')
.orderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}

View File

@@ -15,7 +15,10 @@ import {
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -54,6 +57,36 @@ export class BookingsService {
private readonly consolidationService: ConsolidationService,
) {}
/** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
provided?: string,
): Promise<string> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
const origin = yards.find((y) => y.id === originYardId);
const destination = yards.find((y) => y.id === destinationYardId);
if (!origin) {
throw new BadRequestException(`Origin yard ${originYardId} not found`);
}
if (!destination) {
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
}
if (originYardId === destinationYardId) {
throw new BadRequestException('Origin and destination yards must differ');
}
const expected = deriveTradeDirection(origin, destination);
if (provided && provided !== expected) {
throw new BadRequestException(
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
);
}
return expected;
}
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
@@ -230,6 +263,12 @@ export class BookingsService {
containers,
});
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
@@ -240,7 +279,7 @@ export class BookingsService {
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
@@ -267,7 +306,7 @@ export class BookingsService {
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
tradeDirection,
freightType: dto.freightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
@@ -362,6 +401,14 @@ export class BookingsService {
assertFreightShape({ freightType, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
const tradeDirection = await this.resolveTradeDirectionForBooking(
originYardId,
destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
@@ -375,7 +422,7 @@ export class BookingsService {
cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
@@ -401,6 +448,7 @@ export class BookingsService {
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);

View File

@@ -26,6 +26,8 @@ export const BOOKING_STATUSES = [
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'EXPIRED',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
@@ -278,10 +280,14 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/** End of the 1h pay window once the booking is AWAITING_PAYMENT. */
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
paymentDeadline?: Date | null;
/** When the batch engine picked this booking and opened the pay window. */
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
selectedForBatchAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];

View File

@@ -2,7 +2,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
import {
LOCOMOTIVE_READINESS_VALUES,
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class CreateLocomotiveDto {
@ApiProperty({ example: 'LOCO-001' })
@@ -24,6 +28,11 @@ export class CreateLocomotiveDto {
@IsIn([...LOCOMOTIVE_STATUSES])
status!: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' })
@IsOptional()
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
readiness?: string;
@ApiProperty({ example: 3500 })
@Transform(({ value }) => Number(value))
@IsNumber()

View File

@@ -1,7 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
import {
LOCOMOTIVE_READINESS_VALUES,
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@@ -13,4 +17,9 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType?: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES })
@IsOptional()
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
readiness?: string;
}

View File

@@ -3,7 +3,14 @@ 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 { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { WagonReadiness } from '@edr/types';
import {
Locomotive,
type LocomotiveReadiness,
type LocomotiveStatus,
type LocomotiveType,
} from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
@@ -17,6 +24,7 @@ export class LocomotivesService {
...(filter.locomotiveType
? { locomotiveType: filter.locomotiveType as LocomotiveType }
: {}),
...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}),
},
order: { code: 'ASC' },
});
@@ -34,6 +42,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,
maxPullWeightTons: dto.maxPullWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
powerKw: dto.powerKw ?? null,
@@ -67,6 +76,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),
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
tractionForceKn:

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
@@ -7,9 +7,10 @@ import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { TelebirrProvider } from "@edr/payment-providers";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
@Module({
imports: [HttpModule, ConfigModule],
imports: [HttpModule, ConfigModule, forwardRef(() => TrainSchedulingModule)],
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController],
exports: [PaymentService]

View File

@@ -1,5 +1,7 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
InternalServerErrorException,
NotFoundException,
@@ -23,6 +25,7 @@ import {
} from "@edr/payment-providers";
import { ProviderInitiationInput } from "@edr/types"
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
const DEFAULT_CURRENCY = "ETB";
@@ -33,6 +36,8 @@ export class PaymentService {
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) { }
async initBookingTelebirr(
@@ -125,15 +130,12 @@ export class PaymentService {
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
await this.datasource.transaction(async (mg) => {
const booking = await mg.findOne(Booking, { where: { id: resp.refId } })
if (booking?.status === "AWAITING_PAYMENT") {
// Batch flow: mark paid but keep the reservation — the batch settle job allocates it.
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
} else {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
}
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
})
if (resp.type === "booking") {
await this.bookingBatchService.ensurePaidBookingAllocated(resp.refId)
}
}
return {
status: result.status

View File

@@ -1,9 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
import { Booking } from '../../../bookings/entities/booking.entity';
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
import { BookingBatchService } from '../../../train-scheduling/booking-batch.service';
@Injectable()
export class TelebirrWebhookService {
@@ -13,6 +14,8 @@ export class TelebirrWebhookService {
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
@@ -40,6 +43,7 @@ export class TelebirrWebhookService {
{ id: payment.refId },
{ paymentStatus: "PAID" },
);
await this.bookingBatchService.ensurePaidBookingAllocated(payment.refId);
}
break;
case ProviderPaymentStatus.FAILED:

View File

@@ -2,14 +2,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
export class CreateWeightLimitRuleDto {
@ApiProperty({ description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' })
@ApiProperty({
enum: TRADE_DIRECTIONS,
description: 'Trade direction: IMPORT, EXPORT, BOTH, or DOMESTIC',
})
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;

View File

@@ -0,0 +1,52 @@
import {
getBatchWindowForTimestamp,
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
} from './batch-window.util';
describe('batch-window.util', () => {
it('maps 20:15 EAT to the 19:0022:00 window', () => {
// 20:15 EAT = 17:15 UTC on 11 Jun 2026
const ts = new Date('2026-06-11T17:15:00.000Z');
const window = getBatchWindowForTimestamp(ts);
expect(window.label).toContain('19:00');
expect(window.label).toContain('22:00');
expect(window.label).toContain('11 Jun 2026');
});
it('maps 08:30 EAT to the 07:0010:00 window', () => {
const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT
const window = getBatchWindowForTimestamp(ts);
expect(window.label).toContain('07:00');
expect(window.label).toContain('10:00');
});
it('maps 02:00 EAT to the previous day 22:0007:00 window', () => {
const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun
const window = getBatchWindowForTimestamp(ts);
expect(window.label).toContain('22:00');
expect(window.label).toContain('07:00');
expect(window.label).toContain('11 Jun 2026');
});
it('lists six windows for a calendar day', () => {
const ref = new Date('2026-06-11T12:00:00.000Z');
const windows = listBatchWindowsForDate(ref);
expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length);
expect(windows[0].label).toContain('07:00');
expect(windows[windows.length - 1].label).toContain('22:00');
});
it('includes cross-day overnight window when booking signed at 00:02 EAT', () => {
// 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:0007:00 window
const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z');
const scheduleDate = new Date('2026-06-12T06:00:00.000Z');
const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate);
const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00'));
expect(overnight).toBeDefined();
expect(overnight!.label).toContain('11 Jun 2026');
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
});
});

View File

@@ -0,0 +1,192 @@
import { BATCH_TIMEZONE } from './booking-batch.constants';
/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;
export interface BatchWindow {
key: string;
label: string;
start: Date;
end: Date;
}
type EatDateParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
};
const dateFmt = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
timeZone: BATCH_TIMEZONE,
});
const timeFmt = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: BATCH_TIMEZONE,
});
function eatParts(date: Date): EatDateParts {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: BATCH_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((p) => p.type === type)?.value ?? 0);
return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute'),
};
}
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
year: number,
month: number,
day: number,
hour: number,
minute = 0,
): Date {
// EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
// for Africa/Addis_Ababa the offset is fixed.
const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
return new Date(utcMs);
}
function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
return `${dateFmt.format(start)} · ${timeFmt.format(start)} ${endTime} EAT`;
}
function windowFromEatStart(
year: number,
month: number,
day: number,
startHour: number,
): BatchWindow {
const start = eatToUtc(year, month, day, startHour);
let endYear = year;
let endMonth = month;
let endDay = day;
let endHour: number;
let endHourLabel: string;
const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
endHour = 7;
endHourLabel = '07:00';
const next = new Date(eatToUtc(year, month, day, 0));
next.setUTCDate(next.getUTCDate() + 1);
const nextParts = eatParts(next);
endYear = nextParts.year;
endMonth = nextParts.month;
endDay = nextParts.day;
} else {
endHour = BATCH_WINDOW_START_HOURS[idx + 1];
endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
}
const end = eatToUtc(endYear, endMonth, endDay, endHour);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end, endHourLabel),
};
}
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
const { year, month, day, hour } = eatParts(date);
if (hour < 7) {
const prev = new Date(eatToUtc(year, month, day, 0));
prev.setUTCDate(prev.getUTCDate() - 1);
const prevParts = eatParts(prev);
return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
}
let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
for (const h of BATCH_WINDOW_START_HOURS) {
if (hour >= h) startHour = h;
}
return windowFromEatStart(year, month, day, startHour);
}
/** All six intake windows for an EAT calendar day (includes overnight 22:0007:00). */
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
const { year, month, day } = eatParts(reference);
return BATCH_WINDOW_START_HOURS.map((startHour) =>
windowFromEatStart(year, month, day, startHour),
);
}
export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
return a.start.getTime() - b.start.getTime();
}
/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
export function listBatchWindowsForBookings(
timestamps: Array<Date | null | undefined>,
referenceDate: Date,
): BatchWindow[] {
const byKey = new Map<string, BatchWindow>();
for (const w of listBatchWindowsForDate(referenceDate)) {
byKey.set(w.key, w);
}
for (const ts of timestamps) {
if (!ts) continue;
const w = getBatchWindowForTimestamp(ts);
byKey.set(w.key, w);
}
return [...byKey.values()].sort(compareBatchWindows);
}
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
export function groupByBatchWindow<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
referenceDate: Date,
pendingKey = 'pending-contract',
): Map<string, { window: BatchWindow | null; items: T[] }> {
const timestamps = items.map(getTimestamp);
const windows = listBatchWindowsForBookings(timestamps, referenceDate);
const map = new Map<string, { window: BatchWindow | null; items: T[] }>();
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
const w = getBatchWindowForTimestamp(ts);
if (!map.has(w.key)) {
map.set(w.key, { window: w, items: [] });
}
map.get(w.key)!.items.push(item);
}
return map;
}

View File

@@ -4,14 +4,15 @@
*/
/** Batch boundaries — every 3h from 07:00 (the 07:0010:00 intake settles at 10:00, etc.). */
export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
// export const BATCH_CRON = '*/3 * * * *';
export const BATCH_CRON = '*/5 * * * *';
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
/** How long a selected commercial customer has to pay before their slot expires. */
export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;
@@ -22,3 +23,9 @@ export const DEFAULT_WAGONS_PER_BOOKING = 1;
* against the locomotive's max train length.
*/
export const DEFAULT_WAGON_LENGTH_METERS = 14;
/** Default NW5 flat wagon length for container bookings (m). */
export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
/** Default CW3 covered wagon length for bulk bookings (m). */
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;

View File

@@ -0,0 +1,144 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
const bookingId = 'booking-1';
const paidBooking = {
id: bookingId,
reference: 'BK-2026-000034',
trainScheduleId: scheduleId,
status: 'PAID',
paymentStatus: 'PAID',
isGovernment: false,
cargoTotalWeightVgm: 20,
bookingContainers: [],
} as unknown as Booking;
let service: BookingBatchService;
let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock;
findReservedForSchedule: jest.Mock;
update: jest.Mock;
};
let trainScheduleBookingsRepository: {
existsForBooking: jest.Mock;
createMany: jest.Mock;
};
let trainSchedulesRepository: {
findByIdWithFullGraph: jest.Mock;
findAll: jest.Mock;
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
beforeEach(() => {
bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
trainScheduleBookingsRepository = {
existsForBooking: jest.fn().mockResolvedValue(false),
createMany: jest.fn().mockResolvedValue(undefined),
};
trainSchedulesRepository = {
findByIdWithFullGraph: jest.fn().mockResolvedValue({
id: scheduleId,
maxWagons: 10,
bookingWindowStatus: 'OPEN',
trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } },
scheduleBookings: [],
}),
findAll: jest.fn().mockResolvedValue([]),
};
trainSchedulingService = {
tryAutoWagonAllocation: jest.fn().mockResolvedValue({
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
}),
};
const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined),
};
dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo),
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
const manager = {
getRepository: () => bookingRepo,
};
await fn(manager);
}),
};
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
);
});
it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => {
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]);
await service.reconcilePaidUnlinked(scheduleId);
expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId);
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
[{ trainScheduleId: scheduleId, bookingId }],
expect.anything(),
);
});
it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => {
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1);
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
});
it('ensurePaidBookingAllocated is idempotent when already linked', async () => {
trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true);
await service.ensurePaidBookingAllocated(bookingId);
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
});
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
await service.processSchedule(scheduleId);
expect(fillSpy).toHaveBeenCalledWith(scheduleId);
expect(settleSpy).toHaveBeenCalledWith(scheduleId);
expect(reconcileSpy).toHaveBeenCalledWith(scheduleId);
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
const fillOrder = fillSpy.mock.invocationCallOrder[0];
const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0];
const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0];
expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
});

View File

@@ -18,13 +18,24 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import {
groupByBatchWindow,
} from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
DEFAULT_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from './booking-batch.constants';
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -33,9 +44,12 @@ interface Capacity {
lengthMeters: number;
}
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
| 'ALLOCATED'
| 'AWAITING_PAYMENT'
| 'SELECTED_FOR_BATCH'
| 'READY'
| 'WAITING'
| 'PENDING_CONTRACT'
| 'EXPIRED';
@@ -47,10 +61,57 @@ export interface BatchBoardBooking {
isGovernment: boolean;
wagons: number;
weightTons: number;
lengthMeters: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
}
export type BookingAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
allocationIssue: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
bookings: BatchBoardBookingDetail[];
}
export interface BatchBoardScheduleDetail {
scheduleId: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: BatchBoardSchedule['locomotive'];
capacity: BatchBoardSchedule['capacity'];
counts: BatchBoardSchedule['counts'];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
}
export interface BatchBoardSchedule {
scheduleId: string;
trainNumber: string | null;
@@ -67,15 +128,19 @@ export interface BatchBoardSchedule {
maxTrainLengthMeters: number;
} | null;
capacity: {
maxWagons: number;
usedWagons: number;
remainingWagons: number;
/** Wagons on bookings already linked to the train (ALLOCATED only). */
allocatedWagons: number;
/** Train length used by allocated bookings (from wagon-type dimensions). */
allocatedLengthMeters: number;
maxLengthMeters: number | null;
/** Weight committed on the train (allocated + selected-for-batch). */
usedWeightTons: number;
maxWeightTons: number | null;
};
counts: {
allocated: number;
awaitingPayment: number;
selectedForBatch: number;
ready: number;
waiting: number;
pendingContract: number;
expired: number;
@@ -103,20 +168,121 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** On boot, re-arm a settle timeout for any schedule that still has live reservations. */
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
for (const s of open) {
try {
await this.processSchedule(s.id);
} catch (err) {
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
}
}
const reserved = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.where(`b.status = 'AWAITING_PAYMENT'`)
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.train_schedule_id IS NOT NULL')
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
enqueueScheduleProcessing(scheduleId: string): void {
void this.processSchedule(scheduleId).catch((err) =>
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
);
}
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId);
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
*/
async ensurePaidBookingAllocated(bookingId: string): Promise<void> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { company: true },
});
if (!booking?.trainScheduleId) return;
const isBatchPaid =
booking.status === 'SELECTED_FOR_BATCH' ||
booking.status === 'AWAITING_PAYMENT' ||
booking.status === 'PAID' ||
booking.paymentStatus === 'PAID';
if (!isBatchPaid) return;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
} else if (booking.paymentStatus !== 'PAID') {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
}
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
await this.allocate(booking.trainScheduleId, booking, 'paid');
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
booking.trainScheduleId,
);
if (result.assignedBookingIds.length) {
this.logger.log(
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
);
}
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
);
}
}
/** Customer paid — delegate to ensurePaidBookingAllocated. */
async confirmPaidAndAllocate(bookingId: string): Promise<void> {
await this.ensurePaidBookingAllocated(bookingId);
}
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
await this.allocate(scheduleId, booking, 'paid');
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
}
}
// ---- cron entry point -----------------------------------------------------
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
@@ -127,7 +293,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
for (const s of open) {
try {
await this.fillSchedule(s.id);
await this.processSchedule(s.id);
} catch (err) {
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
}
@@ -152,8 +318,7 @@ export class BookingBatchService implements OnModuleInit {
order: { scheduledDepartureDate: 'ASC' },
});
const rules = await this.loadGlobalRules();
const perWagonLength = this.perWagonLength(rules);
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const board: BatchBoardSchedule[] = [];
@@ -165,7 +330,7 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, perWagonLength);
const need = this.needFor(b, wagonLengths);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
@@ -175,20 +340,114 @@ export class BookingBatchService implements OnModuleInit {
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
state: this.boardState(b, linkedIds.has(b.id)),
};
});
const usedWagons = items
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
.reduce((sum, i) => sum + i.wagons, 0);
const usedWeight = items
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
.reduce((sum, i) => sum + i.weightTons, 0);
board.push(this.buildScheduleSummary(s, items));
}
return board;
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
throw new BadRequestException('Schedule is no longer active');
}
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
>;
try {
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch {
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
}
const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]),
);
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const alloc = allocationByBooking.get(b.id);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
state: this.boardState(b, linkedIds.has(b.id)),
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
allocationIssue: alloc?.issue ?? null,
};
});
const loco = s.trainSet?.locomotive ?? null;
board.push({
const referenceDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupByBatchWindow(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
referenceDate,
);
const emptyCounts = () => ({
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
});
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
if (b.state === 'ALLOCATED') counts.allocated += 1;
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
else if (b.state === 'READY') counts.ready += 1;
else if (b.state === 'WAITING') counts.waiting += 1;
else if (b.state === 'EXPIRED') counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
};
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === 'pending-contract' || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
label: w.label,
start: w.start.toISOString(),
end: w.end.toISOString(),
counts: countFor(bucket.items),
bookings: bucket.items,
});
}
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
@@ -205,30 +464,99 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: {
maxWagons: s.maxWagons ?? 0,
usedWagons,
remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons),
usedWeightTons: Math.round(usedWeight * 100) / 100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
},
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
},
bookings: items,
});
windows,
pendingContract: {
key: 'pending-contract',
label: 'Pending contract',
start: '',
end: '',
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
allocationViolations: allocationPreview.violations,
};
}
return board;
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
async runWagonAllocation(scheduleId: string) {
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
private computeBoardCapacity(
items: Array<{
state: BatchBoardBookingState;
wagons: number;
weightTons: number;
lengthMeters: number;
}>,
loco: Locomotive | null,
): BatchBoardSchedule['capacity'] {
const allocated = items.filter((i) => i.state === 'ALLOCATED');
const committed = items.filter(
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
);
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
};
}
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
},
bookings: items.slice(0, 3),
};
}
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
if (linked) return 'ALLOCATED';
if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT';
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return 'SELECTED_FOR_BATCH';
}
if (booking.status === 'EXPIRED') return 'EXPIRED';
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
if (booking.status === 'PAID') return 'WAITING';
return 'PENDING_CONTRACT';
}
@@ -246,9 +574,10 @@ export class BookingBatchService implements OnModuleInit {
}
const rules = await this.loadGlobalRules();
const perWagonLength = this.perWagonLength(rules);
const limits = this.capacityLimits(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, perWagonLength);
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL');
return;
@@ -258,11 +587,11 @@ export class BookingBatchService implements OnModuleInit {
let armed = false;
for (const booking of pool) {
const need = this.needFor(booking, perWagonLength);
const need = this.needFor(booking, wagonLengths);
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength);
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
@@ -281,6 +610,31 @@ export class BookingBatchService implements OnModuleInit {
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
let anySettled = false;
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
anySettled = true;
} else if (expired) {
await this.expire(booking);
anySettled = true;
}
}
if (anySettled) await this.fillSchedule(scheduleId);
}
// ---- settle (1h after a batch) -------------------------------------------
@@ -306,6 +660,15 @@ export class BookingBatchService implements OnModuleInit {
}
await this.fillSchedule(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
}
// ---- staff override actions ----------------------------------------------
@@ -330,6 +693,7 @@ export class BookingBatchService implements OnModuleInit {
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
}
/**
@@ -375,6 +739,7 @@ export class BookingBatchService implements OnModuleInit {
status: restoredStatus,
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
}
@@ -391,14 +756,16 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------
/** Reserve capacity for a commercial booking and open its 1h pay window. */
/** Reserve capacity for a commercial booking and open its pay window. */
private async reserve(booking: Booking): Promise<void> {
const deadline = new Date(Date.now() + PAYMENT_WINDOW_MS);
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
status: 'AWAITING_PAYMENT',
status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
this.notifier.payNow(booking, deadline);
await this.notifier.payNow(booking, deadline);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -423,9 +790,11 @@ export class BookingBatchService implements OnModuleInit {
schedulingStatus: 'SCHEDULED',
scheduledAt: new Date(),
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
}
/** Expire an unpaid reservation and free its capacity. */
@@ -434,6 +803,7 @@ export class BookingBatchService implements OnModuleInit {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
this.notifier.expired(booking);
}
@@ -446,7 +816,7 @@ export class BookingBatchService implements OnModuleInit {
scheduleId: string,
need: Capacity,
budget: Capacity,
perWagonLength: number,
wagonLengths: WagonLengths,
): Promise<Capacity> {
const reservedCommercial = (
await this.bookingsRepository.findReservedForSchedule(scheduleId)
@@ -472,10 +842,11 @@ export class BookingBatchService implements OnModuleInit {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
this.notifier.displaced(victim);
freed = this.add(freed, this.needFor(victim, perWagonLength));
freed = this.add(freed, this.needFor(victim, wagonLengths));
}
return freed;
}
@@ -494,12 +865,15 @@ export class BookingBatchService implements OnModuleInit {
}
/** What one booking consumes along all three capacity axes. */
private needFor(booking: Booking, perWagonLength: number): Capacity {
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
const wagons = this.wagonsFor(booking);
return {
wagons,
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
lengthMeters: wagons * perWagonLength,
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
};
}
@@ -527,29 +901,71 @@ export class BookingBatchService implements OnModuleInit {
};
}
/** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */
private capacityLimits(
schedule: TrainSchedule,
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Capacity {
const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity;
const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity;
const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity;
const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity;
): Promise<Capacity> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
},
wagonTypes,
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
);
return {
wagons: schedule.maxWagons ?? 0,
weightTons: Math.min(locoWeight, ruleWeight),
lengthMeters: Math.min(locoLength, ruleLength),
wagons: derived.maxWagonSlots,
weightTons: derived.maxWeightTons,
lengthMeters: derived.maxLengthMeters,
};
}
/** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */
private perWagonLength(rules: TrainSchedulingGlobalRules | null): number {
const len = rules ? Number(rules.maxTrainLengthMeters) : 0;
const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0;
if (len > 0 && wagons > 0) return len / wagons;
return DEFAULT_WAGON_LENGTH_METERS;
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<void> {
const limits = await this.capacityLimits(locomotive, rules);
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
await this.dataSource
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: limits.wagons });
schedule.maxWagons = limits.wagons;
}
}
private async loadWagonTypeDimensions(): Promise<
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
];
}
private async loadWagonLengths(): Promise<WagonLengths> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
});
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
return {
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
@@ -560,14 +976,14 @@ export class BookingBatchService implements OnModuleInit {
private async remainingCapacity(
schedule: TrainSchedule,
limits: Capacity,
perWagonLength: number,
wagonLengths: WagonLengths,
): Promise<Capacity> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, perWagonLength)),
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
);
return this.subtract(limits, used);

View File

@@ -1,38 +1,64 @@
import { Injectable, Logger } from '@nestjs/common';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
/**
* Stub notifier for the batch flow — **console.log only** for now.
* Injectable so it can later be swapped for the real NotificationsService without
* touching the batch engine.
*/
@Injectable()
export class BookingNotifierService {
private readonly logger = new Logger('BookingNotifier');
private readonly logger = new Logger(BookingNotifierService.name);
constructor(private readonly notifications: NotificationsService) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
payNow(b: Booking, deadline: Date): void {
this.logger.log(
`PAY NOW — ${this.ref(b)} selected for schedule ${b.trainScheduleId}; pay before ${deadline.toISOString()} (1h).`,
);
private async notifyContact(
b: Booking,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
}
}
async payNow(b: Booking, deadline: Date): Promise<void> {
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW');
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
this.logger.log(
`ALLOCATED — ${this.ref(b)} secured on schedule ${b.trainScheduleId}${
reason === 'gov' ? ' (government, unpaid)' : ''
}.`,
);
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
}
expired(b: Booking): void {
this.logger.warn(
`EXPIRED — ${this.ref(b)} did not pay in time; can move to another schedule or cancel (no re-approval).`,
);
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
void this.notifyContact(b, msg, 'EXPIRED');
}
scheduleFull(b: Booking): void {
@@ -42,8 +68,7 @@ export class BookingNotifierService {
}
displaced(b: Booking): void {
this.logger.warn(
`DISPLACED — ${this.ref(b)} bumped by a government booking; move to another schedule or cancel.`,
);
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');
}
}

View File

@@ -0,0 +1,59 @@
import {
autoFillPlacements,
findMissingContainerNumberIssues,
type ContainerUnitForPlacement,
} from './container-placement.util';
describe('container-placement.util', () => {
const units: ContainerUnitForPlacement[] = [
{
bookingId: 'b1',
bookingContainerId: 'c1',
unitIndex: 0,
label: 'REF · 1/1 · 20GP',
teuSlots: 1,
sizeFt: 20,
containerNumber: 'ABCD1234567',
},
{
bookingId: 'b2',
bookingContainerId: 'c2',
unitIndex: 0,
label: 'REF2 · 1/1 · 40GP',
teuSlots: 2,
sizeFt: 40,
containerNumber: null,
},
];
it('auto-fills placements across slots', () => {
const placements = autoFillPlacements(units, [1, 2]);
expect(placements).toHaveLength(2);
expect(placements[0].sequenceNo).toBe(1);
expect(placements[1].sequenceNo).toBe(2);
});
it('reports missing container numbers only when placement is empty', () => {
const placements = autoFillPlacements(units, [1, 2]);
const issues = findMissingContainerNumberIssues(units, placements);
expect(issues).toHaveLength(0);
expect(placements[1].containerNumber).toMatch(/^TBD-/);
});
it('generates TBD placeholder for missing container numbers', () => {
const single: ContainerUnitForPlacement[] = [
{
bookingId: 'b2',
bookingReference: 'BK-2026-000033',
bookingContainerId: 'c2',
unitIndex: 0,
label: 'REF2 · 1/1 · 40GP',
teuSlots: 2,
sizeFt: 40,
containerNumber: null,
},
];
const placements = autoFillPlacements(single, [1]);
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1');
});
});

View File

@@ -0,0 +1,99 @@
import type { ContainerPlacementInput } from './wagon-plan.util';
export type ContainerUnitForPlacement = {
bookingId: string;
bookingReference?: string | null;
bookingContainerId: string;
unitIndex: number;
label: string;
teuSlots?: number;
sizeFt?: number;
containerNumber?: string | null;
};
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
const ref = unit.bookingReference ?? unit.bookingId.slice(0, 8);
return `TBD-${ref}-${unit.unitIndex + 1}`;
}
export function isPlaceholderContainerNumber(value: string | null | undefined): boolean {
return Boolean(value?.trim().startsWith('TBD-'));
}
export function resolveContainerNumber(unit: ContainerUnitForPlacement): string {
const trimmed = unit.containerNumber?.trim();
return trimmed || placeholderContainerNumber(unit);
}
export function autoFillPlacements(
units: ContainerUnitForPlacement[],
containerSlots: number[],
): ContainerPlacementInput[] {
if (!units.length || !containerSlots.length) return [];
const placements: ContainerPlacementInput[] = [];
const MAX_TEU_PER_WAGON = 2;
let currentSlotIndex = 0;
let teuInCurrentSlot = 0;
for (const unit of units) {
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
currentSlotIndex += 1;
teuInCurrentSlot = 0;
}
const sequenceNo =
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
containerSlots[containerSlots.length - 1] ??
containerSlots[0];
placements.push({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo,
containerNumber: resolveContainerNumber(unit),
});
teuInCurrentSlot += teu;
}
return placements;
}
export function findMissingContainerNumberIssues(
units: ContainerUnitForPlacement[],
placements: ContainerPlacementInput[],
): Array<{ bookingId: string; issue: string }> {
const issues: Array<{ bookingId: string; issue: string }> = [];
const byUnit = new Map(
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
);
for (const unit of units) {
const placement = byUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
if (!placement?.containerNumber?.trim()) {
issues.push({
bookingId: unit.bookingId,
issue: `Missing container number for ${unit.label}`,
});
}
}
return issues;
}
export function placementsForBookings(
placements: ContainerPlacementInput[],
bookingIds: Set<string>,
units: ContainerUnitForPlacement[],
): ContainerPlacementInput[] {
const unitBookingIds = new Map(
units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u.bookingId]),
);
return placements.filter((p) => {
const bookingId = unitBookingIds.get(`${p.bookingContainerId}:${p.unitIndex}`);
return bookingId ? bookingIds.has(bookingId) : false;
});
}

View File

@@ -1,19 +1,4 @@
import type { ScheduleTradeDirection } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
type YardLike = { country?: string | null };
export function deriveScheduleDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
if (originCountry === 'Djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';
}
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
export const deriveScheduleDirection = deriveTradeDirection;

View File

@@ -0,0 +1,8 @@
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' })
@IsUUID()
routeId!: string;
}

View File

@@ -0,0 +1,41 @@
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
} from './train-capacity.util';
describe('train-capacity.util', () => {
const nw5 = { lengthMeters: 14, capacityTons: 70 };
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
const shortLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
[nw5],
);
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
expect(shortLoco.maxWagonSlots).not.toBe(53);
const heavyLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
[nw5],
);
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
});
it('uses shortest wagon type when mixed types are present', () => {
const longBulk = { lengthMeters: 18, capacityTons: 80 };
const mixed = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5, longBulk],
);
expect(mixed.maxWagonSlots).toBe(
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
);
});
it('computes booking length by freight type', () => {
expect(
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
).toBe(28);
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
});

View File

@@ -0,0 +1,90 @@
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
export type WagonTypeDimensions = {
lengthMeters: number;
capacityTons: number;
};
export type LocomotiveLimits = {
maxPullWeightTons: number;
maxTrainLengthMeters: number;
};
export type DerivedTrainCapacity = {
maxWeightTons: number;
maxLengthMeters: number;
maxWagonSlots: number;
};
const DEFAULT_WAGON_LENGTH_M = 14;
const DEFAULT_WAGON_CAPACITY_T = 70;
/**
* Derive train capacity from locomotive pull weight and train length.
* Wagon count is NOT a fixed 53 — it is the minimum of:
* - floor(maxLength / shortest wagon type length)
* - floor(maxWeight / lightest wagon type capacity)
*/
export function deriveTrainCapacityFromLocomotive(
locomotive: LocomotiveLimits,
wagonTypes: WagonTypeDimensions[],
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): DerivedTrainCapacity {
const maxWeightTons = Math.min(
Number(locomotive.maxPullWeightTons) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
);
const maxLengthMeters = Math.min(
Number(locomotive.maxTrainLengthMeters) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
);
const types =
wagonTypes.length > 0
? wagonTypes
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
const minCapacity = Math.min(
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
);
const byLength =
minLength > 0 && Number.isFinite(maxLengthMeters)
? Math.floor(maxLengthMeters / minLength)
: 0;
const byWeight =
minCapacity > 0 && Number.isFinite(maxWeightTons)
? Math.floor(maxWeightTons / minCapacity)
: byLength;
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
return {
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
maxWagonSlots,
};
}
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,
wagonCount: number,
lengths: { container: number; bulk: number },
): number {
const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container;
return wagonCount * perWagon;
}
export function wagonTypeDimensionsFromEntity(wt: {
lengthMeters?: number | string | null;
capacityTons?: number | string | null;
}): WagonTypeDimensions {
return {
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
};
}

View File

@@ -22,6 +22,7 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { AvailableLocomotivesQueryDto } from './dto/available-locomotives-query.dto';
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -64,6 +65,22 @@ export class TrainSchedulingController {
return this.bookingBatchService.getBatchBoard();
}
@Get('batch-board/:scheduleId')
@TrainSchedulingView()
@ApiOperation({ summary: 'Batch board detail for one schedule with EAT 3h windows' })
getBatchBoardDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
}
@Get('available-locomotives')
@TrainSchedulingView()
@ApiOperation({
summary: 'List AVAILABLE locomotives filtered by route corridor readiness',
})
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
}
@Get('bookable-schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
@@ -191,7 +208,14 @@ export class TrainSchedulingController {
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
await this.bookingBatchService.fillSchedule(id);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
return this.bookingBatchService.getBatchBoardDetail(id);
}
@Post('schedules/:id/run-allocation')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Run wagon-level allocation for all eligible linked bookings' })
async runAllocation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingBatchService.runWagonAllocation(id);
}
@Patch('schedules/:id/booking-window')

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
@@ -21,6 +21,7 @@ import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
@@ -35,7 +36,8 @@ import { BookingNotifierService } from './booking-notifier.service';
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
]),
BookingsModule,
forwardRef(() => BookingsModule),
NotificationsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,

View File

@@ -1,4 +1,4 @@
import { ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -25,6 +25,7 @@ const locomotive = {
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
readiness: WagonReadiness.ImportReady,
};
const cw3 = {
@@ -420,6 +421,9 @@ describe('TrainSchedulingService', () => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
@@ -572,4 +576,202 @@ describe('TrainSchedulingService', () => {
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
const exportBooking = makeBooking(
'exp-1',
'BKG-EXP',
50,
1,
'40FT',
1,
'2026-06-20T08:00:00.000Z',
'yard-addis',
'yard-djibouti',
{
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
},
);
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
wagonNumber: `WGN-${index}`,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.previewContainerTrainSchedule({
bookingIds: ['exp-1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-addis',
destinationStationId: 'yard-djibouti',
});
expect(result.valid).toBe(false);
expect(
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
).toBe(true);
});
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
const scheduleId = 'sched-assign-1';
const trainSetId = 'train-set-1';
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
trainSchedulesRepository.findById.mockResolvedValue({
id: scheduleId,
direction: 'IMPORT',
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
trainSetId,
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
trainSet: {
id: trainSetId,
locomotive,
wagons: [],
},
scheduleBookings: [],
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const wagonRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn(),
};
const trainSetWagonRepo = {
delete: jest.fn(),
create: jest.fn((v) => v),
save: jest.fn(async (rows) =>
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
...r,
id: `slot-${i + 1}`,
})),
),
update: jest.fn(),
};
const manager = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Wagon) return wagonRepo;
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
if (entity === TrainSetWagon) return trainSetWagonRepo;
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
return {
create: jest.fn((v) => v),
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
delete: jest.fn(),
};
}
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
}),
};
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
cb(manager),
);
await expect(
service.assignBookingsToSchedule(
scheduleId,
{ bookingIds: ['b-pin'], containerPlacements: [] },
'CONTAINER',
),
).rejects.toBeInstanceOf(BadRequestException);
});
describe('getAvailableLocomotivesForRoute', () => {
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
const routeId = 'route-export';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Djibouti',
isActive: true,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' },
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
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 },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
});
it('returns all available locomotives on domestic routes', async () => {
const routeId = 'route-domestic';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Dire Dawa',
isActive: true,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' },
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
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 },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(2);
});
});
});

View File

@@ -75,17 +75,56 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util';
import {
flipReadiness,
requiredWagonReadiness,
wagonReadinessMatchesSchedule,
} from './wagon-readiness.util';
import {
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
} from './container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
export interface BookingWagonAllocationIssue {
bookingId: string;
status: BookingWagonAllocationStatus;
issue: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: DeferredBookingRow[];
issues: BookingWagonAllocationIssue[];
violations: string[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: 53,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
@@ -245,7 +284,9 @@ export class TrainSchedulingService {
scheduledDepartureDate: new Date(dto.scheduleDate),
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain,
maxWagons: (
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
@@ -294,10 +335,11 @@ export class TrainSchedulingService {
destinationStationId: schedule.destinationStationId,
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons,
maxWagonsPerTrain: dto.maxWagonsPerTrain,
};
const limits = await this.resolveTrainLimitConfig(previewDto);
const locomotive = schedule.trainSet.locomotive;
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -329,7 +371,6 @@ export class TrainSchedulingService {
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
const locomotive = schedule.trainSet.locomotive;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
}
@@ -473,6 +514,7 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
@@ -617,7 +659,7 @@ export class TrainSchedulingService {
paymentDeadline: null,
})
.where('train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`status = 'AWAITING_PAYMENT'`)
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.execute();
});
@@ -1068,6 +1110,14 @@ export class TrainSchedulingService {
bulkWagonType,
});
violations.push(
...(await this.validatePhysicalFleetForPlan(
wagonPlan,
scheduleDirection,
targetScheduleId,
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
@@ -1120,11 +1170,18 @@ export class TrainSchedulingService {
}
}
const availableLocomotives = await this.locomotivesRepository.findAll({
const availableLocomotives = (
await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
});
})
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
if (!availableLocomotives.length) {
violations.push('No available locomotive exists for scheduling');
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) =>
@@ -1168,11 +1225,14 @@ export class TrainSchedulingService {
}
}
private async resolveTrainLimitConfig(dto?: {
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}): Promise<Required<TrainLimitConfig>> {
},
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
@@ -1180,25 +1240,73 @@ export class TrainSchedulingService {
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
if (locomotive) {
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
},
wagonTypes,
{
maxTrainWeightTons: ruleWeightCap,
maxTrainLengthMeters: ruleLengthCap,
},
);
return {
maxWeightTons: this.positiveNumber(
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
maxWagonsPerTrain:
dto?.maxWagonsPerTrain != null
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
: derived.maxWagonSlots,
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) ||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
};
}
const maxWeightTons = this.positiveNumber(
dto?.maxTrainWeightTons,
Number(row?.maxTrainWeightTons) ||
configured?.maxTrainWeightTons ||
DEFAULT_TRAIN_LIMITS.maxWeightTons,
),
maxLengthMeters: this.positiveNumber(
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
);
const maxLengthMeters = this.positiveNumber(
dto?.maxTrainLengthMeters,
Number(row?.maxTrainLengthMeters) ||
configured?.maxTrainLengthMeters ||
DEFAULT_TRAIN_LIMITS.maxLengthMeters,
),
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
);
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
wagonTypes,
);
return {
maxWeightTons,
maxLengthMeters,
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
Number(row?.maxWagonsPerTrain) ||
configured?.maxWagonsPerTrain ||
DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain,
row?.maxWagonsPerTrain != null
? Number(row.maxWagonsPerTrain)
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
),
),
max20ftContainerWeightTons: this.positiveNumber(
@@ -1213,6 +1321,19 @@ export class TrainSchedulingService {
};
}
private async loadSchedulingWagonTypeDimensions(): Promise<
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
];
}
private async resolveScheduleDirection(
targetScheduleId: string | undefined,
bookings: Booking[],
@@ -1282,26 +1403,48 @@ export class TrainSchedulingService {
slots: TrainSetWagon[],
) {
const wagons = await manager.getRepository(Wagon).find();
const assignedPhysicalIds = new Set<string>();
const wagonTypes = await manager.getRepository(WagonType).find();
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const candidates = wagons.filter((wagon) => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
const planSlots = [...slots]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
trainSetWagonId: slot.id,
}));
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
wagons,
scheduleId,
scheduleDirection,
);
if (unpinnable.length) {
throw new BadRequestException({
message: 'Insufficient physical wagons to pin all train slots',
violations: unpinnable,
});
}
const physical = candidates[0];
const assignedPhysicalIds = new Set<string>();
for (const slot of planSlots) {
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
scheduleDirection,
assignedPhysicalIds,
);
if (!physical) continue;
await manager.getRepository(TrainSetWagon).update(slot.id, {
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
await manager.getRepository(Wagon).update(physical.id, {
trainSetWagonId: slot.id,
trainSetWagonId: slot.trainSetWagonId,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
@@ -1309,6 +1452,76 @@ export class TrainSchedulingService {
}
}
/** Pre-assign check: every planned slot must have a matching physical wagon. */
private async validatePhysicalFleetForPlan(
wagonPlan: WagonPlanSlot[],
scheduleDirection: string | null,
targetScheduleId?: string,
): Promise<string[]> {
if (!wagonPlan.length) return [];
const wagons = await this.dataSource.getRepository(Wagon).find();
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: slot.wagonTypeCode,
})),
wagons,
targetScheduleId,
scheduleDirection,
);
}
private findUnpinnableWagonSlots(
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
wagons: Wagon[],
scheduleId: string | undefined,
scheduleDirection: string | null,
): 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,
assignedPhysicalIds,
);
if (!physical) {
violations.push(
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
);
continue;
}
assignedPhysicalIds.add(physical.id);
}
return violations;
}
private pickPhysicalWagonForSlot(
slot: { wagonTypeId: string },
wagons: Wagon[],
scheduleId: string | undefined,
scheduleDirection: string | null,
assignedPhysicalIds: Set<string>,
): Wagon | undefined {
return wagons.find((wagon) => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = scheduleId
? wagon.currentTrainScheduleId === scheduleId
: false;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
});
}
private positiveNumber(value: number | undefined, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
@@ -1639,6 +1852,27 @@ export class TrainSchedulingService {
};
}
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
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' },
order: { code: 'ASC' },
});
if (!requiredReadiness) {
return locomotives;
}
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
}
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.trainSchedulesRepository.findAll({
@@ -1796,4 +2030,221 @@ export class TrainSchedulingService {
}
return SchedulingStatus.Eligible;
}
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, false);
}
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
async tryAutoWagonAllocation(
scheduleId: string,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, true);
}
private async buildAllocationAttempt(
schedule: TrainSchedule,
performAssign: boolean,
): Promise<WagonAllocationAttemptResult> {
const empty: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
if (!schedule.trainSet?.locomotive) {
return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
return {
...empty,
violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
};
}
const linkedBookings = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const eligible = linkedBookings.filter(
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
);
if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
const previewDto = {
bookingIds: eligible.map((b) => b.id),
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) {
const message = err instanceof Error ? err.message : 'Validation failed';
return {
...empty,
violations: [message],
issues: eligible.map((b) => ({
bookingId: b.id,
status: 'FAILED' as const,
issue: message,
})),
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
const deferredMap = new Map(
validation.deferredBookings.map((d) => [d.id, d.reason]),
);
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 missingNumbers = findMissingContainerNumberIssues(units, placements);
const missingByBooking = new Map<string, string>();
for (const m of missingNumbers) {
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
}
const placeholderWarnings = new Map<string, string>();
for (const p of placements) {
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
const unit = units.find(
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
);
if (unit && !placeholderWarnings.has(unit.bookingId)) {
placeholderWarnings.set(
unit.bookingId,
'Container number auto-assigned — verify before dispatch.',
);
}
}
const assignableIds = validation.bookings
.filter((b) => !missingByBooking.has(b.id))
.map((b) => b.id);
const assignableSet = new Set(assignableIds);
const assignPlacements = placementsForBookings(
placements,
assignableSet,
units,
);
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
}
if (missingByBooking.has(b.id)) {
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
}
if (deferredMap.has(b.id)) {
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
}
if (!fittingIds.has(b.id)) {
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
return {
bookingId: b.id,
status: 'FAILED',
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
};
}
if (wagonAssignedIds.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
}
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
});
const result: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: validation.deferredBookings,
issues,
violations: validation.violations,
};
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
};
}
try {
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
undefined,
);
result.assignedBookingIds = assignableIds;
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId)) {
issue.status = 'ASSIGNED';
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
}
}
} catch (err) {
const message =
err instanceof BadRequestException
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
'; ',
) ??
(err.getResponse() as { message?: string }).message ??
err.message)
: err instanceof Error
? err.message
: 'Allocation failed';
result.violations = [...result.violations, message];
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
issue.status = 'FAILED';
issue.issue = message;
}
}
}
return result;
}
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set();
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { trainSetWagonId: In(wagonIds) },
select: ['bookingId'],
});
return new Set(allocations.map((a) => a.bookingId));
}
}

View File

@@ -415,8 +415,10 @@ export function validateTrainLimits(
const violations: string[] = [];
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const wagonLength = Number(wagonType.lengthMeters) || 14;
const maxWagonsPerTrain =
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
limits?.maxWagonsPerTrain ??
Math.floor(maxLengthMeters / wagonLength);
const totalWeightTons = roundTons(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
@@ -451,9 +453,13 @@ export function validateMixedTrainLimits(
wagonTypes: WagonType[],
limits?: TrainLimitConfig,
): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const minWagonLength = Math.min(
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
14,
);
const maxWagonsPerTrain =
limits?.maxWagonsPerTrain ??
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
return validateTrainLimits(
wagonPlan,

View File

@@ -0,0 +1,56 @@
import { WagonReadiness, 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';
export class ListWagonsQueryDto {
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: WagonStatus })
@IsOptional()
@IsEnum(WagonStatus)
status?: WagonStatus;
@ApiPropertyOptional({ enum: WagonReadiness })
@IsOptional()
@IsEnum(WagonReadiness)
readiness?: WagonReadiness;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
wagonTypeId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
trainId?: string;
@ApiPropertyOptional({ default: 'wagonNumber' })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' })
@IsOptional()
@IsString()
sortOrder?: 'ASC' | 'DESC';
@ApiPropertyOptional({ minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
}

View File

@@ -11,6 +11,7 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
@@ -29,7 +30,7 @@ export class WagonsController {
@Get()
@ApiOperation({ summary: 'List all wagons' })
findAll(@Query() query: Record<string, string | undefined>) {
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}

View File

@@ -3,6 +3,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
@@ -31,16 +32,16 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
const search = query.search?.trim();
const status = query.status?.trim();
const readiness = query.readiness?.trim();
const trainId = query.trainId?.trim();
const filters = {
...(status ? { status: status as Wagon['status'] } : {}),
...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}),
const wagonTypeId = query.wagonTypeId?.trim();
const filters: FindOptionsWhere<Wagon> = {
...(query.status ? { status: query.status } : {}),
...(query.readiness ? { readiness: query.readiness } : {}),
...(trainId ? { trainId } : {}),
...(wagonTypeId ? { wagonTypeId } : {}),
};
if (search) {

View File

@@ -10,6 +10,8 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity";
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
@@ -32,6 +34,7 @@ export class PricingDataSeeder {
const rRepo = manager.getRepository(Rate);
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
await this.seedDomesticRoute(manager, yRepo);
await this.seedWeightLimits(wlRepo, ctRepo);
await this.seedPriorityRules(prRepo);
const containerTypes = await ctRepo.find();
@@ -287,6 +290,40 @@ export class PricingDataSeeder {
);
}
private async seedDomesticRoute(manager: any, yRepo: any): Promise<void> {
const addis = await yRepo.findOneBy({ code: "ADDIS_ABABA" });
const direDawa = await yRepo.findOneBy({ code: "DIRE_DAWA" });
if (!addis || !direDawa) return;
const routeRepo = manager.getRepository(Route);
const milestoneRepo = manager.getRepository(RouteMilestone);
const routeName = "Addis Ababa → Dire Dawa";
let route = await routeRepo.findOneBy({ name: routeName });
if (!route) {
route = await routeRepo.save(
routeRepo.create({
name: routeName,
originYardId: addis.id,
destinationYardId: direDawa.id,
isActive: true,
}),
);
await milestoneRepo.save([
milestoneRepo.create({
routeId: route.id,
yardId: addis.id,
sequenceNo: 1,
}),
milestoneRepo.create({
routeId: route.id,
yardId: direDawa.id,
sequenceNo: 2,
}),
]);
this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa");
}
}
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
await wlRepo.createQueryBuilder().delete().execute();
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
@@ -317,6 +354,18 @@ export class PricingDataSeeder {
maxVgmTons: 28,
effectiveFrom: base,
},
{
containerTypeId: twenty.id,
tradeDirection: "DOMESTIC",
maxVgmTons: 26,
effectiveFrom: base,
},
{
containerTypeId: forty.id,
tradeDirection: "DOMESTIC",
maxVgmTons: 28,
effectiveFrom: base,
},
]);
this.logger.log("Seeded weight limit rules");
}
@@ -472,6 +521,20 @@ export class PricingDataSeeder {
rateValue: 25000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_BULK",
containerTypeId: null,
currency: "USD",
rateValue: 35,
rateUnit: "PER_TON",
},
{
rateType: "INTERCITY_BULK",
containerTypeId: null,
currency: "ETB",
rateValue: 1900,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,

View File

@@ -39,6 +39,7 @@ import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
@@ -265,6 +266,10 @@ const App = () => {
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="operations/batch-board" element={<BatchBoardPage />} />
<Route
path="operations/batch-board/:scheduleId"
element={<BatchScheduleDetailPage />}
/>
<Route
path="operations/train-scheduling-v2"
element={<TrainScheduleV2ListPage />}

View File

@@ -136,9 +136,17 @@ export function AllocateBookingWizard({
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const locomotivesQuery = useAvailableLocomotives(
scheduleMode === "new" && routeId ? routeId : undefined,
);
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
useEffect(() => {
if (scheduleMode === "new") {
setLocomotiveId("");
}
}, [routeId, scheduleMode]);
const matchingSchedules = useMemo(
() =>
(schedulesQuery.data ?? []).filter(
@@ -511,6 +519,7 @@ export function AllocateBookingWizard({
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code} · ${
@@ -520,6 +529,10 @@ export function AllocateBookingWizard({
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
disabled={!routeId}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}
/>
</SimpleGrid>
)}

View File

@@ -43,12 +43,15 @@ export const QUERY_KEYS = {
ROOT: ["train-scheduling"] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
locomotives: () => ["train-scheduling", "locomotives"] as const,
locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
batchBoard: () => ["train-scheduling", "batch-board"] as const,
batchBoardDetail: (scheduleId: string) =>
["train-scheduling", "batch-board", scheduleId] as const,
},
FLEET: {

View File

@@ -138,8 +138,12 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>
`/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`,
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,

View File

@@ -154,9 +154,15 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
color: "text-amber-700",
stage: 3,
},
SELECTED_FOR_BATCH: {
title: "Selected for Batch",
description: "Picked from the batch pool — pay within the window to secure the slot.",
color: "text-amber-600",
stage: 3,
},
AWAITING_PAYMENT: {
title: "Awaiting Payment",
description: "Selected in a batch — pay within 1 hour to secure the slot.",
title: "Selected for Batch",
description: "Picked from the batch pool — pay within the window to secure the slot.",
color: "text-amber-600",
stage: 3,
},

View File

@@ -1,13 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { FleetListFilters, FleetResourceSlug } from "@/services/fleet/fleet.service";
import { fleetService } from "@/services/fleet/fleet.service";
export function useFleetList(slug: FleetResourceSlug) {
export function useFleetList(slug: FleetResourceSlug, filters?: FleetListFilters) {
return useQuery({
queryKey: QUERY_KEYS.FLEET.list(slug),
queryFn: () => fleetService.list(slug),
queryKey: [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
queryFn: () => fleetService.list(slug, filters),
});
}

View File

@@ -25,6 +25,27 @@ export const useBatchBoard = () =>
refetchInterval: 30_000,
});
export const useBatchBoardDetail = (scheduleId: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId ?? ""),
queryFn: () => trainSchedulingService.getBatchBoardDetail(scheduleId!),
enabled: Boolean(scheduleId),
refetchInterval: 30_000,
});
export const useRunAllocation = (scheduleId: string) => {
const qc = useQueryClient();
return useMutation({
mutationFn: () => trainSchedulingService.runAllocation(scheduleId),
onSuccess: () => {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
});
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
},
});
};
export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""),
@@ -43,10 +64,11 @@ export const useEligibleBookings = (
enabled,
});
export const useAvailableLocomotives = () =>
export const useAvailableLocomotives = (routeId?: string) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId),
queryFn: () => trainSchedulingService.getAvailableLocomotives(routeId),
enabled: routeId ? Boolean(routeId) : true,
});
export const useBatchActions = (scheduleId?: string) => {
@@ -54,10 +76,14 @@ export const useBatchActions = (scheduleId?: string) => {
const invalidate = () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
if (scheduleId) {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
});
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
});
}
};

View File

@@ -1,15 +1,21 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { wagonService } from '@/services/wagon.service';
export type WagonListFilters = import('@/services/wagon.service').WagonListFilters;
export const wagonKeys = {
all: ['wagons'] as const,
list: (filters?: WagonListFilters) => [...wagonKeys.all, 'list', filters ?? {}] as const,
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
details: () => [...wagonKeys.all, 'detail'] as const,
detail: (id: string) => [...wagonKeys.details(), id] as const,
};
export function useWagons() {
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
export function useWagons(filters?: WagonListFilters) {
return useQuery({
queryKey: wagonKeys.list(filters),
queryFn: () => wagonService.getAll(filters ?? {}).then((res) => res.data),
});
}
export const useGetWagons = useWagons;

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ActionIcon,
Badge,
@@ -113,6 +114,35 @@ const newLine = (): ContainerLine => ({
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
function deriveTradeDirectionFromYards(
origin?: RefNamed | null,
destination?: RefNamed | null,
): TradeDirection | null {
const originCountry = origin?.country?.trim();
const destinationCountry = destination?.country?.trim();
if (!originCountry || !destinationCountry) return null;
if (originCountry === "Djibouti") return "IMPORT";
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT";
return "DOMESTIC";
}
const tradeDirectionLabel: Record<TradeDirection, string> = {
IMPORT: "Import",
EXPORT: "Export",
DOMESTIC: "Domestic",
};
const parseBookingError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
/** Section card with a colored icon chip header. */
function FormSection({
icon: Icon,
@@ -168,7 +198,6 @@ export default function NewBookingPage() {
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
const [tradeDirection, setTradeDirection] = useState("IMPORT");
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
@@ -223,7 +252,16 @@ export default function NewBookingPage() {
? new Date(scheduledDate).toISOString()
: "";
const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code }));
const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
useEffect(() => {
setTrainScheduleId(null);
}, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
@@ -265,13 +303,18 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied =
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
const canSubmit =
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(trainScheduleId) &&
Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) &&
(Boolean(selectedSchedule) || Boolean(scheduledDate)) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
@@ -291,7 +334,7 @@ export default function NewBookingPage() {
freightType,
contractType: "NEW",
equipmentReturn,
tradeDirection,
tradeDirection: tradeDirection!,
paymentCurrency,
isHazardous,
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
@@ -324,7 +367,7 @@ export default function NewBookingPage() {
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate(`/dashboard/booking-requests/${booking.id}`);
},
onError: () => toast.error("Failed to create booking"),
onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")),
});
return (
@@ -460,6 +503,7 @@ export default function NewBookingPage() {
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule"
placeholder={
@@ -476,7 +520,13 @@ export default function NewBookingPage() {
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
/>
<Group grow>
) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can
link a schedule later.
</Text>
) : null}
<Group grow align="flex-end">
<Select
label="Service type"
placeholder="Select service"
@@ -486,16 +536,24 @@ export default function NewBookingPage() {
searchable
disabled={isLoading}
/>
<Select
label="Trade direction"
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
]}
value={tradeDirection}
onChange={(v) => setTradeDirection(v ?? "IMPORT")}
/>
<Box>
<Text size="sm" fw={500} mb={4}>
Trade direction
</Text>
<Badge
size="lg"
variant="light"
color={
tradeDirection === "DOMESTIC"
? "grape"
: tradeDirection === "EXPORT"
? "orange"
: "blue"
}
>
{tradeDirection ? tradeDirectionLabel[tradeDirection] : "Select yards"}
</Badge>
</Box>
</Group>
</Stack>
</FormSection>

View File

@@ -17,6 +17,7 @@ import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import {
FLEET_SELECT_NONE,
getFleetResource,
@@ -38,12 +39,30 @@ const FleetResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (slug !== "wagons" && slug !== "locomotives") return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const readiness = listFilterValues.readiness;
if (status && status !== "ALL") {
filters.status = status as FleetListFilters["status"];
}
if (readiness && readiness !== "ALL") {
filters.readiness = readiness as FleetListFilters["readiness"];
}
if (slug === "wagons" && search.trim()) {
filters.search = search.trim();
}
return filters;
}, [slug, listFilterValues, search]);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
const { create, update, remove } = useFleetMutations(slug);
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
@@ -56,12 +75,18 @@ const FleetResourcePage = () => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
setListFilterValues({});
}, [slug, setPagination]);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn) return [];
if (!hasStatusColumn || usesServerListFilters) return [];
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
@@ -71,7 +96,19 @@ const FleetResourcePage = () => {
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn]);
}, [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(
@@ -125,6 +162,7 @@ const FleetResourcePage = () => {
const filteredRows = useMemo(() => {
if (!config) return allRows;
if (usesServerListFilters) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
@@ -138,7 +176,7 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config]);
}, [allRows, search, statusFilter, config, usesServerListFilters]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
@@ -247,7 +285,27 @@ const FleetResourcePage = () => {
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
hasStatusColumn && statusFilterOptions.length > 1 ? (
listFilterSelects ? (
<Group gap="xs" wrap="nowrap">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
size="sm"
radius="lg"
label={filter.label}
value={filter.value}
onChange={(v) => {
if (!v) return;
setListFilterValues((prev) => ({ ...prev, [filter.key]: v }));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={filter.data}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Select
size="sm"
radius="lg"

View File

@@ -11,6 +11,10 @@ export type FleetResourceSlug =
export const FLEET_SELECT_NONE = "__none__";
import type { WagonListFilters } from "@/services/wagon.service";
export type FleetListFilters = WagonListFilters;
export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
@@ -30,6 +34,13 @@ export interface FleetFormFieldDef extends FormFieldDef {
noneOption?: boolean;
}
export interface FleetListFilterDef {
key: "status" | "readiness" | "wagonTypeId" | "trainId";
label: string;
options: Array<{ value: string; label: string }>;
allLabel?: string;
}
export interface FleetResourceConfig {
slug: FleetResourceSlug;
label: string;
@@ -39,6 +50,8 @@ export interface FleetResourceConfig {
entityLabel: string;
searchPlaceholder: string;
supportsSearch: boolean;
/** Server-side list filters (e.g. wagon status / readiness). */
listFilters?: FleetListFilterDef[];
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
@@ -101,12 +114,27 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
removeSuccessMessage: "Locomotive decommissioned",
cardTitleKey: "name",
cardCodeKey: "code",
cardSubtitleKey: "locomotiveType",
searchKeys: ["code", "name", "locomotiveType", "status"],
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{
key: "readiness",
label: "Readiness",
allLabel: "All readiness",
options: WAGON_READINESS_OPTIONS,
},
],
cardSubtitleKey: "readiness",
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
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: "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" },
@@ -116,6 +144,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: "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" },
@@ -127,6 +156,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
readiness: Freight.WagonReadiness.ImportReady,
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
@@ -187,6 +217,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
searchPlaceholder: "Search wagons…",
supportsSearch: true,
removeAction: "delete",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: WAGON_STATUS_OPTIONS,
},
{
key: "readiness",
label: "Readiness",
allLabel: "All readiness",
options: WAGON_READINESS_OPTIONS,
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "readiness",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],

View File

@@ -8,7 +8,6 @@ import {
Loader,
Paper,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Text,
@@ -18,79 +17,30 @@ import {
} from "@mantine/core";
import {
ArrowRight,
CheckCircle2,
Clock,
Hourglass,
LayoutGrid,
RefreshCw,
Ruler,
Train,
Weight,
XCircle,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
import type {
BatchBoardBooking,
BatchBoardBookingState,
BatchBoardSchedule,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
AWAITING_PAYMENT: { label: "Awaiting payment", color: "orange", icon: Clock },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
import type { BatchBoardSchedule } from "@/types/trainScheduling";
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
{meta.label}
</Badge>
);
}
function BookingRow({ booking }: { booking: BatchBoardBooking }) {
return (
<Group justify="space-between" wrap="nowrap" gap="sm" py={6} px="xs">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{booking.reference}
</Text>
{booking.isGovernment ? (
<Badge size="xs" variant="light" color="grape" radius="sm">
Gov
</Badge>
) : null}
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
<Text size="xs" c="dimmed">
{booking.wagons}w · {fmtTons(booking.weightTons)}
</Text>
<StateBadge state={booking.state} />
</Group>
</Group>
);
}
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule;
const wagonPct =
capacity.maxWagons > 0 ? (capacity.usedWagons / capacity.maxWagons) * 100 : 0;
const lengthPct =
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
: 0;
const weightPct =
capacity.maxWeightTons && capacity.maxWeightTons > 0
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
@@ -103,9 +53,34 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
? "orange"
: "gray";
const totalBookings =
counts.allocated +
counts.selectedForBatch +
counts.ready +
counts.waiting +
counts.pendingContract +
counts.expired;
return (
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
<Box style={{ height: 3, background: "linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))" }} />
<Paper
radius="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
overflow: "hidden",
cursor: "pointer",
}}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
>
<Box
style={{
height: 3,
background:
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
}}
/>
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
@@ -147,18 +122,37 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
</Badge>
)}
{/* Capacity meters */}
<Box>
<Group justify="space-between" mb={2}>
<Text size="xs" c="dimmed">
Wagons
Allocated wagons
</Text>
<Text size="xs" fw={600}>
{capacity.usedWagons}/{capacity.maxWagons}
{capacity.allocatedWagons}
</Text>
</Group>
<Progress value={wagonPct} color={wagonPct >= 100 ? "orange" : "green"} radius="xl" size="sm" />
</Box>
{capacity.maxLengthMeters ? (
<Box>
<Group justify="space-between" mb={2}>
<Group gap={4}>
<Ruler size={12} />
<Text size="xs" c="dimmed">
Train length
</Text>
</Group>
<Text size="xs" fw={600}>
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
</Text>
</Group>
<Progress
value={lengthPct}
color={lengthPct >= 100 ? "orange" : "blue"}
radius="xl"
size="sm"
/>
</Box>
) : null}
{capacity.maxWeightTons ? (
<Box>
<Group justify="space-between" mb={2}>
@@ -172,20 +166,29 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
</Text>
</Group>
<Progress value={weightPct} color={weightPct >= 100 ? "red" : "teal"} radius="xl" size="sm" />
<Progress
value={weightPct}
color={weightPct >= 100 ? "red" : "teal"}
radius="xl"
size="sm"
/>
</Box>
) : null}
{/* Count chips */}
<Group gap={6}>
<Tooltip label="Allocated to the train">
<Badge variant="light" color="green" radius="sm">
{counts.allocated} allocated
</Badge>
</Tooltip>
<Tooltip label="Notified — 1h to pay">
<Tooltip label="Picked by batch — customer notified to pay">
<Badge variant="light" color="orange" radius="sm">
{counts.awaitingPayment} to pay
{counts.selectedForBatch} selected
</Badge>
</Tooltip>
<Tooltip label="Contract signed — waiting for batch pick">
<Badge variant="light" color="teal" radius="sm">
{counts.ready} ready
</Badge>
</Tooltip>
<Tooltip label="Paid, waiting for a slot">
@@ -200,20 +203,11 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
) : null}
</Group>
{/* Bookings */}
{schedule.bookings.length ? (
<ScrollArea.Autosize mah={220}>
<Stack gap={2}>
{schedule.bookings.map((b) => (
<BookingRow key={b.id} booking={b} />
))}
</Stack>
</ScrollArea.Autosize>
) : (
<Text size="xs" c="dimmed" ta="center" py="sm">
No bookings targeting this schedule yet.
<Text size="xs" c="dimmed" ta="center">
{totalBookings
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
: "No bookings yet · click to open"}
</Text>
)}
<Button
variant="light"
@@ -221,11 +215,12 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
radius="md"
size="compact-sm"
rightSection={<ArrowRight size={15} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.scheduleId}`)
}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
}}
>
Open schedule
View batch windows
</Button>
</Stack>
</Paper>
@@ -238,9 +233,7 @@ export default function BatchBoardPage() {
return (
<Container size="xl" py="lg">
<Breadcrumbs
items={[{ label: "Operations" }, { label: "Batch board" }]}
/>
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
<Paper
radius="xl"
@@ -265,8 +258,8 @@ export default function BatchBoardPage() {
Batch board
</Title>
<Text size="sm" c="dimmed" maw={560}>
Every active schedule with its bookings grouped by state allocated, awaiting
payment, paid-waiting and expired. Filling is automatic; this is the live view.
Active schedules click a card to see EAT 3-hour batch windows, bookings, and
wagon allocation status.
</Text>
</Stack>
</Group>

View File

@@ -0,0 +1,536 @@
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Container,
Group,
Loader,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Clock,
Hourglass,
Layers,
PlayCircle,
RefreshCw,
Train,
Weight,
Ruler,
XCircle,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
useBatchBoardDetail,
useRunAllocation,
useScheduleDetail,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
{meta.label}
</Badge>
);
}
function AllocationBadge({
status,
issue,
}: {
status: BookingAllocationStatus;
issue: string | null;
}) {
const meta = ALLOC_META[status];
const badge = (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
if (!issue) return badge;
return (
<Tooltip label={issue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
}
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
if (!bookings.length) {
return (
<Text size="sm" c="dimmed" py="sm" ta="center">
No bookings in this batch window.
</Text>
);
}
return (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Contract signed</Table.Th>
<Table.Th>Selected for batch</Table.Th>
<Table.Th>Capacity</Table.Th>
<Table.Th>Batch state</Table.Th>
<Table.Th>Wagon allocation</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookings.map((b) => (
<Table.Tr key={b.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{b.company}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{fmtDateTime(b.fullyExecutedAt)} EAT</Text>
</Table.Td>
<Table.Td>
{b.selectedForBatchAt ? (
<>
<Text size="sm">{fmtDateTime(b.selectedForBatchAt)} EAT</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange">
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm">
{b.wagons}w · {fmtTons(b.weightTons)}
</Text>
</Table.Td>
<Table.Td>
<StateBadge state={b.state} />
</Table.Td>
<Table.Td>
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length;
const hasIssues = window.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
);
return (
<Accordion.Item value={window.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Text fw={600} size="sm">
{window.label}
</Text>
<Group gap={6} wrap="nowrap">
{hasIssues ? (
<Badge variant="light" color="red" size="sm">
Issues
</Badge>
) : null}
<Badge variant="outline" color="gray" size="sm">
{total} booking{total === 1 ? "" : "s"}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={window.bookings} />
</Accordion.Panel>
</Accordion.Item>
);
}
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId);
const runAllocation = useRunAllocation(scheduleId ?? "");
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
),
[data],
);
const scheduleDetailQuery = useScheduleDetail(
hasAssignedWagons ? scheduleId : undefined,
"CONTAINER",
);
const defaultOpen = useMemo(() => {
if (!data) return [];
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
}, [data]);
const handleRunAllocation = () => {
runAllocation
.mutateAsync()
.then((result) => {
const failed = result.issues.filter((i) => i.status === "FAILED").length;
const deferred = result.deferred.length;
toast({
title: "Allocation run complete",
description:
failed || deferred
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
variant: failed ? "destructive" : "default",
});
void refetch();
})
.catch(() => {
toast({ title: "Allocation failed", variant: "destructive" });
});
};
if (isLoading || !data) {
return (
<Container size="xl" py="lg">
<Group justify="center" py="xl">
<Loader color="green" />
</Group>
</Container>
);
}
const lengthPct =
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
: 0;
const weightPct =
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
: 0;
return (
<Container size="xl" py="lg">
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
]}
/>
<Paper radius="xl" p="xl" mt="md" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/operations/batch-board")}
>
Back
</Button>
<Stack gap={4}>
<Group gap="sm">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<Train size={22} />
</ThemeIcon>
<div>
<Title order={3}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<Text size="sm" c="dimmed">
{data.origin ?? "—"} {data.destination ?? "—"} ·{" "}
{data.scheduleDate
? new Date(data.scheduleDate).toLocaleString()
: "No date"}
</Text>
</div>
</Group>
<Group gap={6}>
<Badge variant="light" color="green">
{data.bookingWindowStatus}
</Badge>
<Badge variant="outline" color="gray">
{data.status}
</Badge>
</Group>
</Stack>
</Group>
<Group gap="sm">
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
<Button
color="green"
leftSection={<PlayCircle size={16} />}
loading={runAllocation.isPending}
onClick={handleRunAllocation}
>
Run allocation
</Button>
<Button
variant="light"
leftSection={<Layers size={16} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
}
>
Open schedule
</Button>
</Group>
</Group>
{data.locomotive ? (
<Text size="sm" c="dimmed" mt="md">
Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
</Text>
) : (
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />}>
No locomotive assigned wagon allocation cannot run.
</Alert>
)}
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" mt="md">
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm" c="dimmed">
Allocated wagons
</Text>
<Text size="sm" fw={600}>
{data.capacity.allocatedWagons}
</Text>
</Group>
</Box>
{data.capacity.maxLengthMeters ? (
<Box>
<Group justify="space-between" mb={4}>
<Group gap={4}>
<Ruler size={14} />
<Text size="sm" c="dimmed">
Train length
</Text>
</Group>
<Text size="sm" fw={600}>
{fmtMeters(data.capacity.allocatedLengthMeters)}/
{fmtMeters(data.capacity.maxLengthMeters)}
</Text>
</Group>
<Progress
value={lengthPct}
color={lengthPct >= 100 ? "orange" : "blue"}
radius="xl"
/>
</Box>
) : null}
{data.capacity.maxWeightTons ? (
<Box>
<Group justify="space-between" mb={4}>
<Group gap={4}>
<Weight size={14} />
<Text size="sm" c="dimmed">
Weight
</Text>
</Group>
<Text size="sm" fw={600}>
{fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
</Text>
</Group>
<Progress
value={weightPct}
color={weightPct >= 100 ? "red" : "teal"}
radius="xl"
/>
</Box>
) : null}
</SimpleGrid>
<Group gap={6} mt="md">
<Badge variant="light" color="green">
{data.counts.allocated} allocated
</Badge>
<Badge variant="light" color="orange">
{data.counts.selectedForBatch} selected
</Badge>
<Badge variant="light" color="teal">
{data.counts.ready} ready
</Badge>
<Badge variant="light" color="blue">
{data.counts.waiting} waiting
</Badge>
<Badge variant="light" color="gray">
{data.counts.pendingContract} pending contract
</Badge>
{data.counts.expired ? (
<Badge variant="light" color="red">
{data.counts.expired} expired
</Badge>
) : null}
</Group>
</Paper>
{data.allocationViolations.length ? (
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />} title="Allocation constraints">
<Stack gap={4}>
{data.allocationViolations.map((v) => (
<Text key={v} size="sm">
{v}
</Text>
))}
</Stack>
</Alert>
) : null}
<Paper radius="lg" withBorder p="lg" mt="lg">
<Title order={4} mb="md">
Batch windows (EAT)
</Title>
<Text size="sm" c="dimmed" mb="md">
Bookings are grouped by contract signing time (<code>fullyExecutedAt</code>). Expand a
window to see bookings and wagon allocation issues.
</Text>
<Accordion multiple defaultValue={defaultOpen} variant="separated">
{data.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
{data.pendingContract.bookings.length ? (
<Accordion.Item value="pending-contract">
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Text fw={600} size="sm">
Pending contract
</Text>
<Badge variant="outline" color="gray" size="sm">
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1 ? "" : "s"}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={data.pendingContract.bookings} />
</Accordion.Panel>
</Accordion.Item>
) : null}
</Accordion>
</Paper>
{hasAssignedWagons && scheduleDetailQuery.data ? (
<Paper radius="lg" withBorder p="lg" mt="lg">
<Title order={4} mb="md">
Train composition
</Title>
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
/>
</Paper>
) : null}
</Container>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
@@ -81,7 +81,7 @@ export default function TrainScheduleV2ListPage() {
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
const { create, cancel } = useScheduleMutations();
const activeRoutes = useMemo(
@@ -89,6 +89,23 @@ export default function TrainScheduleV2ListPage() {
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveReadinessHint = 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";
}, [selectedRoute]);
useEffect(() => {
setLocomotiveId("");
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
const stats = useMemo(() => {
@@ -507,6 +524,11 @@ export default function TrainScheduleV2ListPage() {
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveReadinessHint}
</Text>
) : null}
<TextInput
label="Departure date"
type="datetime-local"
@@ -518,7 +540,7 @@ export default function TrainScheduleV2ListPage() {
/>
<Select
label="Locomotive"
placeholder="Select locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""} · ${
@@ -528,6 +550,10 @@ export default function TrainScheduleV2ListPage() {
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
disabled={!routeId}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>

View File

@@ -1,16 +1,25 @@
import { cargoService, type Cargo } from "@/services/cargoService";
import { containerService, type Container } from "@/services/containerService";
import { locomotivesService, type Locomotive } from "@/services/locomotives.service";
import {
locomotivesService,
type Locomotive,
type LocomotiveListFilters,
} from "@/services/locomotives.service";
import { trainService, type Train } from "@/services/trains.service";
import { wagonService, type Wagon } from "@/services/wagon.service";
import { wagonService, type Wagon, type WagonListFilters } from "@/services/wagon.service";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
export type FleetRecord = Locomotive | Train | Wagon | Container | Cargo;
const listHandlers: Record<FleetResourceSlug, () => Promise<FleetRecord[]>> = {
locomotives: () => locomotivesService.getAll().then((r) => r.data),
export type FleetListFilters = WagonListFilters & LocomotiveListFilters;
const listHandlers: Record<
FleetResourceSlug,
(filters?: FleetListFilters) => Promise<FleetRecord[]>
> = {
locomotives: (filters) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
trains: () => trainService.getAll().then((r) => r.data),
wagons: () => wagonService.getAll().then((r) => r.data),
wagons: (filters) => wagonService.getAll(filters ?? {}).then((r) => r.data),
containers: () => containerService.getAll().then((r) => r.data),
cargoes: () => cargoService.getAll().then((r) => r.data),
};
@@ -43,7 +52,7 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
};
export const fleetService = {
list: (slug: FleetResourceSlug) => listHandlers[slug](),
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
updateHandlers[slug](id, data),

View File

@@ -1,3 +1,5 @@
import type { Freight } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -9,12 +11,18 @@ export type LocomotiveStatus =
| 'ASSIGNED'
| 'OUT_OF_SERVICE';
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
readiness?: Freight.WagonReadiness;
}
export interface Locomotive {
id: string;
code: string;
name?: string | null;
locomotiveType: LocomotiveType;
status: LocomotiveStatus;
readiness: Freight.WagonReadiness;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
powerKw?: number | null;
@@ -30,7 +38,15 @@ export type SaveLocomotivePayload = Omit<
>;
export const locomotivesService = {
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
getAll: (filters: LocomotiveListFilters = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
);
},
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
create: (data: Partial<SaveLocomotivePayload>) =>
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),

View File

@@ -3,6 +3,7 @@ import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
AssignBookingsPayload,
CreateTrainSchedulePayload,
@@ -18,6 +19,7 @@ import type {
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
TrainTrackResponse,
WagonAllocationAttemptResult,
YardOption,
} from '@/types/trainScheduling';
@@ -86,6 +88,13 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.get<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
);
return unwrap(response.data);
},
getBookableSchedules: async (
originYardId?: string,
destinationYardId?: string,
@@ -97,14 +106,22 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
runBatch: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
{},
);
return unwrap(response.data);
},
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => {
const response = await client.post<WagonAllocationAttemptResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
{},
);
return unwrap(response.data);
},
setBookingWindow: async (
scheduleId: string,
status: "OPEN" | "CLOSED",
@@ -232,7 +249,14 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => {
if (routeId) {
const response = await client.get<LocomotiveRecord[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
{ params: { routeId } },
);
return unwrap(response.data);
}
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
params: { status: 'AVAILABLE' },
});

View File

@@ -15,8 +15,25 @@ export interface Wagon {
notes?: string;
}
export interface WagonListFilters {
search?: string;
status?: Freight.WagonStatus;
readiness?: Freight.WagonReadiness;
wagonTypeId?: string;
trainId?: string;
}
export const wagonService = {
getAll: () => apiClient.get<Wagon[]>('/wagons'),
getAll: (filters: WagonListFilters = {}) => {
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.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
const qs = params.toString();
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
},
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>

View File

@@ -180,7 +180,8 @@ export interface BookableSchedule {
export type BatchBoardBookingState =
| "ALLOCATED"
| "AWAITING_PAYMENT"
| "SELECTED_FOR_BATCH"
| "READY"
| "WAITING"
| "PENDING_CONTRACT"
| "EXPIRED";
@@ -192,6 +193,7 @@ export interface BatchBoardBooking {
isGovernment: boolean;
wagons: number;
weightTons: number;
lengthMeters: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
}
@@ -212,15 +214,16 @@ export interface BatchBoardSchedule {
maxTrainLengthMeters: number;
} | null;
capacity: {
maxWagons: number;
usedWagons: number;
remainingWagons: number;
allocatedWagons: number;
allocatedLengthMeters: number;
maxLengthMeters: number | null;
usedWeightTons: number;
maxWeightTons: number | null;
};
counts: {
allocated: number;
awaitingPayment: number;
selectedForBatch: number;
ready: number;
waiting: number;
pendingContract: number;
expired: number;
@@ -228,6 +231,63 @@ export interface BatchBoardSchedule {
bookings: BatchBoardBooking[];
}
export type BookingAllocationStatus =
| "NOT_ATTEMPTED"
| "ASSIGNED"
| "DEFERRED"
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
allocationIssue: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
bookings: BatchBoardBookingDetail[];
}
export interface BatchBoardScheduleDetail {
scheduleId: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: Array<{ id: string; reference: string; reason: string }>;
issues: Array<{
bookingId: string;
status: BookingAllocationStatus;
issue: string | null;
}>;
violations: string[];
}
export interface TrainScheduleWagonAllocation {
id: string;
bookingId: string;

View File

@@ -53,8 +53,10 @@ export enum BookingStatus {
FullyExecuted = "FULLY_EXECUTED",
PnrGenerated = "PNR_GENERATED",
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
/** Selected in a batch and notified to pay within the 1h window. */
AwaitingPayment = "AWAITING_PAYMENT",
/** Selected in a batch and notified to pay within the pay window. */
SelectedForBatch = "SELECTED_FOR_BATCH",
/** @deprecated Use SelectedForBatch */
AwaitingPayment = "SELECTED_FOR_BATCH",
/** Missed the 1h pay window — recoverable via move/cancel (no re-approval). */
Expired = "EXPIRED",
Paid = "PAID",