mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
- 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.
347 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|