mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat(train-scheduling): implement day-level booking pool
- Added `unplaced` method in `BookingNotifierService` to log warnings for bookings that cannot be placed on any train. - Introduced `getAvailableDays` method in `TrainSchedulingService` to retrieve distinct days with open departures for a given route. - Created `AvailableDaysQueryDto` for querying available days based on origin and destination yards. - Updated `TrainSchedulingController` to expose an endpoint for available days. - Modified frontend components to support day-level booking, allowing customers to select only a day without pinning to a specific train. - Removed references to train schedules in booking forms and review steps, emphasizing day selection. - Added a database migration to create an index for efficient querying of bookings by route and day.
This commit is contained in:
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -42,6 +42,14 @@ interface Capacity {
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT calendar day, `yyyy-MM-dd`. */
|
||||
day: string;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
||||
/**
|
||||
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
|
||||
* (contract sign, payment). Day-level pooling distributes across all of that
|
||||
* day's trains, so a single schedule id maps to its whole route-day group.
|
||||
*/
|
||||
enqueueScheduleProcessing(scheduleId: string): void {
|
||||
void this.processSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
||||
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule?.scheduledDepartureDate) return;
|
||||
await this.processRouteDay({
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pipeline: distribute the (route, day) pool across all its trains,
|
||||
* then settle / reconcile / assign wagons per schedule (those steps stay
|
||||
* schedule-scoped — only the fill is day-level).
|
||||
*/
|
||||
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const scheduleIds = await this.fillRouteDay(
|
||||
group.originYardId,
|
||||
group.destinationYardId,
|
||||
group.day,
|
||||
);
|
||||
for (const scheduleId of scheduleIds) {
|
||||
await this.settleDueReservations(scheduleId);
|
||||
await this.reconcilePaidUnlinked(scheduleId);
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||
async processSchedule(scheduleId: string): Promise<void> {
|
||||
await this.fillSchedule(scheduleId);
|
||||
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
if (!s.scheduledDepartureDate) continue;
|
||||
const day = eatDay(s.scheduledDepartureDate);
|
||||
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
originYardId: s.originStationId,
|
||||
destinationYardId: s.destinationStationId,
|
||||
day,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private groupLabel(group: RouteDayGroup): string {
|
||||
return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
async runBatchFill(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.error(
|
||||
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking);
|
||||
await this.reserve(booking, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
|
||||
* priority, filling each train (earliest departure first) until it's full and
|
||||
* spilling overflow to the next. Government bookings that fit no train preempt
|
||||
* lower-priority commercial; bookings that fit no train at all stay pending and
|
||||
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
|
||||
* (or that had remaining pool work) so the caller can settle them per-schedule.
|
||||
*/
|
||||
async fillRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleIds = bookable
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === 'OPEN' &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
if (scheduleIds.length === 0) return [];
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
|
||||
// First train (earliest departure) that fits this booking as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
|
||||
if (!target && booking.isGovernment) {
|
||||
// Government booking fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||
if (this.fits(need, t.budget)) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// Fits no train this day — stays in the pool, retried next batch.
|
||||
this.notifier.unplaced(booking, day);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(target.id, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
}
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
/**
|
||||
* Reserve capacity for a commercial booking on a specific train and open its
|
||||
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
|
||||
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
|
||||
* which is all keyed off `booking.trainScheduleId`, can find the train — with
|
||||
* day-level pooling the booking arrives here with `trainScheduleId` still null,
|
||||
* so the engine sets it as it picks the train.
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
/**
|
||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||
*/
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user