From cde3e462bab1d10bda2df3ba3345c90016c4b76c Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 11 Jun 2026 19:42:23 +0000 Subject: [PATCH] automate the schedule --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 2 + .../1781000000002-AddBatchBookingFields.ts | 45 +++ .../bookings/booking-payment.service.ts | 2 +- .../modules/bookings/bookings.controller.ts | 21 +- .../modules/bookings/bookings.repository.ts | 50 +++ .../src/modules/bookings/bookings.service.ts | 24 ++ .../bookings/dto/create-booking.dto.ts | 6 + .../bookings/entities/booking.entity.ts | 8 + .../src/modules/payment/payment.service.ts | 10 +- .../entities/train-schedule.entity.ts | 4 + .../booking-batch.constants.ts | 15 + .../train-scheduling/booking-batch.service.ts | 377 ++++++++++++++++++ .../booking-notifier.service.ts | 49 +++ .../dto/bookable-schedules-query.dto.ts | 14 + .../train-scheduling.controller.ts | 65 ++- .../train-scheduling.module.ts | 11 +- .../train-scheduling.service.ts | 54 ++- .../trainScheduling/ScheduleBatchPanel.tsx | 252 ++++++++++++ .../backoffice/src/constants/URLS.ts | 9 + .../bookings/booking-status.config.ts | 12 + .../trainScheduling/useTrainScheduling.ts | 57 +++ .../src/pages/bookings/NewBookingPage.tsx | 41 +- .../TrainScheduleV2DetailPage.tsx | 3 + .../src/services/trainScheduling.service.ts | 48 +++ .../backoffice/src/types/trainScheduling.ts | 15 + packages/types/src/freight/index.ts | 11 + pnpm-lock.yaml | 3 + 28 files changed, 1197 insertions(+), 12 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/bookable-schedules-query.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 15f99cf2a..8b4553a64 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -28,6 +28,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/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/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 07ef6a183..268766a83 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', '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/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.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 46600b148..a5180eb85 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -703,6 +703,56 @@ 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.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(); + } + + /** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */ + findReservedForSchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .andWhere(`booking.status = 'AWAITING_PAYMENT'`) + .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..cd69c1a5f 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,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +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 +43,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, @@ -199,6 +203,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({ @@ -235,6 +258,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, 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..7af475f5e 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 @@ -274,6 +274,14 @@ 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 1h pay window once the booking is AWAITING_PAYMENT. */ + @Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true }) + paymentDeadline?: Date | null; + @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; 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 53ee9f8d6..44fe2e2a1 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -12,7 +12,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 { @@ -125,7 +125,13 @@ export class PaymentService { if (result.status === ProviderPaymentStatus.SUCCEEDED) { await this.datasource.transaction(async (mg) => { - await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) + const booking = await mg.findOne(Booking, { where: { id: resp.refId } }) + if (booking?.status === "AWAITING_PAYMENT") { + // Batch flow: mark paid but keep the reservation — the batch settle job allocates it. + await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" }) + } else { + await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) + } await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) }) } 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/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts new file mode 100644 index 000000000..83b6e3655 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -0,0 +1,15 @@ +/** + * 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_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 + +/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ +export const DEFAULT_WAGONS_PER_BOOKING = 1; 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..b35db8761 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -0,0 +1,377 @@ +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 { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { BookingNotifierService } from './booking-notifier.service'; +import { + BATCH_CRON, + BATCH_TIMEZONE, + DEFAULT_WAGONS_PER_BOOKING, + PAYMENT_WINDOW_MS, +} from './booking-batch.constants'; + +/** + * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool + * by priority, greedily fills the train to capacity (skipping oversized bookings), + * 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 here is modelled by wagon count (`schedule.maxWagons`); locomotive weight/length + * is still enforced by the existing assignment path when staff pin wagons. + */ +@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, + ) {} + + /** On boot, re-arm a settle timeout for any schedule that still has live reservations. */ + async onModuleInit(): Promise { + const reserved = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status = 'AWAITING_PAYMENT'`) + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of reserved) this.armSettle(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.fillSchedule(s.id); + } catch (err) { + this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`); + } + } + } + + // ---- 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; + if (!schedule.trainSetId || !schedule.trainSet?.locomotive) { + this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + return; + } + + let remaining = await this.remainingWagons(schedule); + if (remaining <= 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.wagonsFor(booking); + + if (need > remaining) { + if (booking.isGovernment) { + remaining = await this.preemptForGovernment(scheduleId, need, remaining); + if (need > remaining) continue; // still doesn't fit even after preempt + } else { + continue; // skip oversized commercial, try the next + } + } + + if (booking.isGovernment) { + await this.allocate(scheduleId, booking, 'gov'); + } else { + await this.reserve(booking); + armed = true; + } + remaining -= need; + if (remaining <= 0) break; + } + + if (remaining <= 0) await this.setWindow(scheduleId, 'FULL'); + if (armed) this.armSettle(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); + } + + // ---- 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'); + } + } + + /** + * 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, + } 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 1h pay window. */ + private async reserve(booking: Booking): Promise { + const deadline = new Date(Date.now() + PAYMENT_WINDOW_MS); + await this.bookingsRepository.update(booking.id, { + status: 'AWAITING_PAYMENT', + paymentDeadline: deadline, + } as never); + 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, + } as never); + }); + this.notifier.secured(booking, reason); + } + + /** 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, + } 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: number, + remaining: number, + ): 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), + ); + + for (const victim of candidates) { + if (need <= remaining) 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, + } as never); + }); + this.notifier.displaced(victim); + remaining += this.wagonsFor(victim); + } + return remaining; + } + + // ---- 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); + } + + /** 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..e475ef1ae --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * Stub notifier for the batch flow — **console.log only** for now. + * Injectable so it can later be swapped for the real NotificationsService without + * touching the batch engine. + */ +@Injectable() +export class BookingNotifierService { + private readonly logger = new Logger('BookingNotifier'); + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + payNow(b: Booking, deadline: Date): void { + this.logger.log( + `PAY NOW — ${this.ref(b)} selected for schedule ${b.trainScheduleId}; pay before ${deadline.toISOString()} (1h).`, + ); + } + + secured(b: Booking, reason: 'paid' | 'gov'): void { + this.logger.log( + `ALLOCATED — ${this.ref(b)} secured on schedule ${b.trainScheduleId}${ + reason === 'gov' ? ' (government, unpaid)' : '' + }.`, + ); + } + + expired(b: Booking): void { + this.logger.warn( + `EXPIRED — ${this.ref(b)} did not pay in time; can move to another schedule or cancel (no re-approval).`, + ); + } + + 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 { + this.logger.warn( + `DISPLACED — ${this.ref(b)} bumped by a government booking; move to another schedule or cancel.`, + ); + } +} 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/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 80afca1d5..72f051f12 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -22,14 +22,19 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; +import { 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() @@ -52,6 +57,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.getEligibleBookings(query); } + @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' }) @@ -162,6 +177,54 @@ 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.trainSchedulingService.getContainerTrainScheduleById(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' }) 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 dd8f1837d..9d0a94c78 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 @@ -19,6 +19,8 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r 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'; @Module({ imports: [ @@ -41,7 +43,12 @@ import { TrainSchedulingService } from './train-scheduling.service'; RuleEngineModule, ], controllers: [TrainSchedulingController], - providers: [TrainSchedulingService, TrainCheckpointEventsRepository], - 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.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 4735c8386..8a6a04d97 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 @@ -588,11 +588,34 @@ 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 = '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 }; @@ -937,7 +960,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))]; @@ -1591,9 +1615,37 @@ 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), + ), }; } + /** 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, ) { 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" + > + +