mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
880 lines
37 KiB
TypeScript
880 lines
37 KiB
TypeScript
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 8–17 → 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);
|
||
}
|
||
}
|