From 421e0266bc43ca6d2301fc194c0c2ea51f5729ee Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 18 Jun 2026 14:12:46 +0000 Subject: [PATCH] feat(train-scheduling): implement day-level booking pool - Added `unplaced` method in `BookingNotifierService` to log warnings for bookings that cannot be placed on any train. - Introduced `getAvailableDays` method in `TrainSchedulingService` to retrieve distinct days with open departures for a given route. - Created `AvailableDaysQueryDto` for querying available days based on origin and destination yards. - Updated `TrainSchedulingController` to expose an endpoint for available days. - Modified frontend components to support day-level booking, allowing customers to select only a day without pinning to a specific train. - Removed references to train schedules in booking forms and review steps, emphasizing day selection. - Added a database migration to create an index for efficient querying of bookings by route and day. --- .../1784100000000-AddBookingRouteDayIndex.ts | 21 ++ .../modules/bookings/bookings.repository.ts | 55 ++- .../src/modules/bookings/bookings.service.ts | 19 +- .../bookings/dto/create-booking.dto.ts | 13 +- .../bookings/entities/booking.entity.ts | 10 +- .../train-scheduling/batch-window.util.ts | 10 + .../booking-batch.service.spec.ts | 113 +++++- .../train-scheduling/booking-batch.service.ts | 227 ++++++++++-- .../booking-notifier.service.ts | 11 + .../dto/available-days-query.dto.ts | 14 + .../train-scheduling.controller.ts | 15 + .../train-scheduling.service.ts | 48 ++- .../src/pages/bookings/NewBookingPage.tsx | 18 +- .../portal/src/constants/URLS.ts | 1 + .../src/pages/bookings/EditBookingPage.tsx | 4 +- .../src/pages/bookings/NewBookingPage.tsx | 3 +- .../pages/bookings/new-booking-form/schema.ts | 6 +- .../new-booking-form/step-scheduling.tsx | 327 +++--------------- .../new-booking-form/step8-review.tsx | 8 +- .../portal/src/services/api.ts | 7 + .../portal/src/services/bookings.service.ts | 14 + packages/types/src/freight/index.ts | 14 + 22 files changed, 626 insertions(+), 332 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts new file mode 100644 index 000000000..544600bef --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Day-level booking pool: customers select a DAY (route + day), not a specific + * train. The batch engine's pool query filters bookings on + * (origin_yard_id, destination_yard_id, scheduled_date, status); this partial + * index backs that scan. + */ +export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_route_day + ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`); + } +} 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 edda990de..b173bfe68 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -689,6 +689,11 @@ export class BookingsRepository extends BaseRepository { destinationStationId?: string; schedulingStatus?: string; trainScheduleId?: string; + /** + * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees + * the whole (route, day) pool rather than bookings pre-targeted to one train. + */ + day?: string; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -706,9 +711,16 @@ 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) { + // Day-level pooling: customers no longer set train_schedule_id, so the wizard + // surfaces the whole (route, EAT day) pool. Fall back to the legacy + // single-schedule filter only when no day is supplied (e.g. a staff-pinned + // booking that still carries train_schedule_id). + if (options.day) { + qb.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day: options.day }, + ); + } else if (options.trainScheduleId) { qb.andWhere('booking.train_schedule_id = :trainScheduleId', { trainScheduleId: options.trainScheduleId, }); @@ -765,6 +777,43 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Day-level batch pool: ready, not-yet-allocated bookings on a route for one + * EAT calendar day, regardless of which train they end up on. Same status + * rules and ordering as {@link findBatchPool}, but keyed on + * (origin, destination, day) instead of train_schedule_id — the engine then + * distributes these across all trains departing that day. + */ + findBatchPoolByRouteDay( + originYardId: string, + destinationYardId: string, + day: 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.origin_yard_id = :originYardId', { originYardId }) + .andWhere('booking.destination_yard_id = :destinationYardId', { + destinationYardId, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .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 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 b32b16b32..4c8ef2cab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -273,8 +274,8 @@ export class BookingsService { companyId = company.id; } - // Schedule targeting: when provided, the schedule must be OPEN and on the same route. if (dto.trainScheduleId) { + // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: dto.trainScheduleId } }); @@ -290,6 +291,22 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } + } else { + // Day-level pool: the customer picked a DAY — require that the route has at + // least one OPEN departure on that EAT day. The batch engine assigns the + // train later. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + dto.originYardId, + dto.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } } const reference = dto.reference || (await this.generateReference()); 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 3c7eca391..fa3bb6f4d 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,12 +91,21 @@ 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)' }) + /** + * Staff-only manual pin to a specific train. Customers omit this — they pick a + * DAY via {@link scheduledDate} and the batch engine assigns a train within + * that (route, day) pool. When provided, the schedule must be OPEN and on the + * booking route. + */ + @ApiPropertyOptional({ + format: 'uuid', + description: 'Staff only: pin to a specific train schedule. Customers omit this.', + }) @IsOptional() @IsUUID() trainScheduleId?: string; + /** The day the customer wants to ship (the pool day key). */ @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 d6f7f9b55..d5e72d860 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 @@ -277,7 +277,15 @@ 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. */ + /** + * The train this booking is assigned to. FK to train_schedules. + * + * Day-level pooling: customers no longer pick a train — they pick a DAY, and + * this stays null at creation. The batch engine sets it when it assigns the + * booking to a specific train within its (route, day) pool; staff may also + * pin it manually. The day-level pool is keyed on + * (origin_yard_id, destination_yard_id, day of scheduled_date), not this column. + */ @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; 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 index 38ab407bc..7316e1610 100644 --- 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 @@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts { }; } +/** + * The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day + * key for day-level booking pools — it must match the day the portal calendar + * renders, so always derive day keys through this (never `toISOString().slice`). + */ +export function eatDay(date: Date): string { + const { year, month, day } = eatParts(date); + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + /** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */ function eatToUtc( year: number, 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 index a08dd71ec..5cd06091a 100644 --- 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 @@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => { let bookingsRepository: { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; + findBatchPoolByRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => { }; let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; + getBookableSchedules: jest.Mock; }; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; }; + let notifier: { + payNow: jest.Mock; + secured: jest.Mock; + expired: jest.Mock; + unplaced: jest.Mock; + }; beforeEach(() => { bookingsRepository = { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), + findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => { issues: [], violations: [], }), + getBookableSchedules: jest.fn().mockResolvedValue([]), }; const bookingRepo = { findOne: jest.fn().mockResolvedValue(paidBooking), update: jest.fn().mockResolvedValue(undefined), + // WagonType.find() / global-rules find() fall back to defaults when empty. + find: jest.fn().mockResolvedValue([]), }; dataSource = { getRepository: jest.fn().mockReturnValue(bookingRepo), @@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => { }), }; + notifier = { + payNow: jest.fn(), + secured: jest.fn(), + expired: jest.fn(), + unplaced: jest.fn(), + }; + 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, + notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, ); @@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => { expect(fillOrder).toBeLessThan(reconcileOrder); expect(reconcileOrder).toBeLessThan(wagonOrder); }); + + describe('fillRouteDay — day-level distribution', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + // 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day. + const trainA = 'train-a'; + const trainB = 'train-b'; + + // A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits. + const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 }; + + const commercial = (id: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + }) as unknown as Booking; + + beforeEach(() => { + // Two OPEN trains on the same route + day, train A earlier than train B. + trainSchedulingService.getBookableSchedules.mockResolvedValue([ + { + id: trainA, + scheduleDate: '2026-06-20T06:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + { + id: trainB, + scheduleDate: '2026-06-20T09:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + ]); + trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => + Promise.resolve({ + id, + maxWagons: 1, + bookingWindowStatus: 'OPEN', + trainSetId: `set-${id}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + }), + ); + }); + + it('spills overflow to the next train by priority, then reports unplaced', async () => { + // 3 commercial bookings, descending priority; only 1 fits per train (2 total). + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + commercial('hi', 30), + commercial('mid', 20), + commercial('lo', 10), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith( + originYardId, + destinationYardId, + day, + ); + // Both trains were processed. + expect(touched).toEqual([trainA, trainB]); + // Highest priority reserved on train A, next on train B (commercial → reserve). + const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedOn).toEqual(['hi', 'mid']); + // The third booking fits no train and is reported unplaced (and only it). + expect(notifier.unplaced).toHaveBeenCalledTimes(1); + expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo'); + expect(notifier.unplaced.mock.calls[0][1]).toBe(day); + }); + + it('reserves the chosen train id on each commercial booking', async () => { + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // reserve() persists trainScheduleId so the settle lifecycle can find the train. + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'hi', + expect.objectContaining({ + trainScheduleId: trainA, + status: 'SELECTED_FOR_BATCH', + }), + ); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 771422fa2..514f8572d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; -import { groupBookingsIntoBoardWindows } from './batch-window.util'; +import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { BATCH_CRON, BATCH_TIMEZONE, @@ -42,6 +42,14 @@ interface Capacity { lengthMeters: number; } +/** A day-level pool key: all trains on this route departing on this EAT day. */ +interface RouteDayGroup { + originYardId: string; + destinationYardId: string; + /** EAT calendar day, `yyyy-MM-dd`. */ + day: string; +} + type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = @@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit { private readonly trainSchedulingService: TrainSchedulingService, ) {} - /** On boot, reconcile OPEN schedules and re-arm settle timers. */ + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, - }); - for (const s of open) { + const groups = await this.openRouteDayGroups(); + for (const group of groups) { try { - await this.processSchedule(s.id); + await this.processRouteDay(group); } catch (err) { - this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`); + this.logger.warn( + `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); } } const reserved = await this.dataSource @@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit { for (const { scheduleId } of reserved) this.armSettle(scheduleId); } - /** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */ + /** + * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to + * (contract sign, payment). Day-level pooling distributes across all of that + * day's trains, so a single schedule id maps to its whole route-day group. + */ enqueueScheduleProcessing(scheduleId: string): void { - void this.processSchedule(scheduleId).catch((err) => - this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`), + void this.processRouteDayForSchedule(scheduleId).catch((err) => + this.logger.error( + `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`, + ), ); } + /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ + private async processRouteDayForSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return; + await this.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + } + + /** + * Day-level pipeline: distribute the (route, day) pool across all its trains, + * then settle / reconcile / assign wagons per schedule (those steps stay + * schedule-scoped — only the fill is day-level). + */ + async processRouteDay(group: RouteDayGroup): Promise { + const scheduleIds = await this.fillRouteDay( + group.originYardId, + group.destinationYardId, + group.day, + ); + for (const scheduleId of scheduleIds) { + await this.settleDueReservations(scheduleId); + await this.reconcilePaidUnlinked(scheduleId); + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + } + } + /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */ async processSchedule(scheduleId: string): Promise { await this.fillSchedule(scheduleId); @@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + private async openRouteDayGroups(): Promise { + const open = await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: 'OPEN' }, + }); + const groups = new Map(); + for (const s of open) { + if (!s.scheduledDepartureDate) continue; + const day = eatDay(s.scheduledDepartureDate); + const key = `${s.originStationId}|${s.destinationStationId}|${day}`; + if (!groups.has(key)) { + groups.set(key, { + originYardId: s.originStationId, + destinationYardId: s.destinationStationId, + day, + }); + } + } + return [...groups.values()]; + } + + private groupLabel(group: RouteDayGroup): string { + return `${group.originYardId}→${group.destinationYardId} on ${group.day}`; + } + /** * Idempotent: link a paid batch booking to its schedule and assign wagons. * Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases. @@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit { @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) { + const groups = await this.openRouteDayGroups(); + this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); + for (const group of groups) { try { - await this.processSchedule(s.id); + await this.processRouteDay(group); } catch (err) { - this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`); + this.logger.error( + `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); } } } @@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit { if (booking.isGovernment) { await this.allocate(scheduleId, booking, 'gov'); } else { - await this.reserve(booking); + await this.reserve(booking, scheduleId); armed = true; } budget = this.subtract(budget, need); @@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(scheduleId); } + /** + * Distribute one (route, day) pool across ALL of that day's OPEN trains, by + * priority, filling each train (earliest departure first) until it's full and + * spilling overflow to the next. Government bookings that fit no train preempt + * lower-priority commercial; bookings that fit no train at all stay pending and + * trigger a staff `unplaced` warning. Returns the schedule ids that were touched + * (or that had remaining pool work) so the caller can settle them per-schedule. + */ + async fillRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + // The day's OPEN bookable schedules on this exact corridor, earliest first. + const bookable = await this.trainSchedulingService.getBookableSchedules( + originYardId, + destinationYardId, + ); + const scheduleIds = bookable + .filter( + (s) => + s.bookingWindowStatus === 'OPEN' && + s.scheduleDate != null && + eatDay(new Date(s.scheduleDate)) === day, + ) + .sort( + (a, b) => + new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + ) + .map((s) => s.id); + + if (scheduleIds.length === 0) return []; + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + + // Live per-schedule budget + arm flag, in departure order. + const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; + for (const id of scheduleIds) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !schedule.trainSetId || !locomotive) { + this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + continue; + } + const limits = await this.capacityLimits(locomotive, rules); + await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + trains.push({ id, budget, armed: false }); + } + if (trains.length === 0) return []; + + const pool = await this.bookingsRepository.findBatchPoolByRouteDay( + originYardId, + destinationYardId, + day, + ); + + for (const booking of pool) { + const need = this.needFor(booking, wagonLengths); + + // First train (earliest departure) that fits this booking as-is. + let target = trains.find((t) => this.fits(need, t.budget)); + + if (!target && booking.isGovernment) { + // Government booking fits nowhere on its own — try to preempt commercial + // on each train (earliest first) until one frees enough room. + for (const t of trains) { + t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + if (this.fits(need, t.budget)) { + target = t; + break; + } + } + } + + if (!target) { + // Fits no train this day — stays in the pool, retried next batch. + this.notifier.unplaced(booking, day); + continue; + } + + if (booking.isGovernment) { + await this.allocate(target.id, booking, 'gov'); + } else { + await this.reserve(booking, target.id); + target.armed = true; + } + target.budget = this.subtract(target.budget, need); + } + + for (const t of trains) { + if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.armed) this.armSettle(t.id); + void this.triggerWagonAllocation(t.id); + } + + return trains.map((t) => t.id); + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); @@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit { // ---- mutations ------------------------------------------------------------ - /** Reserve capacity for a commercial booking and open its pay window. */ - private async reserve(booking: Booking): Promise { + /** + * Reserve capacity for a commercial booking on a specific train and open its + * pay window. `scheduleId` is persisted so the settle/allocate lifecycle + * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid), + * which is all keyed off `booking.trainScheduleId`, can find the train — with + * day-level pooling the booking arrives here with `trainScheduleId` still null, + * so the engine sets it as it picks the train. + */ + private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, status: 'SELECTED_FOR_BATCH', selectedForBatchAt: now, paymentDeadline: deadline, } as never); + booking.trainScheduleId = scheduleId; await this.notifier.payNow(booking, deadline); } @@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(scheduleId); } - /** Expire an unpaid reservation and free its capacity. */ + /** + * Expire an unpaid reservation and free its capacity. With day-level pooling we + * also clear `trainScheduleId` so the booking is no longer pinned to the train + * it failed to pay for — it's back in the day pool for staff to act on. + */ private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, status: 'EXPIRED', schedulingStatus: 'ELIGIBLE', paymentDeadline: null, selectedForBatchAt: null, } as never); + booking.trainScheduleId = null; this.notifier.expired(booking); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e63bc1aec..e8f272123 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -67,6 +67,17 @@ export class BookingNotifierService { ); } + /** + * Staff-facing warning when a pooled booking fits no train on its chosen day. + * It stays pending and is retried next batch; staff can add capacity or pin it + * to a train manually. Mirrors {@link scheduleFull} — no customer notification. + */ + unplaced(b: Booking, day: string): void { + this.logger.warn( + `UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`, + ); + } + 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/dto/available-days-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts new file mode 100644 index 000000000..bb55508e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class AvailableDaysQueryDto { + @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 2a4c3a357..0c01dd219 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 @@ -32,6 +32,7 @@ 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 { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; @@ -108,6 +109,20 @@ export class TrainSchedulingController { ); } + @Get("available-days") + // No staff guard: customers hit this while creating a booking to find which + // DAYS have a departure on their route. Day-level pooling — no capacity is + // returned, only the list of bookable days. + @ApiOperation({ + summary: "Distinct days with an OPEN same-route departure (day-level pool)", + }) + getAvailableDays(@Query() query: AvailableDaysQueryDto) { + return this.trainSchedulingService.getAvailableDays( + query.originYardId, + query.destinationYardId, + ); + } + @Get("container/eligible-bookings") @TrainSchedulingView() @ApiOperation({ summary: "List eligible container bookings" }) 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 98b6d70c2..7bfe5811e 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 @@ -87,6 +87,7 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -167,12 +168,28 @@ export class TrainSchedulingService { ) {} async getEligibleBookings(query: GetEligibleBookingsDto) { + // Day-level pooling: when the wizard targets a schedule, surface the whole + // (route, EAT day) pool — not just bookings pre-pinned to that train — by + // resolving the schedule's route + day and filtering on the day instead. + let day: string | undefined; + let originStationId = query.originStationId; + let destinationStationId = query.destinationStationId; + if (query.trainScheduleId) { + const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); + if (schedule?.scheduledDepartureDate) { + day = eatDay(schedule.scheduledDepartureDate); + originStationId = originStationId ?? schedule.originStationId; + destinationStationId = destinationStationId ?? schedule.destinationStationId; + } + } + const bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, - originStationId: query.originStationId, - destinationStationId: query.destinationStationId, + originStationId, + destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, + day, }); return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; } @@ -1989,6 +2006,33 @@ export class TrainSchedulingService { return filteredSchedules; } + /** + * Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable + * departure on the route. Customers pick a DAY (not a train) — so this returns + * only the day strings, no capacity, counts or train info. + */ + async getAvailableDays( + originYardId?: string, + destinationYardId?: string, + ): Promise<{ days: string[] }> { + const schedules = await this.getBookableSchedules(originYardId, destinationYardId); + const days = new Set(); + for (const s of schedules) { + if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate))); + } + return { days: [...days].sort() }; + } + + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ + async existsOpenScheduleOnRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + return days.includes(day); + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { 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 38be6dd31..86c188d86 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -302,8 +302,9 @@ export default function NewBookingPage() { const allLinesValid = lines.length > 0 && lines.every(lineValid); const sameYard = Boolean(originYardId && originYardId === destinationYardId); - const scheduleSatisfied = - hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate); + // Day-level pool: a shipment DAY is enough to proceed. Pinning a specific train + // (trainScheduleId) is an optional staff override — the batch engine otherwise + // assigns the train. A selected schedule implies its day, so either satisfies. const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate); const canSubmit = @@ -311,7 +312,6 @@ export default function NewBookingPage() { Boolean(destinationYardId) && !sameYard && Boolean(tradeDirection) && - scheduleSatisfied && Boolean(serviceTypeId) && departureSatisfied && (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && @@ -473,25 +473,25 @@ export default function NewBookingPage() { {hasBookableSchedules ? (