diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 8b4553a64..12f1d2278 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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:*", diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.spec.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.spec.ts new file mode 100644 index 000000000..f245bca83 --- /dev/null +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts new file mode 100644 index 000000000..e9e183b25 --- /dev/null +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -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'; +} diff --git a/apps/edr-freight-api/src/migrations/1781000000003-AddSelectedForBatchStatus.ts b/apps/edr-freight-api/src/migrations/1781000000003-AddSelectedForBatchStatus.ts new file mode 100644 index 000000000..1ba9670b2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000003-AddSelectedForBatchStatus.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface { + name = 'AddSelectedForBatchStatus1781000000003'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000004-AddDomesticWeightLimitTradeDirection.ts b/apps/edr-freight-api/src/migrations/1781000000004-AddDomesticWeightLimitTradeDirection.ts new file mode 100644 index 000000000..59b1de22e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000004-AddDomesticWeightLimitTradeDirection.ts @@ -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 { + 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 { + // PostgreSQL does not support removing enum values safely. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 7c33b1e3e..c64b6221b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 268766a83..7386bfcfc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -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)) { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts new file mode 100644 index 000000000..8240105c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index b0d4121a9..e5f63eb27 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -275,7 +275,9 @@ export class BookingPricingService { ? isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT' - : 'INTERCITY_CONTAINER'; + : isBulk + ? 'INTERCITY_BULK' + : 'INTERCITY_CONTAINER'; const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); @@ -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})`, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index c230a187f..cdffe49f5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 3e78bb8ad..d1084fbab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -720,6 +720,7 @@ export class BookingsRepository extends BaseRepository { findBatchPool(scheduleId: string): Promise { 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 { .getMany(); } - /** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */ + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { 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 { + 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(); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index cd69c1a5f..0845011e3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { + 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 { 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); diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 7af475f5e..d1c706540 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index 1469630ec..41ce09f26 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index 1ea5ef29d..e8684f460 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index ac030d5d8..09c4c717f 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -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: diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index ac38503b9..7bbb8b722 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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,11 +7,12 @@ 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] }) -export class PaymentModule { } \ No newline at end of file +export class PaymentModule { } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 44fe2e2a1..6be87f8b9 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts index cf89a2d60..e8645c03c 100644 --- a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts +++ b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts @@ -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: @@ -50,4 +54,4 @@ export class TelebirrWebhookService { break; } } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 87cd8ccdf..6be37214b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts new file mode 100644 index 000000000..f2d3eda25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -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:00–22: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:00–10: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:00–07: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:00–07: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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts new file mode 100644 index 000000000..e837a6d48 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -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:00–07: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, + referenceDate: Date, +): BatchWindow[] { + const byKey = new Map(); + 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( + items: T[], + getTimestamp: (item: T) => Date | null | undefined, + referenceDate: Date, + pendingKey = 'pending-contract', +): Map { + const timestamps = items.map(getTimestamp); + const windows = listBatchWindowsForBookings(timestamps, referenceDate); + const map = new Map(); + + 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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index e93094b04..eda168e03 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -4,14 +4,15 @@ */ /** Batch boundaries — every 3h from 07:00 (the 07:00–10: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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts new file mode 100644 index 000000000..a08dd71ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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) => { + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 78c16030a..8170c3bcf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { + 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 { + 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 { + 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 { + await this.ensurePaidBookingAllocated(bookingId); + } + + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ + async reconcilePaidUnlinked(scheduleId: string): Promise { + 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,60 +340,223 @@ 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); - const loco = s.trainSet?.locomotive ?? null; - - board.push({ - 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: { - 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, - }, - counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').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, - }); + board.push(this.buildScheduleSummary(s, items)); } return board; } + /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ + async getBatchBoardDetail(scheduleId: string): Promise { + 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 + >; + 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; + + 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, + 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, + }, + windows, + pendingContract: { + key: 'pending-contract', + label: 'Pending contract', + start: '', + end: '', + counts: countFor(pendingBookings), + bookings: pendingBookings, + }, + allocationViolations: allocationPreview.violations, + }; + } + + /** 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 { + 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 { - 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 { 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 { + 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 { + 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 { + 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 { @@ -560,14 +976,14 @@ export class BookingBatchService implements OnModuleInit { private async remainingCapacity( schedule: TrainSchedule, limits: Capacity, - perWagonLength: number, + wagonLengths: WagonLengths, ): Promise { 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( - (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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e475ef1ae..e63bc1aec 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -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 { + 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 { + 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'); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts new file mode 100644 index 000000000..f51199b8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts new file mode 100644 index 000000000..72ee1407e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts @@ -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, + 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; + }); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts index f66a06c89..7e7358358 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts new file mode 100644 index 000000000..470794322 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts new file mode 100644 index 000000000..d72f3d311 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts new file mode 100644 index 000000000..593bb7bee --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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, + }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 64833d492..8056f4cdc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 9d0a94c78..64112d720 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 6f63755c3..9127cbc61 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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) => + 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); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 23d253736..6b4004cfb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 = { 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({ - where: { status: 'AVAILABLE' }, - }); + 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?: { - maxTrainWeightTons?: number; - maxTrainLengthMeters?: number; - maxWagonsPerTrain?: number; - }): Promise> { + private async resolveTrainLimitConfig( + dto?: { + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }, + locomotive?: Pick, + ): Promise> { 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: 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, + ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons, + ); + const maxLengthMeters = this.positiveNumber( + dto?.maxTrainLengthMeters, + ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters, + ); + const derivedWithoutLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters }, + wagonTypes, + ); + return { - maxWeightTons: this.positiveNumber( - dto?.maxTrainWeightTons, - Number(row?.maxTrainWeightTons) || - configured?.maxTrainWeightTons || - DEFAULT_TRAIN_LIMITS.maxWeightTons, - ), - maxLengthMeters: this.positiveNumber( - dto?.maxTrainLengthMeters, - Number(row?.maxTrainLengthMeters) || - configured?.maxTrainLengthMeters || - DEFAULT_TRAIN_LIMITS.maxLengthMeters, - ), + 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(); + 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(); + 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 { + 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(); + 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, + ): 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 { + 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 { + 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 { + 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 { + 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>; + 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(); + for (const m of missingNumbers) { + if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue); + } + const placeholderWarnings = new Map(); + 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> { + 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)); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index a450fe8c9..8c3199461 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts new file mode 100644 index 000000000..7670d7d34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index fd2305f93..c70208052 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -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) { + findAll(@Query() query: ListWagonsQueryDto) { return this.wagonsService.findAll(query); } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 10681d4d0..7ab6a67e6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -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 = {}): Promise { + async findAll(query: ListWagonsQueryDto = {}): Promise { const where: FindOptionsWhere[] | FindOptionsWhere = []; 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 = { + ...(query.status ? { status: query.status } : {}), + ...(query.readiness ? { readiness: query.readiness } : {}), ...(trainId ? { trainId } : {}), + ...(wagonTypeId ? { wagonTypeId } : {}), }; if (search) { diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 882674862..f28c38113 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -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 { + 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 { 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, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6ba338a7c..8b170f0a7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 = () => { /> } /> } /> + } + /> } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index b4ae069b2..3892fbf81 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -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({ /> - + {hasBookableSchedules ? ( + - { + 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)" } }} + /> + ))} + + ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( ({ 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" + } />