mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
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.
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user