Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
2026-08-07 12:53:19 +00:00

880 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
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 {
bookingCloseCutoff,
clampCloseToOfficeHours,
eatDay,
nextCycleOpensAt,
type OfficeHours,
} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
* The most urgent document-review deadline that still has un-accepted booking
* requests behind it. Backoffice counts down to it and warns staff, because
* everything still pending when the phase ends is expired automatically.
*/
export interface DocReviewAlert {
/** A schedule of the route-day group under review (deep-link target). */
scheduleId: string;
originYardId: string;
destinationYardId: string;
/** EAT booking day of the group, YYYY-MM-DD. */
day: string;
/** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
tradeDirection: string;
/** ISO deadline the review phase ends at. */
docReviewEndsAt: string;
/** Full length of the review phase — the client warns past its halfway mark. */
docReviewMinutes: number;
/** Requests neither accepted nor rejected — they expire at the deadline. */
pendingCount: number;
}
/**
* 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<void> {
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).
//
// Overridable because that lag is the integration suite's pacing floor: every
// window phase, wagon allocation and expiry it waits on lands on this tick, so
// a 10s cadence costs ~5s of pure latency per wait across a few hundred waits.
// The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the
// env var must be set before the module is imported.
@Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', {
name: 'booking-window-tick',
timeZone: BATCH_TIMEZONE,
})
async tick(): Promise<void> {
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();
// One pre-deadline pay reminder per hold (deduped via reminder stamp).
await this.bookingBatchService.sendPaymentReminders().catch((err) =>
this.logger.warn(
`Payment reminder sweep failed: ${(err as Error).message}`,
),
);
// 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;
}
}
/**
* The route-day currently in document review whose deadline is nearest and
* which still has un-accepted requests. Null when nothing is under review or
* every request has been decided — the backoffice header shows nothing then.
*
* One card, one deadline, one count: route-days are checked in deadline order
* and the first with pending work wins, so the number always belongs to the
* clock beside it.
*/
async getDocReviewAlert(): Promise<DocReviewAlert | null> {
const reviewing = (
await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
})
)
.filter(
(s) =>
s.windowPhase === 'DOC_REVIEW' &&
s.docReviewCompletedAt == null &&
s.docReviewEndsAt != null &&
s.scheduledDepartureDate != null,
)
.sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
if (reviewing.length === 0) return null;
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const seen = new Set<string>();
for (const schedule of reviewing) {
const group = {
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
};
// Sibling trains share one review phase for the route-day pool — count it once.
const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
if (seen.has(key)) continue;
seen.add(key);
const pendingCount =
await this.bookingBatchService.countUnacceptedForRouteDay(group);
if (pendingCount === 0) continue;
return {
scheduleId: schedule.id,
...group,
// Carried so the backoffice list opens on the same direction the
// at-risk requests belong to (import corridor, or a domestic day).
tradeDirection: schedule.direction ?? 'IMPORT',
docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
pendingCount,
};
}
return null;
}
/** Staff finished document review early — start the batch/payment phase now. */
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
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);
// The batch fill emits only for trains it actually reserved onto — this
// covers the empty-pool case so every open board still refetches. Push
// failures only log; the doc-review completion itself already persisted.
try {
this.gateway.emitBatchChanged(s.id, 'doc_review_completed');
} catch (err) {
this.logger.warn(
`Batch-board push failed for ${s.id}: ${(err as Error).message}`,
);
}
}
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;
}
// ---- transitions ------------------------------------------------------------
private async advanceSchedule(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
now: Date,
): Promise<void> {
// 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<boolean> {
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);
// Intercity rides along whatever train passes, export included.
void this.notifyIntercityCorridorScheduled(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';
}
// Export has no conclude step: this close is the last moment the day's
// bookings could have boarded. Once every train on the route-day is
// shut, expire what is still waiting for this date (the sweep defers
// while a sibling train stays open).
await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
return true;
}
return false;
}
private async advanceImport(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
now: Date,
): Promise<boolean> {
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);
void this.notifyIntercityCorridorScheduled(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);
// Batch reserved nobody (empty pool, or it allocated without pay windows):
// a PAYMENT phase with nobody to pay is a dead hour with the window shut.
// Conclude straight away — full → DONE, otherwise reopen per office hours.
if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
this.logger.log(
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
`skipping the empty payment phase and concluding the cycle`,
);
await this.concludeCycle(schedule, cfg, now);
return true;
}
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; waiting bookings still fit → fresh pay
* window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
*/
private async concludeCycle(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
now: Date,
): Promise<void> {
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`,
);
// This train is done. If no other train on the route-day can still take
// the waiting list, those bookings have nowhere to go — expire + notify
// them now instead of leaving them FULLY_EXECUTED forever.
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
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;
}
// The window reopens only once the waiting list is exhausted: a booking can
// still reach the pool mid-payment (late doc accept, consolidation partner),
// so retry the batch before reopening. Anything that fits gets a fresh pay
// window and the cycle stays in PAYMENT; check live reservations on THIS
// schedule because the day-level fill may have reserved onto a sibling.
// Waiting bookings that fit no train stay pooled and the window reopens.
// Booking shuts at the configured cutoff (departure closeOffset), not
// departure — every phase-end below is bounded by it, mirroring the initial
// window computation.
const cutoff = bookingCloseCutoff(
schedule.scheduledDepartureDate,
schedule.direction,
cfg,
);
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
if (
promoted > 0 &&
(await this.bookingBatchService.hasLiveReservations(schedule.id))
) {
let paymentPhaseEndsAt = new Date(
now.getTime() + cfg.paymentWindowMinutes * 60_000,
);
if (paymentPhaseEndsAt > cutoff) {
paymentPhaseEndsAt = cutoff;
}
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
this.logger.log(
`[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
`fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
);
return;
}
// 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, cutoff);
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`,
);
// No further cycle on this train. Same sweep as the FULL branch: if no
// sibling train can still take the day's waiting list, expire + notify.
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
return;
}
let nextClosesAt = new Date(
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
);
// Office hours end a running window early: never let the duration outlive
// the desk close (open 16:00, 3h, desk 817 → closes 17:00).
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > cutoff) {
nextClosesAt = cutoff;
}
// 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<void> {
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<void> {
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', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
)
// Deadline is the line — expire() itself reconciles against the gateway
// before actually expiring, so a late in-window payment is still caught.
.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<void> {
try {
const rows: Array<{
company_id: string;
phone: string | null;
email: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT
c.company_id,
${companyNotifyPhoneExpr('co')} 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
${primaryContactUserJoin('co')}
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<string>();
const seenEmail = new Set<string>();
const seenCompany = new Set<string>();
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}`,
);
}
}
/**
* SMS + email + inbox the owner of every waiting intercity booking whose
* corridor lies on this schedule's route.
*
* Intercity (DOMESTIC) bookings carry no date — the customer books a corridor
* and the cargo waits in a pool until staff ride it along a passing
* import/export train (see IntercityService). Until now that wait was silent:
* `notifyWindowOpened` only reaches companies holding an ACTIVE contract whose
* `contract_routes` match the train's exact origin→destination, and an
* intercity booking is neither contracted nor necessarily end-to-end.
*
* Corridor match mirrors `IntercityService.corridorOnRoute` exactly — both
* yards on the route with origin strictly before destination, falling back to
* the train's own origin/destination when the route has fewer than two
* milestones — so nobody is told about a train they can never be placed on.
*/
private async notifyIntercityCorridorScheduled(
schedule: TrainSchedule,
): Promise<void> {
try {
const rows: Array<{
bookingId: string;
companyId: string;
phone: string | null;
email: string | null;
corridor: string;
}> = await this.dataSource.query(
// `stops` is the schedule's stop list, with the two-stop
// origin→destination pseudo-route as the legacy fallback — the same
// shape IntercityService.milestoneSequenceOf builds in TypeScript.
`WITH ms AS (
SELECT yard_id, sequence_no
FROM freight.route_milestones
WHERE route_id = $1 AND deleted_at IS NULL
),
stops AS (
SELECT yard_id, sequence_no FROM ms WHERE (SELECT count(*) FROM ms) >= 2
UNION ALL
SELECT v.yard_id, v.seq
FROM (VALUES ($2::uuid, 1), ($3::uuid, 2)) AS v(yard_id, seq)
WHERE (SELECT count(*) FROM ms) < 2
)
SELECT DISTINCT
b.id AS "bookingId",
b.company_id AS "companyId",
${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email,
COALESCE(oy.label, oy.code) || ' to ' ||
COALESCE(dy.label, dy.code) AS corridor
FROM freight.bookings b
JOIN stops o ON o.yard_id = b.origin_yard_id
JOIN stops d ON d.yard_id = b.destination_yard_id
AND d.sequence_no > o.sequence_no
JOIN freight.companies co ON co.id = b.company_id AND co.deleted_at IS NULL
JOIN freight.yards oy ON oy.id = b.origin_yard_id
JOIN freight.yards dy ON dy.id = b.destination_yard_id
${primaryContactUserJoin('co')}
WHERE b.deleted_at IS NULL
AND b.trade_direction = 'DOMESTIC'
AND b.train_schedule_id IS NULL
-- Same waiting pool IntercityService.findWaitingIntercityBookings
-- draws candidates from: commercial paid/executed, government approved.
AND ((b.is_government = false AND b.status IN ('FULLY_EXECUTED', 'PAID'))
OR (b.is_government = true AND b.status = 'APPROVED'))
-- Once per booking, not once per train. A booking can sit in the
-- pool for weeks while several trains open a window on its corridor,
-- and "trains run your corridor, you are queued" is the same message
-- every time. The inbox row written below is the marker.
-- ponytail: unindexed jsonb probe over freight.notifications; add a
-- partial index on (data->>'intercityCorridorBookingId') if the
-- table grows enough for this to show up in the tick loop.
AND NOT EXISTS (
SELECT 1 FROM freight.notifications n
WHERE n.data->>'intercityCorridorBookingId' = b.id::text)`,
[schedule.routeId, schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
timeZone: BATCH_TIMEZONE,
});
const msgFor = (corridors: string[]) =>
`A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` +
`departing ${depart}. EDR will confirm once your cargo is placed on a train.`;
// One inbox item per booking (its `data` is the once-per-booking marker
// the query above reads), but one SMS/email per company — a customer with
// three waiting bookings gets one message naming all three corridors.
const byCompany = new Map<
string,
{ phone: string | null; email: string | null; corridors: string[] }
>();
for (const row of rows) {
const entry = byCompany.get(row.companyId) ?? {
phone: row.phone,
email: row.email,
corridors: [],
};
if (!entry.corridors.includes(row.corridor)) entry.corridors.push(row.corridor);
byCompany.set(row.companyId, entry);
await this.inbox.notify({
recipients: { companyId: row.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Train scheduled on your corridor',
body: msgFor([row.corridor]),
link: `/bookings/${row.bookingId}`,
data: {
intercityCorridorBookingId: row.bookingId,
trainScheduleId: schedule.id,
},
});
}
for (const [companyId, entry] of byCompany) {
const msg = msgFor(entry.corridors);
if (entry.phone) {
await this.notifications
.directSend('sms', entry.phone, msg)
.catch((e) =>
this.logger.warn(`Intercity corridor SMS failed: ${(e as Error).message}`),
);
}
if (entry.email) {
await this.notifications
.directSend('email', entry.email, msg)
.catch((e) =>
this.logger.warn(`Intercity corridor email failed: ${(e as Error).message}`),
);
}
this.logger.log(
`Notified company ${companyId} of ${entry.corridors.length} intercity ` +
`corridor(s) served by schedule ${schedule.id}`,
);
}
} catch (err) {
this.logger.warn(
`notifyIntercityCorridorScheduled 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<void> {
await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch);
Object.assign(schedule, patch);
}
}