diff --git a/README.md b/README.md index 8aba7ec6d..4d7bd604b 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,6 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap pnpm install ``` -<<<<<<< HEAD ### 3. Environment Configuration ```bash # Copy environment template @@ -949,7 +948,6 @@ For technical support or questions: --- **Built with ❤️ for Ethio-Djibouti Railway** -======= ### Start local databases ```bash @@ -1022,4 +1020,3 @@ pnpm dev:passenger # passenger API + portal + backoffice - **One DB per domain** — no cross-database joins. See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions. ->>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 15f99cf2a..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:*", @@ -28,6 +29,7 @@ "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "^1.4.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9de089bb1..bb7b752e2 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,6 +1,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; import { DataSource, DataSourceOptions } from "typeorm"; import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; @@ -58,6 +59,7 @@ import { OverviewModule } from './modules/overview/overview.module'; isGlobal: true, load: [appConfig, databaseConfig, telebirrConfig], }), + ScheduleModule.forRoot(), // EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], 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/1781000000000-AddLocomotiveReadiness.ts b/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts new file mode 100644 index 000000000..f6d87f41d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLocomotiveReadiness1781000000000 implements MigrationInterface { + name = 'AddLocomotiveReadiness1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_readiness + ON freight.locomotives (readiness) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS readiness + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts b/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts new file mode 100644 index 000000000..7d9ce745a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface { + name = 'CreateTrainCheckpointEvents1781000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + yard_id UUID NOT NULL, + sequence_no INT NOT NULL, + kind VARCHAR(20) NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + note TEXT NULL, + recorded_by_user_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule + ON freight.train_checkpoint_events (train_schedule_id, sequence_no) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts b/apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts new file mode 100644 index 000000000..0287b55be --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBatchBookingFields1781000000002 implements MigrationInterface { + name = 'AddBatchBookingFields1781000000002'; + + public async up(queryRunner: QueryRunner): Promise { + // Booking → target schedule (pool membership) + 1h pay-window deadline. + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL, + ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id + ON freight.bookings (train_schedule_id) + WHERE deleted_at IS NULL + `); + + // TrainSchedule → booking-window status (OPEN/FULL/CLOSED). + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status + ON freight.train_schedules (booking_window_status) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS train_schedule_id, + DROP COLUMN IF EXISTS payment_deadline + `); + } +} 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 ab8a8dfa7..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,6 +1,9 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { Readable } from 'stream'; @@ -19,9 +22,12 @@ 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 { + private readonly logger = new Logger(BookingContractService.name); + constructor( private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, @@ -30,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 { @@ -92,7 +100,16 @@ export class BookingContractService { const templateKey = this.templateResolver.resolve(booking); const summary = this.buildContractSummary(booking); - await this.upsertContractPdf(bookingId, booking.reference, templateKey); + + // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract + // from becoming ready — the document is (re)rendered lazily on view/download. + try { + await this.upsertContractPdf(bookingId, booking.reference, templateKey); + } catch (err) { + this.logger.warn( + `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`, + ); + } const now = new Date(); const updated = await this.bookingsRepository.update(bookingId, { @@ -191,11 +208,20 @@ export class BookingContractService { } const updated = await this.bookingsRepository.update(bookingId, updates as never); - await this.upsertContractPdf( - bookingId, - booking.reference, - booking.contractTemplateKey ?? this.templateResolver.resolve(booking), - ); + if (role === 'STAFF' && updated?.trainScheduleId) { + this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); + } + try { + await this.upsertContractPdf( + bookingId, + booking.reference, + booking.contractTemplateKey ?? this.templateResolver.resolve(booking), + ); + } catch (err) { + this.logger.warn( + `Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ); + } return updated!; } 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 07ef6a183..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', '']); + 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.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 12d6e0d5a..8631aa9e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -54,7 +54,7 @@ import { type AuthUserPayload, resolveAuthUserId, } from '../../common/resolve-auth-user-id'; -import { assertFreightPermission } from '../../common/freight-permission.util'; +import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; @ApiTags('bookings') @Controller('bookings') @@ -73,7 +73,7 @@ export class BookingsController { @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) @ApiBody({ type: CreateBookingDto }) - create( + async create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, @@ -81,7 +81,22 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - return this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create(dto, files ?? [], user?.id); + + // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. + const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + if (isStaff && !dto.isGovernment) { + try { + await this.pricingService.generatePrice(result.booking.id); + await this.transitionService.submit(result.booking.id); + const submitted = await this.bookingsService.findById(result.booking.id); + return { booking: submitted, warnings: result.warnings }; + } catch { + // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. + return result; + } + } + return result; } @Patch(':id') 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 46600b148..d1084fbab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -659,6 +659,7 @@ export class BookingsRepository extends BaseRepository { originStationId?: string; destinationStationId?: string; schedulingStatus?: string; + trainScheduleId?: string; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -676,6 +677,14 @@ export class BookingsRepository extends BaseRepository { .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) .andWhere('scheduleBooking.id IS NULL'); + // Mirror the automatic batch pool: a schedule only ever considers bookings that + // targeted THAT schedule (same as findBatchPool's train_schedule_id filter). + if (options.trainScheduleId) { + qb.andWhere('booking.train_schedule_id = :trainScheduleId', { + trainScheduleId: options.trainScheduleId, + }); + } + if (options.freightType) { qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); } @@ -703,6 +712,90 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Ready, not-yet-allocated bookings targeting a schedule (the batch pool). + * Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract). + * Ordered government → priority → contract-sign time. + */ + 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 }) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ + findAllBySchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** 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 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(); + } + + /** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */ + findAllocatedCommercialForSchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .innerJoin( + TrainScheduleBooking, + 'sb', + 'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId', + { scheduleId }, + ) + .where('booking.is_government = false') + .orderBy('booking.priority_score', 'ASC') + .addOrderBy('booking.created_at', 'DESC') + .getMany(); + } + findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise { if (!bookingIds.length) return Promise.resolve([]); return this.bookingRepo(manager).find({ 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 d8e796b36..0845011e3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -14,6 +14,12 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { InjectDataSource } from '@nestjs/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'; import { assertFreightShape } from './booking-freight.util'; @@ -40,6 +46,7 @@ const NEEDS_ACTION_STATUSES = [ @Injectable() export class BookingsService { constructor( + @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, @@ -50,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(); @@ -199,6 +236,25 @@ export class BookingsService { companyId = company.id; } + // Schedule targeting: when provided, the schedule must be OPEN and on the same route. + if (dto.trainScheduleId) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: dto.trainScheduleId } }); + if (!schedule) { + throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`); + } + if (schedule.bookingWindowStatus !== 'OPEN') { + throw new BadRequestException('Selected schedule is no longer accepting bookings'); + } + if ( + schedule.originStationId !== dto.originYardId || + schedule.destinationStationId !== dto.destinationYardId + ) { + throw new BadRequestException('Selected schedule is not on the booking route'); + } + } + const reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ @@ -207,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) @@ -217,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, @@ -235,6 +297,7 @@ export class BookingsService { isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, + trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, previousContractId: dto.previousContractId, serviceTypeId: dto.serviceTypeId, @@ -243,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, @@ -338,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( @@ -351,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, @@ -377,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/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 194bd5a83..3c7eca391 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -91,6 +91,12 @@ export class CreateBookingDto { @IsUUID() trainId?: string; + /** Target schedule this booking is created against (required by the backoffice create form). */ + @ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @IsDateString() scheduledDate!: string; 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 b3104d322..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', @@ -274,6 +276,18 @@ export class Booking extends BaseEntity { @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) scheduledAt?: Date | null; + /** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + /** 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/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 2c5aa463a..40e00aa68 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { WagonReadiness } from '@edr/types'; import { Column, Entity, Index, OneToMany } from 'typeorm'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; @@ -12,12 +13,20 @@ export const LOCOMOTIVE_STATUSES = [ export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; +/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */ +export const LOCOMOTIVE_READINESS_VALUES = [ + WagonReadiness.ImportReady, + WagonReadiness.ExportReady, +] as const; + export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; +export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number]; @Entity({ schema: 'freight', name: 'locomotives' }) @Index(['code']) @Index(['status']) +@Index(['readiness']) export class Locomotive extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; @@ -37,6 +46,9 @@ export class Locomotive extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; + @Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) + readiness!: LocomotiveReadiness; + @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) powerKw?: number | null; 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 e4168370c..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, @@ -12,7 +14,7 @@ import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; import { ConfigService } from "@nestjs/config"; -import { SchedulingStatus } from "@edr/types"; +// import { SchedulingStatus } from "@edr/types"; import { Booking } from "../bookings/entities/booking.entity"; import { @@ -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( @@ -62,107 +67,6 @@ export class PaymentService { const result = await this.telebirrProvider.initiate(input); -<<<<<<< HEAD - const queryRunner = this.datasource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - console.log(paymentResp.expiresAt); - try { - const resp = await cb(queryRunner); - const payment = await this.paymentRepo.createTr(queryRunner, { - amount, - currency, - method, - refId: resp.id, - type: resp.type, - merchantOrderId: orderId, - rawInitiation: paymentResp.rawInitiation, - clientAction: paymentResp.clientAction, - expiresAt: paymentResp.expiresAt, - reason, - }); - await queryRunner.commitTransaction(); - return { - refId: payment.refId, - clientAction: paymentResp.clientAction, - status: payment.status, - paidAt: payment.paidAt?.toISOString(), - failureCode: payment.failerCode ?? undefined, - failureMessage: payment.failureMessage ?? undefined, - }; - } catch (err) { - await queryRunner.rollbackTransaction(); - throw new Error("payment failed"); - } finally { - await queryRunner.release(); - } - } - - async getActivePaymentByRefIdAndMethod( - refId: string, - method: PaymentEntity["method"], - ): Promise { - return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method); - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ - merchantOrderId: orderId, - status: "success", - }); - if (!payment) { - throw new BadRequestException(); - } - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) { - throw new InternalServerErrorException(); - } - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - - const html = template({ - vendorName: "Ethio Djibouti Railway Ticket Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment?.method, - subtotal: payment?.amount.toString(), - total: payment?.amount.toString(), - currency: payment?.currency, - reason: payment?.reason, - }); - - return html; - } - - async checkStatusAndUpdate(orderId: string) { - const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }); - if (!resp) { - throw new NotFoundException("order id not found"); - } - - try { - const result = await this.telebirrPaymentStategy.queryStatus( - resp.merchantOrderId, - ); - const bizContent = result.rawResponse.biz_content as { - order_status: string; - }; - - const ordersStatus = bizContent.order_status; - if (ordersStatus == "PAY_SUCCESS") { - await this.datasource.transaction(async (mg) => { - const now = new Date(); - const holdExpires = new Date(now.getTime() + 3 * 60 * 60 * 1000); - await mg.update(Booking, { id: resp.refId }, { - status: "PAID", - schedulingStatus: SchedulingStatus.Holding, - holdStartedAt: now, - holdExpiresAt: holdExpires, - }); - await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }); -======= const payment = await this.paymentRepo.create({ amount: amount, currency: DEFAULT_CURRENCY, @@ -174,7 +78,6 @@ export class PaymentService { clientAction: result.clientAction as Record, expiresAt: result.expiresAt, reason: `Payment for booking`, ->>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f }); return { @@ -227,9 +130,12 @@ export class PaymentService { if (result.status === ProviderPaymentStatus.SUCCEEDED) { await this.datasource.transaction(async (mg) => { - 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-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index feb7fa318..d1ed23ef7 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -79,6 +79,10 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'max_wagons', type: 'int', default: 53 }) maxWagons!: number; + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ + @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) + bookingWindowStatus!: string; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } 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 new file mode 100644 index 000000000..eda168e03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -0,0 +1,31 @@ +/** + * Tunables for the demand-batching booking → allocation flow. + * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + */ + +/** 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 = '*/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 = 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; + +/** + * Fallback per-wagon length (m) for the batch length budget when global rules don't yet + * define maxTrainLength / maxWagons to derive it from. Used only to estimate train length + * 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 new file mode 100644 index 000000000..8170c3bcf --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -0,0 +1,1039 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +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_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 { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +type WagonLengths = { container: number; bulk: number }; + +export type BatchBoardBookingState = + | 'ALLOCATED' + | 'SELECTED_FOR_BATCH' + | 'READY' + | 'WAITING' + | 'PENDING_CONTRACT' + | 'EXPIRED'; + +export interface BatchBoardBooking { + id: string; + reference: string; + company: string; + 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; + routeName: string | null; + origin: string | null; + destination: string | null; + scheduleDate: string | null; + status: string; + bookingWindowStatus: string; + locomotive: { + code: string; + name: string | null; + maxPullWeightTons: number; + maxTrainLengthMeters: number; + } | null; + capacity: { + /** 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; + selectedForBatch: number; + ready: number; + waiting: number; + pendingContract: number; + expired: number; + }; + bookings: BatchBoardBooking[]; +} + +/** + * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool + * by priority, greedily fills the train to capacity (skipping bookings that don't fit), + * reserves a 1h pay window for commercial customers (government allocated unpaid, + * preempting lower-priority commercial if needed), then settles each batch 1h later — + * allocating those who paid and expiring those who didn't, topping up from the waiting list. + * Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the + * locomotive's max pull weight, and its max train length (also capped by global rules). + */ +@Injectable() +export class BookingBatchService implements OnModuleInit { + private readonly logger = new Logger(BookingBatchService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingsRepository: BookingsRepository, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, + private readonly notifier: BookingNotifierService, + private readonly scheduler: SchedulerRegistry, + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + /** 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 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 }) + async runBatchFill(): Promise { + const open = await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: 'OPEN' }, + }); + this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`); + for (const s of open) { + try { + await this.processSchedule(s.id); + } catch (err) { + this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`); + } + } + } + + // ---- monitoring board ----------------------------------------------------- + + /** + * Read model for the batch monitoring page: every still-relevant schedule (not arrived/ + * cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle + * state (allocated / awaiting payment / paid-waiting / pending contract / expired). + */ + async getBatchBoard(): Promise { + const schedules = await this.trainSchedulesRepository.findAll({ + relations: { + trainSet: { locomotive: true }, + originStation: true, + destinationStation: true, + route: true, + }, + order: { scheduledDepartureDate: 'ASC' }, + }); + + const wagonLengths = await this.loadWagonLengths(); + const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const board: BatchBoardSchedule[] = []; + for (const s of schedules) { + if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + + 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); + + const items: BatchBoardBooking[] = bookings.map((b) => { + const need = this.needFor(b, wagonLengths); + 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)), + }; + }); + + 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 === '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'; + } + + // ---- core fill ------------------------------------------------------------ + + /** Fill one schedule from its priority-ordered pool until full. */ + async fillSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const locomotive = schedule.trainSet?.locomotive; + if (!schedule.trainSetId || !locomotive) { + this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + return; + } + + const rules = await this.loadGlobalRules(); + 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; + } + + const pool = await this.bookingsRepository.findBatchPool(scheduleId); + let armed = false; + + for (const booking of pool) { + const need = this.needFor(booking, wagonLengths); + + if (!this.fits(need, budget)) { + if (booking.isGovernment) { + 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 + } + } + + if (booking.isGovernment) { + await this.allocate(scheduleId, booking, 'gov'); + } else { + await this.reserve(booking); + armed = true; + } + budget = this.subtract(budget, need); + if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board + } + + 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) ------------------------------------------- + + /** Allocate paid reservations, expire the rest, then top up. */ + async settleBatch(scheduleId: string): Promise { + this.removeTimeout(scheduleId); + const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const now = Date.now(); + + for (const booking of reserved) { + const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const expired = booking.paymentDeadline + ? booking.paymentDeadline.getTime() <= now + : true; + + if (paid) { + await this.allocate(scheduleId, booking, 'paid'); + } else if (expired) { + await this.expire(booking); + } + // else: still within window (rare at settle) → leave for the re-armed timeout + } + + 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 ---------------------------------------------- + + /** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */ + async markPaid(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (!booking.trainScheduleId) { + throw new BadRequestException('Booking has no target schedule to allocate to'); + } + await this.dataSource + .getRepository(Booking) + .update(bookingId, { paymentStatus: 'PAID' }); + await this.allocate(booking.trainScheduleId, booking, 'paid'); + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + booking.trainScheduleId, + ); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(booking.trainScheduleId, 'FULL'); + } + void this.triggerWagonAllocation(booking.trainScheduleId!); + } + + /** + * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). + * Used for EXPIRED or full-schedule bookings — no re-approval. + */ + async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: newScheduleId } }); + if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== 'OPEN') { + throw new BadRequestException('Target schedule is not accepting bookings'); + } + if ( + schedule.originStationId !== booking.originYardId || + schedule.destinationStationId !== booking.destinationYardId + ) { + throw new BadRequestException('Target schedule is not on the booking route'); + } + + await this.dataSource.transaction(async (manager) => { + if (booking.trainScheduleId) { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + booking.trainScheduleId, + bookingId, + manager, + ); + } + const restoredStatus = + booking.status === 'EXPIRED' + ? booking.isGovernment + ? 'APPROVED' + : 'FULLY_EXECUTED' + : booking.status; + await manager.getRepository(Booking).update(bookingId, { + trainScheduleId: newScheduleId, + status: restoredStatus, + schedulingStatus: 'ELIGIBLE', + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + }); + } + + /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ + async expireReservation(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + await this.expire(booking); + if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + } + + // ---- mutations ------------------------------------------------------------ + + /** Reserve capacity for a commercial booking and open its pay window. */ + private async reserve(booking: Booking): Promise { + const now = new Date(); + const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + await this.bookingsRepository.update(booking.id, { + status: 'SELECTED_FOR_BATCH', + selectedForBatchAt: now, + paymentDeadline: deadline, + } as never); + await this.notifier.payNow(booking, deadline); + } + + /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ + private async allocate( + scheduleId: string, + booking: Booking, + reason: 'paid' | 'gov', + ): Promise { + await this.dataSource.transaction(async (manager) => { + const exists = await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); + if (!exists) { + await this.trainScheduleBookingsRepository.createMany( + [{ trainScheduleId: scheduleId, bookingId: booking.id }], + manager, + ); + } + await manager.getRepository(Booking).update(booking.id, { + status: reason === 'paid' ? 'PAID' : booking.status, + 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. */ + private async expire(booking: Booking): Promise { + await this.bookingsRepository.update(booking.id, { + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + this.notifier.expired(booking); + } + + /** + * Free capacity for a government booking by displacing the lowest-priority commercial + * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. + */ + private async preemptForGovernment( + scheduleId: string, + need: Capacity, + budget: Capacity, + wagonLengths: WagonLengths, + ): Promise { + const reservedCommercial = ( + await this.bookingsRepository.findReservedForSchedule(scheduleId) + ).filter((b) => !b.isGovernment); + const allocatedCommercial = + await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + + // lowest priority first; reserved are cheaper to free than allocated + const candidates = [...reservedCommercial, ...allocatedCommercial].sort( + (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0), + ); + + let freed = budget; + for (const victim of candidates) { + if (this.fits(need, freed)) break; + await this.dataSource.transaction(async (manager) => { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + scheduleId, + victim.id, + manager, + ); + await manager.getRepository(Booking).update(victim.id, { + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + }); + this.notifier.displaced(victim); + freed = this.add(freed, this.needFor(victim, wagonLengths)); + } + return freed; + } + + // ---- capacity helpers ----------------------------------------------------- + + private wagonsFor(booking: Booking): number { + if (booking.wagonsRequired && booking.wagonsRequired > 0) { + return Math.ceil(booking.wagonsRequired); + } + const fromContainers = (booking.bookingContainers ?? []).reduce( + (sum, c) => sum + Number(c.quantity ?? 0), + 0, + ); + return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + } + + /** What one booking consumes along all three capacity axes. */ + private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity { + const wagons = this.wagonsFor(booking); + return { + wagons, + weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + + private fits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); + } + + private subtract(budget: Capacity, need: Capacity): Capacity { + return { + wagons: budget.wagons - need.wagons, + weightTons: budget.weightTons - need.weightTons, + lengthMeters: budget.lengthMeters - need.lengthMeters, + }; + } + + private add(budget: Capacity, freed: Capacity): Capacity { + return { + wagons: budget.wagons + freed.wagons, + weightTons: budget.weightTons + freed.weightTons, + lengthMeters: budget.lengthMeters + freed.lengthMeters, + }; + } + + /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ + private async capacityLimits( + locomotive: Locomotive, + rules: TrainSchedulingGlobalRules | null, + ): 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: derived.maxWagonSlots, + weightTons: derived.maxWeightTons, + lengthMeters: derived.maxLengthMeters, + }; + } + + /** 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 { + return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + } + + /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ + private async remainingCapacity( + schedule: TrainSchedule, + limits: Capacity, + 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, wagonLengths)), + { wagons: 0, weightTons: 0, lengthMeters: 0 }, + ); + return this.subtract(limits, used); + } + + /** maxWagons minus wagons already taken by allocated + reserved bookings. */ + private async remainingWagons(schedule: TrainSchedule): 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.reduce((s, b) => s + this.wagonsFor(b), 0) + + reserved.reduce((s, b) => s + this.wagonsFor(b), 0); + return (schedule.maxWagons ?? 0) - used; + } + + private async setWindow( + scheduleId: string, + status: 'OPEN' | 'FULL' | 'CLOSED', + ): Promise { + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { bookingWindowStatus: status }); + } + + // ---- timer plumbing ------------------------------------------------------- + + private timeoutName(scheduleId: string): string { + return `settle:${scheduleId}`; + } + + private armSettle(scheduleId: string): void { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + ); + }, PAYMENT_WINDOW_MS); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + } + + private removeTimeout(scheduleId: string): void { + const name = this.timeoutName(scheduleId); + try { + if (this.scheduler.doesExist('timeout', name)) { + this.scheduler.deleteTimeout(name); + } + } catch { + // ignore — not armed + } + } +} 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 new file mode 100644 index 000000000..e63bc1aec --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -0,0 +1,74 @@ +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'; + +@Injectable() +export class BookingNotifierService { + private readonly logger = new Logger(BookingNotifierService.name); + + constructor(private readonly notifications: NotificationsService) {} + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + 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 { + 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 { + 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 { + this.logger.warn( + `SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`, + ); + } + + displaced(b: Booking): void { + 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/dto/bookable-schedules-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/bookable-schedules-query.dto.ts new file mode 100644 index 000000000..1dc908639 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/bookable-schedules-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class BookableSchedulesQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts index 3660426ed..363e9b5e8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts @@ -17,6 +17,14 @@ export class GetEligibleBookingsDto { @IsUUID() destinationStationId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: 'Scope to bookings that targeted this specific schedule (batch parity).', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional() @IsOptional() schedulingStatus?: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts index 9a2cafa2f..c8fde0c07 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts @@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto { @IsUUID() destinationStationId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ example: 'HOLDING' }) @IsOptional() schedulingStatus?: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts index e33327f38..5d11192d6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto { @IsUUID() destinationStationId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ example: 'HOLDING' }) @IsOptional() schedulingStatus?: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts new file mode 100644 index 000000000..7f778760d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TrainCheckpointKind } from '@edr/types'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +export class RecordCheckpointDto { + @ApiProperty({ description: 'Station position along the route (0 = origin).' }) + @IsInt() + @Min(0) + sequenceNo!: number; + + @ApiProperty({ enum: TrainCheckpointKind, required: false }) + @IsOptional() + @IsEnum(TrainCheckpointKind) + kind?: TrainCheckpointKind; + + @ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' }) + @IsOptional() + @IsISO8601() + occurredAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts new file mode 100644 index 000000000..5fa90a2e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts @@ -0,0 +1,45 @@ +import { BaseEntity } from '@edr/api-common'; +import { TrainCheckpointKind } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +/** + * One staff-logged tracking checkpoint for a dispatched train as it passes a + * station along its route (origin → milestones → destination). + */ +@Entity({ schema: 'freight', name: 'train_checkpoint_events' }) +@Index(['trainScheduleId']) +@Index(['trainScheduleId', 'sequenceNo']) +export class TrainCheckpointEvent extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** Position along the corridor: 0 = origin, N+1 = destination. */ + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'kind', type: 'varchar', length: 20 }) + kind!: TrainCheckpointKind; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true }) + recordedByUserId?: string | null; +} 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-checkpoint-events.repository.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts new file mode 100644 index 000000000..210de382e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts @@ -0,0 +1,24 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +@Injectable() +export class TrainCheckpointEventsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainCheckpointEvent) + repository: Repository, + ) { + super(repository); + } + + findBySchedule(trainScheduleId: string): Promise { + return this.findAll({ + where: { trainScheduleId }, + relations: { yard: true }, + order: { sequenceNo: 'ASC', occurredAt: 'ASC' }, + }); + } +} 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 320efb859..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 @@ -21,14 +21,21 @@ import { PinWagonsDto } from './dto/pin-wagons.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; +import { 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'; +import { BookingBatchService } from './booking-batch.service'; @ApiTags('train-scheduling') @ApiBearerAuth() @Controller('train-scheduling') export class TrainSchedulingController { - constructor(private readonly trainSchedulingService: TrainSchedulingService) {} + constructor( + private readonly trainSchedulingService: TrainSchedulingService, + private readonly bookingBatchService: BookingBatchService, + ) {} @Get('global-rules') @TrainSchedulingView() @@ -51,6 +58,39 @@ export class TrainSchedulingController { return this.trainSchedulingService.getEligibleBookings(query); } + @Get('batch-board') + @TrainSchedulingView() + @ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' }) + getBatchBoard() { + 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' }) + getBookableSchedules(@Query() query: BookableSchedulesQueryDto) { + return this.trainSchedulingService.getBookableSchedules( + query.originYardId, + query.destinationYardId, + ); + } + @Get('container/eligible-bookings') @TrainSchedulingView() @ApiOperation({ summary: 'List eligible container bookings' }) @@ -161,6 +201,85 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + // ---- batch / booking-window staff actions ---- + + @Post('schedules/:id/run-batch') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Manually run the batch fill for a schedule' }) + async runBatch(@Param('id', ParseUUIDPipe) id: string) { + await this.bookingBatchService.fillSchedule(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') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Open or close a schedule booking window' }) + async setBookingWindow( + @Param('id', ParseUUIDPipe) id: string, + @Body('status') status: 'OPEN' | 'CLOSED', + ) { + await this.trainSchedulingService.setBookingWindow(id, status === 'CLOSED' ? 'CLOSED' : 'OPEN'); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post('bookings/:bookingId/mark-paid') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Staff: mark a reserved booking paid and allocate it now' }) + async markBookingPaid(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + await this.bookingBatchService.markPaid(bookingId); + return { ok: true }; + } + + @Post('bookings/:bookingId/expire') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Staff: expire a reservation and free its capacity' }) + async expireBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + await this.bookingBatchService.expireReservation(bookingId); + return { ok: true }; + } + + @Post('bookings/:bookingId/move-schedule') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Re-point a booking to another OPEN same-route schedule' }) + async moveBookingSchedule( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('trainScheduleId', ParseUUIDPipe) trainScheduleId: string, + ) { + await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId); + return { ok: true }; + } + + @Get('schedules/:id/checkpoints') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' }) + getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleCheckpoints(id); + } + + @Post('schedules/:id/checkpoints') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' }) + recordCheckpoint( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RecordCheckpointDto, + ) { + return this.trainSchedulingService.recordCheckpoint(id, dto); + } + + @Post('schedules/:id/arrive') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' }) + arriveSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.arriveSchedule(id); + } + @Get('container/schedules') @TrainSchedulingView() @ApiOperation({ summary: 'List container train schedules' }) 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 d133cbc74..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'; @@ -14,9 +14,14 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module' import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; 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: [ @@ -29,8 +34,10 @@ import { TrainSchedulingService } from './train-scheduling.service'; Wagon, Container, TrainSchedulingGlobalRules, + TrainCheckpointEvent, ]), - BookingsModule, + forwardRef(() => BookingsModule), + NotificationsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, @@ -38,7 +45,12 @@ import { TrainSchedulingService } from './train-scheduling.service'; RuleEngineModule, ], controllers: [TrainSchedulingController], - providers: [TrainSchedulingService], - exports: [TrainSchedulingService], + providers: [ + TrainSchedulingService, + TrainCheckpointEventsRepository, + BookingBatchService, + BookingNotifierService, + ], + exports: [TrainSchedulingService, BookingBatchService], }) export class TrainSchedulingModule {} 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 1b7e25ec6..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 = { @@ -125,6 +126,13 @@ describe('TrainSchedulingService', () => { findAll: jest.fn().mockResolvedValue([]), }; + const trainCheckpointEventsRepository = { + findBySchedule: jest.fn().mockResolvedValue([]), + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + }; + service = new TrainSchedulingService( dataSource as never, bookingsRepository as never, @@ -135,6 +143,7 @@ describe('TrainSchedulingService', () => { wagonBookingAllocationsRepository as never, wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, + trainCheckpointEventsRepository as never, ); const defaultFleetWagons = [ @@ -412,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' }); @@ -564,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 bbf2f4f95..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 @@ -1,6 +1,7 @@ import { AllocationLoadType, SchedulingStatus, + TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, WagonStatus, } from '@edr/types'; @@ -74,13 +75,56 @@ import { pickBulkWagonType, } from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; -import { 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, }; @@ -98,6 +142,7 @@ export class TrainSchedulingService { private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, + private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly configService?: ConfigService, ) {} @@ -107,6 +152,7 @@ export class TrainSchedulingService { originStationId: query.originStationId, destinationStationId: query.destinationStationId, schedulingStatus: query.schedulingStatus, + trainScheduleId: query.trainScheduleId, }); return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; } @@ -219,11 +265,17 @@ export class TrainSchedulingService { throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); } - const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); const direction = deriveScheduleDirection( route.originYard ?? { country: null }, route.destinationYard ?? { country: null }, ); + if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`, + ); + } + + const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, @@ -232,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' }); @@ -260,6 +314,20 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule has no train set'); } + // Batch parity: a schedule may only allocate bookings that targeted it. This mirrors + // the automatic fill, which only pulls bookings whose train_schedule_id is this schedule. + if (dto.bookingIds.length) { + const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds); + const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId); + if (stray.length) { + throw new BadRequestException( + `These bookings are not assigned to this schedule: ${stray + .map((b) => b.reference ?? b.id) + .join(', ')}`, + ); + } + } + const previewDto = { bookingIds: dto.bookingIds, scheduleDate: schedule.scheduledDepartureDate.toISOString(), @@ -267,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, @@ -302,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'); } @@ -446,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, @@ -576,6 +645,260 @@ export class TrainSchedulingService { manager, ); } + // Close the booking window; any still-pending (unallocated) reservations don't ride this train. + await manager + .getRepository(TrainSchedule) + .update(scheduleId, { bookingWindowStatus: 'CLOSED' }); + await manager + .getRepository(Booking) + .createQueryBuilder() + .update() + .set({ + status: 'EXPIRED', + schedulingStatus: SchedulingStatus.Eligible, + paymentDeadline: null, + }) + .where('train_schedule_id = :scheduleId', { scheduleId }) + .andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .execute(); + }); + + return this.getTrainScheduleById(scheduleId); + } + + /** Open or close a schedule's booking window (staff override). */ + async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { bookingWindowStatus: status }); + } + + /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ + private async buildScheduleStations(schedule: TrainSchedule) { + type Station = { sequenceNo: number; yardId: string; label: string; code: string }; + const stations: Station[] = []; + + const route = schedule.routeId + ? await this.dataSource.getRepository(Route).findOne({ + where: { id: schedule.routeId }, + relations: { originYard: true, destinationYard: true, milestones: { yard: true } }, + }) + : null; + + if (route) { + const origin = route.originYard; + const destination = route.destinationYard; + const milestones = [...(route.milestones ?? [])].sort( + (a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, + ); + stations.push({ + sequenceNo: 0, + yardId: route.originYardId, + label: origin?.label ?? origin?.code ?? 'Origin', + code: origin?.code ?? '', + }); + milestones.forEach((m, i) => + stations.push({ + sequenceNo: i + 1, + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, + code: m.yard?.code ?? '', + }), + ); + stations.push({ + sequenceNo: milestones.length + 1, + yardId: route.destinationYardId, + label: destination?.label ?? destination?.code ?? 'Destination', + code: destination?.code ?? '', + }); + return stations; + } + + // Fallback: no route milestones — just origin → destination from the schedule stations. + stations.push({ + sequenceNo: 0, + yardId: schedule.originStationId, + label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin', + code: schedule.originStation?.code ?? '', + }); + stations.push({ + sequenceNo: 1, + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination', + code: schedule.destinationStation?.code ?? '', + }); + return stations; + } + + /** Track payload for a schedule: ordered stations, logged checkpoints, current position. */ + async getScheduleCheckpoints(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const stations = await this.buildScheduleStations(schedule); + const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + const currentSequenceNo = events.length + ? Math.max(...events.map((e) => e.sequenceNo)) + : -1; + + return { + scheduleId, + status: schedule.status, + direction: schedule.direction ?? null, + trainNumber: schedule.trainNumber ?? null, + actualDepartureAt: schedule.actualDepartureAt + ? schedule.actualDepartureAt.toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? schedule.actualArrivalAt.toISOString() + : null, + origin: stations[0]?.label ?? null, + destination: stations[stations.length - 1]?.label ?? null, + stations, + currentSequenceNo, + checkpoints: events.map((e) => ({ + id: e.id, + sequenceNo: e.sequenceNo, + yardId: e.yardId, + label: e.yard?.label ?? e.yard?.code ?? null, + kind: e.kind, + occurredAt: e.occurredAt.toISOString(), + note: e.note ?? null, + })), + }; + } + + /** Log the train passing a station. Logging the destination station triggers arrival. */ + async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only DISPATCHED trains can be tracked'); + } + + const stations = await this.buildScheduleStations(schedule); + const finalSeq = stations[stations.length - 1].sequenceNo; + const station = stations.find((s) => s.sequenceNo === dto.sequenceNo); + if (!station) { + throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`); + } + + const kind = + dto.kind ?? + (dto.sequenceNo === 0 + ? TrainCheckpointKind.Departed + : dto.sequenceNo === finalSeq + ? TrainCheckpointKind.Arrived + : TrainCheckpointKind.Passed); + const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); + + // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. + const [existing] = await this.trainCheckpointEventsRepository.findAll({ + where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, + }); + if (existing) { + await this.trainCheckpointEventsRepository.update(existing.id, { + kind, + occurredAt, + note: dto.note ?? null, + yardId: station.yardId, + }); + } else { + await this.trainCheckpointEventsRepository.create({ + trainScheduleId: scheduleId, + yardId: station.yardId, + sequenceNo: dto.sequenceNo, + kind, + occurredAt, + note: dto.note ?? null, + }); + } + + if (dto.sequenceNo === finalSeq) { + await this.arriveSchedule(scheduleId); + } + + return this.getScheduleCheckpoints(scheduleId); + } + + /** + * Mark a dispatched train arrived: close out the schedule, flip readiness on the + * locomotive + wagons (they have repositioned), and free the assets for re-use. + */ + async arriveSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only DISPATCHED trains can arrive'); + } + + const isDomestic = schedule.direction === 'DOMESTIC'; + const now = new Date(); + + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Arrived, + { actualArrivalAt: now }, + manager, + ); + + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + status: 'COMPLETED', + }); + } + + if (schedule.trainSet?.locomotiveId) { + const loco = await manager + .getRepository(Locomotive) + .findOne({ where: { id: schedule.trainSet.locomotiveId } }); + if (loco) { + await manager.getRepository(Locomotive).update(loco.id, { + status: 'AVAILABLE', + readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness), + }); + } + } + + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId) continue; + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (!wagon) continue; + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: WagonStatus.Available, + readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness), + }); + } + + // Ensure a destination checkpoint exists so the timeline shows ARRIVED. + const stations = await this.buildScheduleStations(schedule); + const finalStation = stations[stations.length - 1]; + const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({ + where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo }, + }); + if (!existingFinal) { + await manager.getRepository(TrainCheckpointEvent).save( + manager.getRepository(TrainCheckpointEvent).create({ + trainScheduleId: scheduleId, + yardId: finalStation.yardId, + sequenceNo: finalStation.sequenceNo, + kind: TrainCheckpointKind.Arrived, + occurredAt: now, + }), + ); + } }); return this.getTrainScheduleById(scheduleId); @@ -694,7 +1017,8 @@ export class TrainSchedulingService { } const invalidStatus = bookings.filter( - (b) => !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID'), + (b) => + !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && !b.isGovernment, ); if (invalidStatus.length) { const statuses = [...new Set(invalidStatus.map((b) => b.status))]; @@ -786,6 +1110,14 @@ export class TrainSchedulingService { bulkWagonType, }); + violations.push( + ...(await this.validatePhysicalFleetForPlan( + wagonPlan, + scheduleDirection, + targetScheduleId, + )), + ); + const placementRules = { max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons, max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, @@ -838,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) => @@ -886,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; @@ -898,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( @@ -931,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[], @@ -1000,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, }); @@ -1027,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; @@ -1339,6 +1834,7 @@ export class TrainSchedulingService { id: schedule.trainSet.locomotive.id, code: schedule.trainSet.locomotive.code, name: schedule.trainSet.locomotive.name ?? null, + readiness: schedule.trainSet.locomotive.readiness ?? null, } : null, wagonCount: schedule.trainSet?.wagonCount ?? 0, @@ -1347,9 +1843,58 @@ export class TrainSchedulingService { bookingsCount: schedule.scheduleBookings?.length ?? 0, freightType: this.resolveScheduleFreightType(schedule), status: schedule.status, + bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + maxWagons: schedule.maxWagons ?? 0, + remainingWagons: Math.max( + 0, + (schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0), + ), }; } + /** 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({ + where: { + bookingWindowStatus: 'OPEN', + ...(originYardId ? { originStationId: originYardId } : {}), + ...(destinationYardId ? { destinationStationId: destinationYardId } : {}), + }, + relations: { + trainSet: { locomotive: true }, + route: true, + originStation: true, + destinationStation: true, + scheduleBookings: { booking: true }, + }, + order: { scheduledDepartureDate: 'ASC' }, + }); + return schedules + .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) + .map((s) => this.mapScheduleListItem(s)); + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { @@ -1407,6 +1952,7 @@ export class TrainSchedulingService { code: schedule.trainSet.locomotive.code, name: schedule.trainSet.locomotive.name, status: schedule.trainSet.locomotive.status, + readiness: schedule.trainSet.locomotive.readiness ?? null, maxPullWeightTons: roundTons( Number(schedule.trainSet.locomotive.maxPullWeightTons), ), @@ -1484,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/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts index 3cdd717d8..bda854d58 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts @@ -16,3 +16,16 @@ export function wagonReadinessMatchesSchedule( if (!required) return true; return wagonReadiness === required; } + +/** + * Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train + * reaches its destination: the asset has repositioned, so it is now ready for + * the opposite direction. Direction-agnostic so it handles round trips. + */ +export function flipReadiness( + readiness: WagonReadiness | string, +): WagonReadiness { + return readiness === WagonReadiness.ImportReady + ? WagonReadiness.ExportReady + : WagonReadiness.ImportReady; +} 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 6761b90c9..8b170f0a7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -3,6 +3,7 @@ import { Boxes, FileText, LayoutDashboard, + LayoutGrid, Network, Paperclip, Settings, @@ -37,7 +38,10 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec 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"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; @@ -75,6 +79,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/operations/train-scheduling-v2", icon: , }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + }, ], }, { @@ -256,6 +265,11 @@ const App = () => { element={} /> } /> + } /> + } + /> } @@ -264,6 +278,10 @@ const App = () => { path="operations/train-scheduling-v2/:scheduleId" element={} /> + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx index 0195efe11..3d6eeb398 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx @@ -11,14 +11,11 @@ import { RefreshCw, } from "lucide-react"; -import { freightBrand } from "@/theme/freight-brand"; import type { BookingListSummaryMetrics, BookingListSummaryTabs, } from "@/services/bookings.service"; -const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`; - /** Lifecycle stages for the pipeline distribution bar (in flow order). */ const PIPELINE_STAGES: Array<{ key: keyof BookingListSummaryTabs; @@ -26,13 +23,18 @@ const PIPELINE_STAGES: Array<{ color: string; }> = [ { key: "intake", label: "Intake", color: "#38bdf8" }, - { key: "in_approval", label: "Approval", color: "#fbbf24" }, - { key: "approved_contract", label: "Contract", color: "#a78bfa" }, + { key: "in_approval", label: "Approval", color: "#f59e0b" }, + { key: "approved_contract", label: "Contract", color: "#8b5cf6" }, { key: "payment", label: "Payment", color: "#fb923c" }, - { key: "operations", label: "Operations", color: "#2dd4bf" }, - { key: "completed", label: "Completed", color: "#86efac" }, + { key: "operations", label: "Operations", color: "#14b8a6" }, + { key: "completed", label: "Completed", color: "#22c55e" }, ]; +const CARD_STYLE = { + background: "var(--mantine-color-gray-0)", + border: "1px solid var(--mantine-color-gray-2)", +} as const; + export interface BookingRequestsHeaderProps { metrics?: BookingListSummaryMetrics; tabs?: BookingListSummaryTabs; @@ -59,74 +61,40 @@ export function BookingRequestsHeader({ style={{ position: "relative", overflow: "hidden", - background: HERO_GRADIENT, - boxShadow: freightBrand.shadow, + background: "#ffffff", + border: "1px solid var(--mantine-color-gray-2)", + boxShadow: "0 1px 3px rgba(15,23,42,0.04)", }} > - {/* decorative glows */} - - - - + - + Operations - + <Title order={2} fw={700} style={{ color: "#0f172a" }}> Booking Requests - + Track every booking from submission through approval, payment, and dispatch — prioritize what needs action. - @@ -134,19 +102,14 @@ export function BookingRequestsHeader({ - + - + {tabs ? : null} @@ -173,36 +131,31 @@ export function BookingRequestsHeader({ /** Compact ring gauge with the stat icon at its center. */ function MiniDonut({ pct, - color = "white", + color, children, size = 52, stroke = 5, }: { pct?: number | null; - color?: string; + color: string; children: ReactNode; size?: number; stroke?: number; }) { const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; - const clamped = - pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null; + const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null; const dash = clamped != null ? (clamped / 100) * circumference : 0; return ( - + {clamped != null ? ( @@ -226,7 +179,7 @@ function MiniDonut({ display: "flex", alignItems: "center", justifyContent: "center", - color: "white", + color, }} > {children} @@ -241,7 +194,7 @@ function HeroStat({ value, hint, ratio, - ratioColor = "white", + ratioColor = "var(--mantine-color-green-6)", }: { icon: LucideIcon; label: string; @@ -252,29 +205,19 @@ function HeroStat({ }) { const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null; return ( - + - + {label} - + {value} - + {pct != null ? `${pct}% of queue` : hint} @@ -288,20 +231,12 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { const total = segments.reduce((sum, s) => sum + s.count, 0); return ( - + - + Booking pipeline - + {total} active @@ -312,7 +247,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { height: 14, borderRadius: 999, overflow: "hidden", - background: "rgba(255,255,255,0.18)", + background: "var(--mantine-color-gray-2)", gap: 2, }} > @@ -322,11 +257,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { ) : null, ) @@ -339,10 +270,10 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { {segments.map((s) => ( - + {s.label} - + {s.count} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index 67a7fbc52..e396ec4c2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -10,7 +10,7 @@ import { Wallet, Weight, } from "lucide-react"; -import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; +import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; @@ -18,12 +18,9 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; -import { freightBrand } from "@/theme/freight-brand"; import { formatDate } from "./booking-detail.styles"; -const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`; - export interface BookingRequestHeroProps { booking: BookingDetail; customerLabel: string; @@ -55,40 +52,25 @@ export function BookingRequestHero({ style={{ position: "relative", overflow: "hidden", - background: HERO_GRADIENT, - boxShadow: freightBrand.shadow, + background: "#ffffff", + border: "1px solid var(--mantine-color-gray-2)", + boxShadow: "0 1px 3px rgba(15,23,42,0.04)", }} > - - } onClick={onBack} - style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.25)" }} > Back to list } @@ -101,11 +83,11 @@ export function BookingRequestHero({ - + Booking reference - + {booking.reference} @@ -116,7 +98,7 @@ export function BookingRequestHero({ {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} ) : null} @@ -130,7 +112,12 @@ export function BookingRequestHero({ {booking.nextStep ? ( - + ) : null} @@ -143,19 +130,22 @@ export function BookingRequestHero({ minimumFractionDigits: 2, })}`} hint={booking.paymentStatus} + accent="green" /> - + @@ -174,8 +164,8 @@ function MetaItem({ }) { return ( - - + + {text} @@ -187,11 +177,13 @@ function HeroTile({ label, value, hint, + accent = "green", }: { icon: LucideIcon; label: string; value: ReactNode; hint?: ReactNode; + accent?: string; }) { return ( - + - + - + {label} - + {value} {hint ? ( - + {hint} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx index e9f90afd4..dbea71656 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx @@ -5,13 +5,12 @@ import { SegmentedControl, Stack, Text, + ThemeIcon, Title, } from "@mantine/core"; import { Activity, RefreshCw } from "lucide-react"; -import { freightBrand } from "@/theme/freight-brand"; import type { OverviewRange } from "@/types/overview"; -import "./overview.css"; const RANGE_OPTIONS = [ { label: "7 days", value: "7d" }, @@ -19,8 +18,6 @@ const RANGE_OPTIONS = [ { label: "90 days", value: "90d" }, ]; -const HERO_GRADIENT = `linear-gradient(125deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 50%, ${freightBrand.primaryLight} 125%)`; - function formatRelativeTime(iso: string | undefined) { if (!iso) return "—"; const diffMs = Date.now() - new Date(iso).getTime(); @@ -32,7 +29,7 @@ function formatRelativeTime(iso: string | undefined) { return new Date(iso).toLocaleString(); } -/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */ +/** Decorative line-art locomotive + rails — faint brand tint on the right. */ function TrainArtwork() { return ( - {/* rails */} - {/* locomotive body */} - - {/* cab windows */} + - {/* lower stripe */} - {/* wheels */} - {/* coupling */} - {/* headlight beam */} @@ -98,50 +84,28 @@ export function OverviewPageHeader({ position: "relative", overflow: "hidden", borderRadius: 20, - padding: "28px 28px", - background: HERO_GRADIENT, - boxShadow: freightBrand.shadow, + padding: "26px 28px", + background: "#ffffff", + border: "1px solid var(--mantine-color-gray-2)", + boxShadow: "0 1px 3px rgba(15,23,42,0.04)", }} > - {/* decorative glows */} - - - - - + + + + Freight Backoffice · Live - + <Title order={1} style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1, color: "#0f172a" }}> Operations Overview - + Real-time freight performance · updated {formatRelativeTime(generatedAt)} @@ -153,14 +117,10 @@ export function OverviewPageHeader({ data={RANGE_OPTIONS} size="sm" radius="lg" - classNames={{ - root: "ov-seg-root", - indicator: "ov-seg-indicator", - label: "ov-seg-label", - }} + color="green" /> { if (isAxiosError(error)) { @@ -115,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( @@ -157,13 +186,6 @@ export function AllocateBookingWizard({ const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined; const finalizeStep = hasContainerStep ? 3 : 2; - const stepLabels = [ - "Bookings", - "Wagon plan", - ...(hasContainerStep ? ["Containers"] : []), - "Finalize", - ]; - useEffect(() => { if (!opened) { setActiveStep(0); @@ -216,6 +238,21 @@ export function AllocateBookingWizard({ [routesQuery.data], ); + const displayWagonPlan = useMemo(() => { + const savedWagons = assignedSchedule?.trainSet?.wagons ?? []; + const physicalBySeq = new Map( + savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]), + ); + if (previewResult?.wagonPlan?.length) { + return previewResult.wagonPlan.map((slot) => ({ + ...slot, + physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null, + })); + } + if (savedWagons.length) return savedWagons; + return []; + }, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]); + const ensureSchedule = async (): Promise => { if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId; if (!routeId || !scheduleDate || !locomotiveId) { @@ -357,305 +394,607 @@ export function AllocateBookingWizard({ } }; + const amount = Number(booking.totalAmount); + const containers = booking.bookingContainers ?? []; + const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const weight = Number(booking.cargoTotalWeightVgm ?? 0); const holdCountdown = formatCountdown(booking.holdExpiresAt); - const stepDescription = - activeStep === 0 - ? "Select & preview" - : activeStep === 1 - ? "Allocations" - : hasContainerStep && activeStep === 2 - ? "Map units" - : "Depart"; + const containerComplete = + hasContainerStep && + containerUnits.length > 0 && + validateLocalPlacements(containerUnits, containerPlacements).length === 0; - const stepIcon = - activeStep === 0 - ? "package" - : activeStep === 1 - ? "layout" - : hasContainerStep && activeStep === 2 - ? "container" - : "check"; + const stepsMeta = [ + { + key: "bookings", + icon: Package, + title: "Bookings", + subtitle: "Select cargo & preview the plan", + complete: Boolean(previewResult) || Boolean(assignedSchedule), + }, + { + key: "wagon", + icon: LayoutGrid, + title: "Wagon plan", + subtitle: "Review generated allocations", + complete: displayWagonPlan.length > 0, + }, + ...(hasContainerStep + ? [ + { + key: "container", + icon: ContainerIcon, + title: "Containers", + subtitle: "Map units to wagon slots", + complete: containerComplete, + }, + ] + : []), + { + key: "finalize", + icon: CheckCircle2, + title: "Finalize", + subtitle: "Lock the plan & dispatch", + complete: allocationComplete, + }, + ]; + const completedCount = stepsMeta.filter((s) => s.complete).length; + const progressPct = Math.round((completedCount / stepsMeta.length) * 100); + const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i)); + + const renderStepRightSlot = (key: string) => { + if (key === "bookings") { + if (previewResult) { + return ( + + {previewResult.valid ? "Plan valid" : "Has issues"} + + ); + } + return allBookingIds.length ? ( + + {allBookingIds.length} selected + + ) : null; + } + if (key === "wagon" && displayWagonPlan.length) { + return ( + + {displayWagonPlan.length} wagons + + ); + } + if (key === "container" && containerUnits.length) { + return ( + + {containerUnits.length} units + + ); + } + if (key === "finalize" && allocationComplete) { + return ; + } + return null; + }; + + const renderStepBody = (key: string) => { + if (key === "bookings") { + return ( + + + + + Train schedule + + setScheduleMode(v as "existing" | "new")} + > + + + + + + + {scheduleMode === "existing" ? ( + ({ value: r.id, label: r.name }))} + value={routeId || null} + onChange={(v) => setRouteId(v ?? "")} + searchable + /> + ({ - value: s.id, - label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`, - }))} - value={selectedScheduleId} - onChange={setSelectedScheduleId} - searchable + + - ) : ( - - ({ - value: l.id, - label: l.code, - }))} - value={locomotiveId || null} - onChange={(v) => setLocomotiveId(v ?? "")} - searchable - /> - - )} + + + + {booking.schedulingStatus ? ( + + ) : null} + - - - ({ - id: b.id, - reference: b.reference ?? b.id.slice(0, 8), - weightTons: b.weightTons, - }))} - eligibleItems={eligibleQuery.data?.items ?? []} - eligibleLoading={eligibleQuery.isLoading} - selectedIds={allBookingIds} - onSelectionChange={(ids) => { - setExtraBookingIds(ids.filter((id) => id !== booking.id)); - }} - freightType={bookingFreightType} - /> - - - - setForceAssign(e.currentTarget.checked)} - /> - {previewResult ? ( - - - - - - ) : null} - - - - - - - {reschedulePlan?.displaced.length ? ( - - - - Government preempt — bookings to displace - - {reschedulePlan.displaced.map((b) => ( - - {b.reference} (priority {b.priorityScore}) - - ))} - setConfirmPreempt(e.currentTarget.checked)} + - - + } + > + Preview {previewResult.valid ? "valid" : "has issues"} + ) : null} - - + + + - + - - {!hasContainerStep ? ( - - ) : ( - - )} - + + + + + + {/* Workflow */} + + + + + + + + + + Allocation workflow + + + {completedCount} of {stepsMeta.length} steps complete · expand any step + to edit + + - - + + {progressPct}% + + } + /> + - {hasContainerStep ? ( - - - {!containerUnits.length ? ( - - - Run preview from the Bookings step to load container units for numbering. - - - ) : ( - - )} - - - - - - - ) : null} - - - - {allocationComplete ? ( - - - - - Allocation complete - - - Booking {booking.reference} is scheduled on train{" "} - {assignedSchedule?.trainSet?.locomotive?.code ?? "—"}. - - - - - - - - ) : ( - <> - - - Finalize moves the schedule to SCHEDULED and completes the booking - allocation. - - - - - - - )} - - - + + {stepsMeta.map((step, index) => ( + toggleStep(index)} + rightSlot={renderStepRightSlot(step.key)} + > + {renderStepBody(step.key)} + + ))} + + + ); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx new file mode 100644 index 000000000..9f30611d0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx @@ -0,0 +1,192 @@ +import { Fragment } from "react"; +import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { Check, Flag, MapPin, Train } from "lucide-react"; + +import { freightBrand } from "@/theme/freight-brand"; +import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling"; + +export interface RouteCorridorTrackProps { + stations: TrackStation[]; + /** Highest sequenceNo reached so far (−1 = not yet departed). */ + currentSequenceNo: number; + checkpoints: TrainCheckpoint[]; + /** True when the train is DISPATCHED and staff may log progress. */ + canLog: boolean; + loggingSeq?: number | null; + onLogCheckpoint?: (sequenceNo: number) => void; +} + +const COLUMN_WIDTH = 150; +const PASSED = freightBrand.primary; +const UPCOMING = "var(--mantine-color-gray-3)"; + +function railColor(active: boolean) { + return active ? PASSED : UPCOMING; +} + +export function RouteCorridorTrack({ + stations, + currentSequenceNo, + checkpoints, + canLog, + loggingSeq, + onLogCheckpoint, +}: RouteCorridorTrackProps) { + const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c])); + const lastIndex = stations.length - 1; + + return ( + + + {stations.map((station, index) => { + const passed = station.sequenceNo <= currentSequenceNo; + const isCurrent = station.sequenceNo === currentSequenceNo; + const isFinal = index === lastIndex; + const isNext = canLog && station.sequenceNo === currentSequenceNo + 1; + const checkpoint = bySeq.get(station.sequenceNo); + // left rail solid once this node is reached; right rail solid once the next node is reached + const leftActive = station.sequenceNo <= currentSequenceNo; + const rightActive = station.sequenceNo + 1 <= currentSequenceNo; + + return ( + + + {/* rail + node */} + + {index > 0 && ( + + )} + {index < lastIndex && ( + + )} + + {/* train marker hovering over the current node */} + {isCurrent && ( + + + + )} + + {/* node */} + + {passed ? ( + + ) : isFinal ? ( + + ) : ( + + )} + + + + {/* label */} + + + {station.label} + + {index === 0 ? ( + + Origin + + ) : isFinal ? ( + + Destination + + ) : null} + + + {/* checkpoint time or action */} + {checkpoint ? ( + + {new Date(checkpoint.occurredAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + ) : isNext ? ( + + ) : ( + + )} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx new file mode 100644 index 000000000..9e9a87fc8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx @@ -0,0 +1,252 @@ +import { useMemo, useState } from "react"; +import { + Badge, + Button, + Group, + Modal, + Paper, + Select, + Stack, + Table, + Text, + ThemeIcon, +} from "@mantine/core"; +import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react"; + +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { + useBatchActions, + useBookableSchedules, +} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useToast } from "@/hooks/use-toast"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; + +interface ScheduleBatchPanelProps { + schedule: TrainScheduleDetail; +} + +const windowColor: Record = { + OPEN: "green", + FULL: "orange", + CLOSED: "gray", +}; + +export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) { + const { toast } = useToast(); + const actions = useBatchActions(schedule.id); + const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN"; + const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED"; + + const [moveBookingId, setMoveBookingId] = useState(null); + const [moveTarget, setMoveTarget] = useState(null); + + const { data: targets } = useBookableSchedules( + schedule.originStation?.id, + schedule.destinationStation?.id, + ); + const moveOptions = useMemo( + () => + (targets ?? []) + .filter((s) => s.id !== schedule.id) + .map((s) => ({ + value: s.id, + label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( + s.scheduleDate, + ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`, + })), + [targets, schedule.id], + ); + + const bookings = schedule.bookings ?? []; + + const run = (fn: Promise, ok: string) => + fn + .then(() => toast({ title: ok })) + .catch(() => toast({ title: "Action failed", variant: "destructive" })); + + return ( + + + + + + +
+ Batch allocation + + {bookings.length} allocated · {schedule.trainSet?.wagonCount ?? 0} wagons used + +
+
+ + + Window: {windowStatus} + + +
+ + {!locked && ( + + + {windowStatus === "CLOSED" ? ( + + ) : ( + + )} + + )} + + {bookings.length === 0 ? ( + + No bookings allocated yet. The batch cron fills this schedule by priority; paid bookings are + assigned automatically. + + ) : ( + + + + Booking + Customer + Status + Actions + + + + {bookings.map((b) => ( + + + + {b.reference ?? b.id.slice(0, 8)} + + + + + {b.customer ?? "—"} + + + + + + + {!locked && ( + + {b.status !== "PAID" && ( + + )} + + + + )} + + + ))} + +
+ )} + + setMoveBookingId(null)} + title="Move booking to another schedule" + centered + radius="lg" + > + + I agree to EDR Freight{" "} @@ -386,7 +386,7 @@ const LoginPage = () => {
-
+
{!needsMfa ? loginForm : mfaForm}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index ffbe7dcb6..04c6a421d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -1,72 +1,360 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; +import { isAxiosError } from "axios"; import { + ActionIcon, + Badge, + Box, Button, - Card, Container, + Divider, + Grid, Group, NumberInput, + Paper, + SegmentedControl, Select, Stack, Switch, Text, + Textarea, TextInput, + ThemeIcon, Title, + Tooltip, } from "@mantine/core"; +import { + AlertTriangle, + ArrowLeft, + Boxes, + CalendarClock, + Container as ContainerIcon, + Flame, + Info, + Layers, + MapPin, + Package, + Plus, + Settings2, + Ship, + Trash2, + Weight, +} from "lucide-react"; import toast from "react-hot-toast"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { bookingsService } from "@/services/bookings.service"; +import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling"; +import { api } from "@/auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +interface CompanyOption { + id: string; + name?: string | null; + tin?: string | null; + email?: string | null; +} + +type FreightType = "CONTAINER" | "BULK"; + +interface RefNamed { + id: string; + name: string; + code: string; + country?: string; +} +interface RefContainerType { + id: string; + name: string; + code: string; + is_reefer?: boolean; + wagons_per_unit?: number; +} +interface RefContainerGroup { + size: string; + types: RefContainerType[]; +} +interface RefCargoChild { + id: string; + name: string; + code: string; + show_free_text_box?: boolean; +} +interface RefCargoGroup { + id: string; + name: string; + code: string; + children?: RefCargoChild[]; +} interface ReferenceData { - yard?: Array<{ id: string; name: string; code: string }>; - service?: Array<{ id: string; name: string; code: string }>; - containers?: Array<{ - size: string; - types: Array<{ id: string; name: string; code: string }>; - }>; - cargo_type?: Array<{ id: string; name: string; code: string }>; + yard?: RefNamed[]; + service?: RefNamed[]; + shipping_line?: RefNamed[]; + containers?: RefContainerGroup[]; + cargo_type?: RefCargoGroup[]; +} + +interface ContainerLine { + key: string; + containerTypeId: string | null; + quantity: number; + vgmPerUnitTons: number; +} + +let lineCounter = 0; +const newLine = (): ContainerLine => ({ + key: `line-${lineCounter++}`, + containerTypeId: null, + quantity: 1, + vgmPerUnitTons: 20, +}); + +const fmtTons = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`; + +type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; + +function deriveTradeDirectionFromYards( + origin?: RefNamed | null, + destination?: RefNamed | null, +): TradeDirection | null { + const originCountry = origin?.country?.trim(); + const destinationCountry = destination?.country?.trim(); + if (!originCountry || !destinationCountry) return null; + if (originCountry === "Djibouti") return "IMPORT"; + if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT"; + return "DOMESTIC"; +} + +const tradeDirectionLabel: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", +}; + +const parseBookingError = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + +/** Section card with a colored icon chip header. */ +function FormSection({ + icon: Icon, + title, + subtitle, + accent = "green", + right, + children, +}: { + icon: typeof Package; + title: string; + subtitle?: string; + accent?: string; + right?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {right} + + {children} + + ); } export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); + const [isGovernment, setIsGovernment] = useState(false); const [governmentInstitution, setGovernmentInstitution] = useState(""); - const [freightType, setFreightType] = useState<"CONTAINER" | "BULK">("CONTAINER"); + const [companyId, setCompanyId] = useState(null); + const [freightType, setFreightType] = useState("CONTAINER"); const [originYardId, setOriginYardId] = useState(null); const [destinationYardId, setDestinationYardId] = useState(null); + const [trainScheduleId, setTrainScheduleId] = useState(null); const [serviceTypeId, setServiceTypeId] = useState(null); const [scheduledDate, setScheduledDate] = useState(""); - const [weight, setWeight] = useState(100); - const [containerTypeId, setContainerTypeId] = useState(null); + const [paymentCurrency, setPaymentCurrency] = useState("ETB"); + + // container freight + const [lines, setLines] = useState([newLine()]); + + // bulk freight const [cargoTypeId, setCargoTypeId] = useState(null); + const [cargoFreeText, setCargoFreeText] = useState(""); + const [bulkWeight, setBulkWeight] = useState(100); + + // extra options + const [equipmentReturn, setEquipmentReturn] = useState("NA"); + const [isHazardous, setIsHazardous] = useState(false); + const [shippingLineId, setShippingLineId] = useState(null); + const [firstMilePickupAddress, setFirstMile] = useState(""); + const [lastMileDeliveryAddress, setLastMile] = useState(""); const { data: refData, isLoading } = useQuery({ queryKey: ["bookings", "reference-data"], queryFn: () => bookingsService.getReferenceData() as Promise, }); + const { data: companies, isLoading: companiesLoading } = useQuery({ + queryKey: ["companies", "list"], + queryFn: async () => { + const res = await api.get(URL_CONSTANTS.COMPANIES.BASE); + return unwrap(res.data) as CompanyOption[]; + }, + }); + + const companyOptions = (companies ?? []).map((c) => ({ + value: c.id, + label: c.name || c.email || c.tin || c.id, + })); + + const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules( + originYardId, + destinationYardId, + ); + const scheduleOptions = (bookableSchedules ?? []).map((s) => ({ + value: s.id, + label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( + s.scheduleDate, + ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`, + })); + const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId); + + // When a schedule is chosen its date IS the departure; otherwise fall back to the manual field. + const effectiveDepartureIso = selectedSchedule + ? new Date(selectedSchedule.scheduleDate).toISOString() + : scheduledDate + ? new Date(scheduledDate).toISOString() + : ""; + + const yardRecords = refData?.yard ?? []; + const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code })); + const originYard = yardRecords.find((y) => y.id === originYardId) ?? null; + const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null; + const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard); + const hasBookableSchedules = (bookableSchedules ?? []).length > 0; + + useEffect(() => { + setTrainScheduleId(null); + }, [originYardId, destinationYardId]); + const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); + const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); + + const containerGroupData = useMemo( + () => + (refData?.containers ?? []).map((g) => ({ + group: g.size, + items: g.types.map((t) => ({ + value: t.id, + label: `${t.code}${t.name && t.name !== t.code ? ` — ${t.name}` : ""}`, + })), + })), + [refData?.containers], + ); + + const { cargoData, freeTextById } = useMemo(() => { + const groups = refData?.cargo_type ?? []; + const freeText = new Map(); + const data = groups.map((g) => { + if (g.children?.length) { + g.children.forEach((c) => freeText.set(c.id, Boolean(c.show_free_text_box))); + return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) }; + } + return { value: g.id, label: g.name }; + }); + return { cargoData: data, freeTextById: freeText }; + }, [refData?.cargo_type]); + + const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false; + + // ---- derived totals ---- + const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0); + const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0); + const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight; + + // ---- validation ---- + const lineValid = (l: ContainerLine) => + Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0; + const allLinesValid = lines.length > 0 && lines.every(lineValid); + const sameYard = Boolean(originYardId && originYardId === destinationYardId); + + const scheduleSatisfied = + hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate); + const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate); + + const canSubmit = + Boolean(originYardId) && + Boolean(destinationYardId) && + !sameYard && + Boolean(tradeDirection) && + scheduleSatisfied && + Boolean(serviceTypeId) && + departureSatisfied && + (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && + (freightType === "BULK" + ? Boolean(cargoTypeId) && bulkWeight > 0 + : allLinesValid); + + const updateLine = (key: string, patch: Partial) => + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))); + const removeLine = (key: string) => + setLines((prev) => (prev.length === 1 ? prev : prev.filter((l) => l.key !== key))); + const createMutation = useMutation({ mutationFn: () => bookingsService.create({ isGovernment, governmentInstitution: isGovernment ? governmentInstitution : undefined, + companyId: isGovernment ? undefined : companyId || undefined, freightType, contractType: "NEW", - equipmentReturn: "NA", - tradeDirection: "IMPORT", - paymentCurrency: "ETB", - scheduledDate: scheduledDate || new Date().toISOString(), + equipmentReturn, + tradeDirection: tradeDirection!, + paymentCurrency, + isHazardous, + scheduledDate: effectiveDepartureIso || new Date().toISOString(), originYardId, destinationYardId, + trainScheduleId: trainScheduleId || undefined, serviceTypeId, - cargoTotalWeightVgm: weight, + shippingLineId: shippingLineId || undefined, + firstMilePickupAddress: firstMilePickupAddress.trim() || undefined, + lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined, + cargoTotalWeightVgm, cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined, + cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined, containers: - freightType === "CONTAINER" && containerTypeId - ? [{ containerTypeId, quantity: 1, vgmPerUnitTons: weight }] + freightType === "CONTAINER" + ? lines.map((l) => ({ + containerTypeId: l.containerTypeId, + quantity: l.quantity, + vgmPerUnitTons: l.vgmPerUnitTons, + })) : undefined, }), onSuccess: async (booking) => { @@ -79,36 +367,11 @@ export default function NewBookingPage() { void queryClient.invalidateQueries({ queryKey: ["bookings"] }); navigate(`/dashboard/booking-requests/${booking.id}`); }, - onError: () => toast.error("Failed to create booking"), + onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")), }); - const yards = (refData?.yard ?? []).map((y) => ({ - value: y.id, - label: y.name ?? y.code, - })); - const services = (refData?.service ?? []).map((s) => ({ - value: s.id, - label: s.name ?? s.code, - })); - const containerTypes = - refData?.containers?.flatMap((g) => - g.types.map((t) => ({ value: t.id, label: `${g.size} · ${t.code}` })), - ) ?? []; - const cargoTypes = (refData?.cargo_type ?? []).map((c) => ({ - value: c.id, - label: c.name ?? c.code, - })); - - const canSubmit = - originYardId && - destinationYardId && - serviceTypeId && - scheduledDate && - (!isGovernment || governmentInstitution.trim().length >= 2) && - (freightType === "BULK" ? cargoTypeId : containerTypeId); - return ( - + - - Create booking (staff) - - - - setIsGovernment(e.currentTarget.checked)} - /> - {isGovernment ? ( - setGovernmentInstitution(e.currentTarget.value)} - required - /> - ) : null} - - - + + {/* LEFT — form */} + + + + + setIsGovernment(e.currentTarget.checked)} + /> + {isGovernment ? ( + setGovernmentInstitution(e.currentTarget.value)} + required + /> + ) : ( + { + setOriginYardId(v); + setTrainScheduleId(null); + }} + searchable + disabled={isLoading} + error={sameYard ? "Same as destination" : undefined} + /> + + ) : originYardId && destinationYardId ? ( + + No open train schedule on this route — set a preferred departure below. Staff can + link a schedule later. + + ) : null} + + - ) : ( - setPaymentCurrency(v ?? "ETB")} + /> + + - - - - + {/* Cargo */} + {freightType === "CONTAINER" ? ( + + {totalContainers} container{totalContainers === 1 ? "" : "s"} + + } + > + + {lines.map((line, idx) => { + const invalid = !lineValid(line); + return ( + + + + {idx + 1} + + + {showFreeText ? ( +