import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, 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 { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { eatDay, nextCycleOpensAt, type OfficeHours } 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 & domestic 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). * Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy * fill (runBatchFill), which this tick invokes every 5th minute. New schedules of * every direction get a window phase. */ @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, private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, private readonly gateway: BookingWindowGateway, ) {} async onModuleInit(): Promise { await this.tick().catch((err) => this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), ); } // 10-second cadence: every transition is derived from persisted timestamps // and applied idempotently, so a finer tick only shrinks the lag between a // deadline passing and the phase actually moving (was a full minute). @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) async tick(): Promise { if (this.ticking) return; this.ticking = true; try { const now = new Date(); const liveCfg = await this.trainSchedulingService.getWindowConfig(); const active = ( await this.trainSchedulesRepository.findAll({ where: [ { status: TrainScheduleStatusEnum.Draft }, { status: TrainScheduleStatusEnum.Scheduled }, ], }) ).filter( // CLOSED_FOR_DAY is legacy (the daily desk now reopens via PRE_WINDOW): // still pick those rows up so advanceImport can revive them next morning. (s) => s.windowPhase != null && s.windowPhase !== 'DONE', ); for (const schedule of active) { try { // Each train runs under its OWN frozen rule snapshot, not the live global // config — a later global-rules edit must not retro-change the window an // existing train already advertised, and the reopen cycles must match the // board (which is drawn from the same snapshot). await this.advanceSchedule( schedule, effectiveWindowConfig(schedule, liveCfg), now, ); } catch (err) { // This is THE line to watch when a window freezes mid-phase: the tick // catches a throw here per-schedule and moves on, so a schedule whose // transition keeps throwing stays stuck in its phase forever. Log the // phase + stack so the failing step is obvious. this.logger.error( `[WINDOW] transition FAILED for schedule ${schedule.id} ` + `(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` + `${(err as Error).message}`, ); this.logger.error( `[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`, ); } } await this.settleOverdueReservations(); // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes // (30 ticks at the 10-second cadence). this.tickCount += 1; if (this.tickCount % 30 === 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 liveCfg = 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, effectiveWindowConfig(s, liveCfg), 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; // Push the new window state to portal home / backoffice GL sections so // they refresh instantly instead of waiting out their poll interval. this.gateway.emitPhase(schedule); } } /** 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'; } // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop // (the `ticking` guard would otherwise delay every schedule's transition). void this.notifyWindowOpened(schedule); 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; // Legacy rows parked at CLOSED_FOR_DAY predate the daily-desk reopen: revive // them through the same not-full conclude path so they resume next morning // (or finalize as DONE if no cycle fits before departure). if (windowPhase === 'CLOSED_FOR_DAY') { await this.concludeCycle(schedule, cfg, now); return true; } 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'; } // Only announce the first opening of the day; reopen cycles don't re-notify. // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); this.logger.log( `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + `(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( `[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; 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 }); const routeDay = { originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day: eatDay(schedule.scheduledDepartureDate), }; // Doc review is over: bookings staff never accepted (still pending) can no // longer make this train — expire them BEFORE the batch so they never // compete for capacity and never reach the pool. await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay); // 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(routeDay); this.logger.log( `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + `until ${paymentPhaseEndsAt.toISOString()}`, ); return true; } if ( windowPhase === 'PAYMENT' && schedule.paymentPhaseEndsAt != null && now >= schedule.paymentPhaseEndsAt ) { this.logger.log( `[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` + `(allocate paid / expire unpaid) then concluding the cycle`, ); await this.bookingBatchService.settleDueReservations(schedule.id); // The settle expires unpaid reservations and promotes the waiting list into // the wagons they free. Those promoted customers get a fresh pay window, and // `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to // cover it. Concluding here on the STALE in-memory timestamp would end the // cycle the top-up just extended and expire them before they could pay — so // re-read, and stay in PAYMENT if the deadline moved. const settled = await this.trainSchedulesRepository.findById(schedule.id); if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) { schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt; this.logger.log( `[WINDOW] ${schedule.id} PAYMENT extended to ` + `${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` + `promoted into the freed wagons; not concluding this cycle yet`, ); return true; } // `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own // deadline is set milliseconds later, per booking, so the phase always expires // a fraction before the reservations it opened. Concluding here would end the // cycle while customers still had time to pay, and the settle that finally // expires them (next tick) would have no cycle left to promote the waiting // list into. Hold in PAYMENT until every reservation has actually resolved. if (await this.bookingBatchService.hasLiveReservations(schedule.id)) { this.logger.log( `[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` + `are still within their pay windows — holding the cycle open`, ); return true; } 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); this.logger.log( `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, ); return; } // Not full, so any FULL flag left over from a batch whose bookings later // expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below // refuses to reopen a FULL schedule, which is how a train with an empty // consist used to cycle forever without ever being fillable again. Re-read // the flag onto the in-memory row — advanceSchedule keeps looping on this // same object, and PRE_WINDOW→OPEN reads it. if (schedule.bookingWindowStatus === 'FULL') { await this.bookingBatchService.refreshWindowStatus(schedule.id); const fresh = await this.trainSchedulesRepository.findById(schedule.id); if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; } // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. const officeHours: OfficeHours = { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }; const nextOpensAt = nextCycleOpensAt( now, officeHours, schedule.scheduledDepartureDate, ); if (nextOpensAt == null) { await this.setPhase(schedule, { windowPhase: 'DONE' }); this.logger.log( `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + `departure — window DONE`, ); return; } let nextClosesAt = new Date( nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000, ); if (nextClosesAt > schedule.scheduledDepartureDate) { nextClosesAt = schedule.scheduledDepartureDate; } // Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt, // whether that is later today or next morning after the office-hours break. await this.setPhase(schedule, { windowPhase: 'PRE_WINDOW', windowOpensAt: nextOpensAt, windowClosesAt: nextClosesAt, docReviewCompletedAt: null, docReviewEndsAt: null, paymentPhaseEndsAt: null, }); const sameDay = eatDay(nextOpensAt) === eatDay(now); this.logger.log( `[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` + `REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, ); } 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}`, ); } } } /** * SMS + email every active-contract customer on this schedule's route when its * booking window opens, so they can book from the portal home before it closes. * Fire-and-forget; a failed notification never blocks the window transition. */ private async notifyWindowOpened(schedule: TrainSchedule): Promise { try { const rows: Array<{ company_id: string; phone: string | null; email: string | null; }> = await this.dataSource.query( `SELECT DISTINCT c.company_id, COALESCE(co.contact_person_phone, co.phone) AS phone, COALESCE(co.email, co.general_manager_email) AS email FROM freight.contract_routes cr JOIN freight.contracts c ON c.id = cr.contract_id AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') AND c.deleted_at IS NULL JOIN freight.companies co ON co.id = c.company_id WHERE cr.origin_yard_id = $1 AND cr.destination_yard_id = $2 AND cr.deleted_at IS NULL`, [schedule.originStationId, schedule.destinationStationId], ); if (!rows.length) return; const closes = schedule.windowClosesAt ? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE }) : 'later today'; const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); const msg = `Booking is now open for the train departing ${depart}. ` + `Book your shipment from the portal home page before ${closes} EAT.`; const seenPhone = new Set(); const seenEmail = new Set(); const seenCompany = new Set(); for (const r of rows) { if (r.phone && !seenPhone.has(r.phone)) { seenPhone.add(r.phone); await this.notifications .directSend('sms', r.phone, msg) .catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`)); } if (r.email && !seenEmail.has(r.email)) { seenEmail.add(r.email); await this.notifications .directSend('email', r.email, msg) .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); } // In-app inbox item for every portal user of each eligible company, // deep-linking to the new-booking page. if (r.company_id && !seenCompany.has(r.company_id)) { seenCompany.add(r.company_id); void this.inbox.notify({ recipients: { companyId: r.company_id }, audience: NotificationAudience.PORTAL, type: NotificationType.SCHEDULE_UPDATE, title: 'Booking window open', body: msg, link: '/bookings/new', data: { trainScheduleId: schedule.id }, }); } } this.logger.log( `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`, ); } catch (err) { this.logger.warn( `notifyWindowOpened failed for ${schedule.id}: ${(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); } }