Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
Marshal 56de90892d Add booking window management and locomotive scheduling features
- Implemented migration to release stuck assigned locomotives.
- Added schedule window phases to train schedules.
- Created booking batch offers table for partial capacity bookings.
- Developed BookingSplitService to handle partial booking offers and splits.
- Introduced BookingWindowService to manage booking window lifecycle and transitions.
- Added BookingBatchOffer entity to represent offers made during booking splits.
- Enhanced locomotive options with warnings for scheduling.
- Created UpcomingWindowsSection component to display upcoming booking windows.
2026-07-03 03:36:06 +00:00

347 lines
13 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 { 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<void> {
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<void> {
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<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 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<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;
}
}
/** 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';
}
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<boolean> {
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<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);
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<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')`)
.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<void> {
await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch);
Object.assign(schedule, patch);
}
}