import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BookingBatchService } from './booking-batch.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { eatDay } from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; /** * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS * booking window for EXPORT schedules. All state lives in DB timestamps on the * schedule row, so every transition is derived purely from the clock — a restart * resumes mid-phase with no loss (onModuleInit runs one tick immediately). * * Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept * documents) → PAYMENT (batch reserves in priority order, customers pay) → * reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). * Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy * fill (runBatchFill), which this tick invokes every 5th minute. */ @Injectable() export class BookingWindowService implements OnModuleInit { private readonly logger = new Logger(BookingWindowService.name); private ticking = false; private tickCount = 0; constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly trainSchedulesRepository: TrainSchedulesRepository, private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, ) {} async onModuleInit(): Promise { await this.tick().catch((err) => this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), ); } @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) async tick(): Promise { if (this.ticking) return; this.ticking = true; try { const now = new Date(); const cfg = await this.trainSchedulingService.getWindowConfig(); const active = ( await this.trainSchedulesRepository.findAll({ where: [ { status: TrainScheduleStatusEnum.Draft }, { status: TrainScheduleStatusEnum.Scheduled }, ], }) ).filter( (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', ); for (const schedule of active) { try { await this.advanceSchedule(schedule, cfg, now); } catch (err) { this.logger.error( `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, ); } } await this.settleOverdueReservations(); // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. this.tickCount += 1; if (this.tickCount % 5 === 0) { await this.bookingBatchService.runBatchFill(); } } finally { this.ticking = false; } } /** Staff finished document review early — start the batch/payment phase now. */ async completeDocReview(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } if (schedule.windowPhase !== 'DOC_REVIEW') { // Idempotent for the whole route-day group: only DOC_REVIEW schedules move. return schedule; } const now = new Date(); const cfg = await this.trainSchedulingService.getWindowConfig(); // Stamp the whole route-day group so one staff action releases every train // sharing this booking day's pool. const group = ( await this.trainSchedulesRepository.findAll({ where: { originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, }, }) ).filter( (s) => s.windowPhase === 'DOC_REVIEW' && s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate), ); for (const s of group) { await this.dataSource .getRepository(TrainSchedule) .update(s.id, { docReviewCompletedAt: now }); s.docReviewCompletedAt = now; await this.advanceSchedule(s, cfg, now); } const fresh = await this.trainSchedulesRepository.findById(scheduleId); return fresh ?? schedule; } // ---- transitions ------------------------------------------------------------ private async advanceSchedule( schedule: TrainSchedule, cfg: BookingWindowConfig, now: Date, ): Promise { // Apply every transition that is due, in order (fast-forwards after downtime). for (let guard = 0; guard < 6; guard += 1) { const advanced = schedule.direction === 'EXPORT' ? await this.advanceExport(schedule, now) : await this.advanceImport(schedule, cfg, now); if (!advanced) return; } } /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */ private async advanceExport(schedule: TrainSchedule, now: Date): Promise { if ( schedule.windowPhase === 'PRE_WINDOW' && schedule.windowOpensAt && now >= schedule.windowOpensAt ) { await this.setPhase(schedule, { windowPhase: 'OPEN', bookingCycleNo: schedule.bookingCycleNo + 1, }); if (schedule.bookingWindowStatus !== 'FULL') { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } if ( schedule.windowPhase === 'OPEN' && schedule.windowClosesAt && now >= schedule.windowClosesAt ) { await this.setPhase(schedule, { windowPhase: 'DONE' }); if (schedule.bookingWindowStatus === 'OPEN') { await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); schedule.bookingWindowStatus = 'CLOSED'; } return true; } return false; } private async advanceImport( schedule: TrainSchedule, cfg: BookingWindowConfig, now: Date, ): Promise { const { windowPhase, windowOpensAt, windowClosesAt } = schedule; if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { await this.setPhase(schedule, { windowPhase: 'OPEN', bookingCycleNo: schedule.bookingCycleNo + 1, docReviewCompletedAt: null, docReviewEndsAt: null, paymentPhaseEndsAt: null, }); if (schedule.bookingWindowStatus !== 'FULL') { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } this.logger.log( `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, ); return true; } if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) { const docReviewEndsAt = new Date( windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000, ); await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt }); if (schedule.bookingWindowStatus === 'OPEN') { await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); schedule.bookingWindowStatus = 'CLOSED'; } this.logger.log( `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, ); return true; } if ( windowPhase === 'DOC_REVIEW' && (schedule.docReviewCompletedAt != null || (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt)) ) { const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); // Run the batch: priority fill over the route-day pool, reserving pay windows // (or allocating government) — skipped automatically for everyone who fits // is handled inside the fill (all fit → all reserved → all notified). await this.bookingBatchService.processRouteDay({ originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day: eatDay(schedule.scheduledDepartureDate), }); this.logger.log( `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, ); return true; } if ( windowPhase === 'PAYMENT' && schedule.paymentPhaseEndsAt != null && now >= schedule.paymentPhaseEndsAt ) { await this.bookingBatchService.settleDueReservations(schedule.id); await this.concludeCycle(schedule, cfg, now); return true; } return false; } /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ private async concludeCycle( schedule: TrainSchedule, cfg: BookingWindowConfig, now: Date, ): Promise { const full = await this.bookingBatchService.isScheduleFull(schedule.id); if (full) { await this.bookingBatchService.setWindow(schedule.id, 'FULL'); await this.setPhase(schedule, { windowPhase: 'DONE' }); await this.tryAutoFinalize(schedule.id); return; } const closesAt = schedule.windowClosesAt ?? now; const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); const nextOpensAt = reopenAt > now ? reopenAt : now; let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); if (nextClosesAt > schedule.scheduledDepartureDate) { nextClosesAt = schedule.scheduledDepartureDate; } const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; if (sameBookingDay && beforeDeparture) { await this.setPhase(schedule, { windowPhase: 'PRE_WINDOW', windowOpensAt: nextOpensAt, windowClosesAt: nextClosesAt, docReviewCompletedAt: null, docReviewEndsAt: null, paymentPhaseEndsAt: null, }); this.logger.log( `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, ); } else { await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); this.logger.log( `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, ); } } private async tryAutoFinalize(scheduleId: string): Promise { try { await this.trainSchedulingService.finalizeSchedule(scheduleId); this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`); } catch (err) { // Not DRAFT / no linked bookings yet — staff finalize manually. this.logger.warn( `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`, ); } } /** Durable settle backstop: expire/allocate reservations whose deadline passed. */ private async settleOverdueReservations(): Promise { const overdue = 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.payment_deadline <= now()') .andWhere('b.train_schedule_id IS NOT NULL') .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of overdue) { try { await this.bookingBatchService.settleDueReservations(scheduleId); } catch (err) { this.logger.warn( `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`, ); } } } private async setPhase( schedule: TrainSchedule, patch: Partial< Pick< TrainSchedule, | 'windowPhase' | 'windowOpensAt' | 'windowClosesAt' | 'docReviewEndsAt' | 'docReviewCompletedAt' | 'paymentPhaseEndsAt' | 'bookingCycleNo' > >, ): Promise { await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch); Object.assign(schedule, patch); } }