mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-31 21:17:38 +00:00
implement intercity booking management and booking window websocket integration
This commit is contained in:
@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
|
||||
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
||||
}
|
||||
|
||||
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
|
||||
/**
|
||||
* Export booking window: a single FCFS window from `exportBookingLeadHours`
|
||||
* before departure until departure. The open honours the daily desk hours —
|
||||
* when the raw lead instant lands while the desk is shut, the window opens at
|
||||
* the next desk opening instead (capped at departure, so a config whose desk
|
||||
* never opens before the train leaves yields a zero-length window rather than
|
||||
* one that outlives the train).
|
||||
*/
|
||||
export function computeExportWindowTimes(
|
||||
departure: Date,
|
||||
cfg: { exportBookingLeadHours: number },
|
||||
cfg: {
|
||||
exportBookingLeadHours: number;
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
},
|
||||
): InitialWindowTimes {
|
||||
return {
|
||||
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
|
||||
windowClosesAt: departure,
|
||||
};
|
||||
const rawOpen = new Date(
|
||||
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
|
||||
);
|
||||
let opensAt = officeHoursOpen(rawOpen, {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
});
|
||||
if (opensAt.getTime() > departure.getTime()) {
|
||||
opensAt = departure;
|
||||
}
|
||||
return { windowOpensAt: opensAt, windowClosesAt: departure };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
||||
* after each close, on the same booking day, until departure. This mirrors
|
||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||
* exact windows the engine runs.
|
||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure.
|
||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
|
||||
* with the open shifted to the next desk opening when it lands outside office hours
|
||||
* (same math as `computeExportWindowTimes`).
|
||||
*
|
||||
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
|
||||
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
|
||||
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
|
||||
): BoardWindow[] {
|
||||
if (direction === 'EXPORT') {
|
||||
const start =
|
||||
anchorOpensAt ??
|
||||
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
||||
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
|
||||
return [boardWindowFromInterval(start, departure)];
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import { BookingSplitService } from './booking-split.service';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
export interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
@@ -1444,6 +1444,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.fillSchedule(booking.trainScheduleId);
|
||||
}
|
||||
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Remaining capacity budget (wagons / weight / length) for a schedule, and
|
||||
* the per-booking need calculator — exposed for the intercity accept flow,
|
||||
* which reserves ride-along bookings onto import/export trains outside the
|
||||
* batch engine.
|
||||
*/
|
||||
async intercityCapacity(scheduleId: string): Promise<{
|
||||
budget: Capacity;
|
||||
needFor: (booking: Booking) => Capacity;
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return null;
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an intercity booking onto the given train. Commercial bookings get
|
||||
* the same pay-window lifecycle as a batch reservation (deadline, invoice
|
||||
* due-date sync, pay-now notify, settle on the window tick), so payment →
|
||||
* allocation needs no special path. Government bookings allocate directly.
|
||||
*/
|
||||
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
|
||||
if (booking.isGovernment) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(booking.id, { trainScheduleId: scheduleId });
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
return;
|
||||
}
|
||||
await this.reserve(booking, scheduleId);
|
||||
this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
BOOKING_WINDOW_WS_EVENTS,
|
||||
BOOKING_WINDOW_WS_NAMESPACE,
|
||||
type BookingWindowPhaseEvent,
|
||||
} from '@edr/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* Server → client push for booking-window state changes. Same handshake model
|
||||
* as the notifications gateway: clients only listen, the token is verified on
|
||||
* connect. Events are broadcast namespace-wide — window state is route-scoped
|
||||
* public information for signed-in users, and clients filter/invalidate their
|
||||
* own queries.
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
namespace: BOOKING_WINDOW_WS_NAMESPACE,
|
||||
cors: { origin: true, credentials: true },
|
||||
})
|
||||
export class BookingWindowGateway implements OnGatewayConnection {
|
||||
private readonly logger = new Logger(BookingWindowGateway.name);
|
||||
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
|
||||
constructor(private readonly wsAuth: WsAuthService) {}
|
||||
|
||||
async handleConnection(socket: Socket): Promise<void> {
|
||||
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||
if (!userId) {
|
||||
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
socket.data.userId = userId;
|
||||
}
|
||||
|
||||
/** Push a schedule's current window state to every connected client. */
|
||||
emitPhase(schedule: TrainSchedule): void {
|
||||
const payload: BookingWindowPhaseEvent = {
|
||||
scheduleId: schedule.id,
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
direction: schedule.direction ?? null,
|
||||
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
|
||||
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||
bookingCycleNo: schedule.bookingCycleNo,
|
||||
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
|
||||
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
|
||||
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
|
||||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
|
||||
};
|
||||
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
|
||||
}
|
||||
|
||||
private extractToken(socket: Socket): string | undefined {
|
||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||
if (authToken) return authToken;
|
||||
|
||||
const queryToken = socket.handshake.query?.token;
|
||||
if (typeof queryToken === 'string') return queryToken;
|
||||
|
||||
const header = socket.handshake.headers?.authorization;
|
||||
if (header?.startsWith('Bearer ')) return header.slice(7);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
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 { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
||||
@@ -40,6 +41,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly gateway: BookingWindowGateway,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -48,7 +50,10 @@ export class BookingWindowService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
||||
// 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).
|
||||
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
||||
async tick(): Promise<void> {
|
||||
if (this.ticking) return;
|
||||
this.ticking = true;
|
||||
@@ -89,9 +94,10 @@ export class BookingWindowService implements OnModuleInit {
|
||||
|
||||
await this.settleOverdueReservations();
|
||||
|
||||
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
|
||||
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
|
||||
// (30 ticks at the 10-second cadence).
|
||||
this.tickCount += 1;
|
||||
if (this.tickCount % 5 === 0) {
|
||||
if (this.tickCount % 30 === 0) {
|
||||
await this.bookingBatchService.runBatchFill();
|
||||
}
|
||||
} finally {
|
||||
@@ -151,6 +157,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
? 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +178,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||
schedule.bookingWindowStatus = 'OPEN';
|
||||
}
|
||||
await this.notifyWindowOpened(schedule);
|
||||
// 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);
|
||||
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
||||
return true;
|
||||
}
|
||||
@@ -216,7 +227,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.bookingWindowStatus = 'OPEN';
|
||||
}
|
||||
// Only announce the first opening of the day; reopen cycles don't re-notify.
|
||||
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
|
||||
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
|
||||
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
||||
this.logger.log(
|
||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class AcceptIntercityBookingsDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'Waiting intercity booking ids to accept onto this train, in priority order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
bookingIds!: string[];
|
||||
}
|
||||
@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importWindowLeadDays?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 24,
|
||||
description:
|
||||
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
exportBookingLeadHours?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingBatchService, type Capacity } from './booking-batch.service';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
|
||||
/**
|
||||
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
||||
* train — they ride a passing import/export schedule whose route milestones
|
||||
* contain the booking's origin strictly before its destination.
|
||||
*
|
||||
* Flow: the customer books a corridor with no date; at finalize time staff see
|
||||
* every waiting intercity booking whose corridor lies on the schedule's route,
|
||||
* with its wagon/weight/length need against the train's remaining capacity;
|
||||
* accepting reserves it (pay window → payment → allocation, same lifecycle as
|
||||
* a batch reservation). Cargo is loaded manually when the train reaches the
|
||||
* booking's origin yard and unloaded at its destination yard.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IntercityService {
|
||||
private readonly logger = new Logger(IntercityService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Waiting intercity bookings this schedule could carry, with the train's
|
||||
* remaining capacity along all three axes (wagons, weight, length) and each
|
||||
* booking's need, so staff can pick what fits.
|
||||
*/
|
||||
async listCandidates(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||
|
||||
const waiting = milestoneSeq
|
||||
? await this.findWaitingIntercityBookings(milestoneSeq)
|
||||
: [];
|
||||
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
routeId: schedule.routeId ?? null,
|
||||
remaining: capacity?.budget ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
need,
|
||||
fits: need && capacity ? fits(need, capacity.budget) : false,
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
...this.mapBooking(booking),
|
||||
need: capacity?.needFor(booking) ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept selected waiting intercity bookings onto this train, in the given
|
||||
* order, each re-checked against the shrinking capacity budget. Commercial
|
||||
* bookings open a pay window (payment → allocation runs on the existing
|
||||
* settle lifecycle); government bookings allocate immediately.
|
||||
*/
|
||||
async acceptBookings(scheduleId: string, bookingIds: string[]) {
|
||||
if (bookingIds.length === 0) {
|
||||
throw new BadRequestException('Select at least one intercity booking');
|
||||
}
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||
if (!milestoneSeq) {
|
||||
throw new BadRequestException(
|
||||
'Schedule has no route milestones — cannot serve intercity corridors',
|
||||
);
|
||||
}
|
||||
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||
if (!capacity) {
|
||||
throw new BadRequestException(
|
||||
'Schedule has no locomotive/train set — capacity unknown',
|
||||
);
|
||||
}
|
||||
|
||||
const accepted: string[] = [];
|
||||
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
||||
let budget = capacity.budget;
|
||||
|
||||
for (const bookingId of bookingIds) {
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
|
||||
if (!booking) {
|
||||
rejected.push({ bookingId, reason: 'Booking not found' });
|
||||
continue;
|
||||
}
|
||||
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
|
||||
if (notWaiting) {
|
||||
rejected.push({ bookingId, reason: notWaiting });
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
if (!fits(need, budget)) {
|
||||
rejected.push({
|
||||
bookingId,
|
||||
reason: 'Does not fit the remaining wagon/weight/length capacity',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
||||
budget = subtract(budget, need);
|
||||
accepted.push(bookingId);
|
||||
this.logger.log(
|
||||
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { accepted, rejected, remaining: budget };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
|
||||
* the train is physically at the booking's origin yard: either it has not
|
||||
* departed yet and the booking boards at the train's own origin, or the
|
||||
* latest recorded checkpoint is at the booking's origin yard.
|
||||
*/
|
||||
async loadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (booking.status !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'IN_TRANSIT' });
|
||||
return { bookingId, status: 'IN_TRANSIT' as const };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an intercity booking's cargo as unloaded at its destination yard —
|
||||
* requires the latest checkpoint to be at that yard. Completes the booking.
|
||||
*/
|
||||
async unloadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'COMPLETED' });
|
||||
return { bookingId, status: 'COMPLETED' as const };
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
|
||||
* origin/destination pseudo-route for legacy schedules without a routeId,
|
||||
* so an intercity booking exactly matching the train's own corridor still
|
||||
* qualifies.
|
||||
*/
|
||||
private async routeMilestoneSequence(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<Map<string, number> | null> {
|
||||
if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
if (milestones.length >= 2) {
|
||||
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||
}
|
||||
}
|
||||
if (schedule.originStationId && schedule.destinationStationId) {
|
||||
return new Map([
|
||||
[schedule.originStationId, 1],
|
||||
[schedule.destinationStationId, 2],
|
||||
]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
|
||||
private async findWaitingIntercityBookings(
|
||||
milestoneSeq: Map<string, number>,
|
||||
): Promise<Booking[]> {
|
||||
const pool = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
.andWhere('booking.train_schedule_id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
|
||||
}
|
||||
|
||||
/** Intercity bookings already reserved/allocated on this schedule. */
|
||||
private async findAcceptedIntercityBookings(
|
||||
scheduleId: string,
|
||||
): Promise<Booking[]> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.orderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private corridorOnRoute(
|
||||
booking: Booking,
|
||||
milestoneSeq: Map<string, number>,
|
||||
): boolean {
|
||||
const originSeq = milestoneSeq.get(booking.originYardId);
|
||||
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
|
||||
return (
|
||||
originSeq != null && destinationSeq != null && originSeq < destinationSeq
|
||||
);
|
||||
}
|
||||
|
||||
private whyNotWaiting(
|
||||
booking: Booking,
|
||||
milestoneSeq: Map<string, number>,
|
||||
): string | null {
|
||||
if (booking.tradeDirection !== 'DOMESTIC') {
|
||||
return 'Not an intercity booking';
|
||||
}
|
||||
if (booking.trainScheduleId) {
|
||||
return 'Already assigned to a train';
|
||||
}
|
||||
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
|
||||
if (booking.status !== readyStatus) {
|
||||
return `Not ready to board (status ${booking.status})`;
|
||||
}
|
||||
if (!this.corridorOnRoute(booking, milestoneSeq)) {
|
||||
return "Corridor is not on this schedule's route";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
}
|
||||
if (booking.tradeDirection !== 'DOMESTIC') {
|
||||
throw new BadRequestException('Not an intercity booking');
|
||||
}
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
* not recorded any checkpoint yet (still sitting at its origin).
|
||||
*/
|
||||
private async assertTrainAtYard(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
side: 'origin' | 'destination',
|
||||
): Promise<void> {
|
||||
const latest = await this.dataSource
|
||||
.getRepository(TrainCheckpointEvent)
|
||||
.findOne({
|
||||
where: { trainScheduleId: schedule.id },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||
throw new BadRequestException(
|
||||
'Train has not reached this yard yet — record its checkpoint first',
|
||||
);
|
||||
}
|
||||
if (latest.yardId !== yardId) {
|
||||
throw new BadRequestException(
|
||||
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapBooking(booking: Booking) {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
status: booking.status,
|
||||
freightType: booking.freightType,
|
||||
isGovernment: booking.isGovernment,
|
||||
customer: booking.company?.name ?? 'Unknown customer',
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
origin:
|
||||
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||
destination:
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
'Unknown destination',
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function fits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
need.weightTons <= budget.weightTons &&
|
||||
need.lengthMeters <= budget.lengthMeters
|
||||
);
|
||||
}
|
||||
|
||||
function subtract(budget: Capacity, need: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons - need.wagons,
|
||||
weightTons: budget.weightTons - need.weightTons,
|
||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TrainSchedulingManage,
|
||||
TrainSchedulingView,
|
||||
} from "../../common/booking-guards";
|
||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||
@@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingWindowService } from "./booking-window.service";
|
||||
import { IntercityService } from "./intercity.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
@ApiTags("train-scheduling")
|
||||
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingWindowService: BookingWindowService,
|
||||
private readonly intercityService: IntercityService,
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/intercity-candidates")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity",
|
||||
})
|
||||
getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.intercityService.listCandidates(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/accept")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
|
||||
})
|
||||
acceptIntercityBookings(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptIntercityBookingsDto,
|
||||
) {
|
||||
return this.intercityService.acceptBookings(id, dto.bookingIds);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
|
||||
})
|
||||
loadIntercityBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.intercityService.loadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/unload")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
|
||||
})
|
||||
unloadIntercityBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.intercityService.unloadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/import-djibouti")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
@@ -25,7 +26,10 @@ import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -46,6 +50,8 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
TrainCheckpointEvent,
|
||||
ImportDjiboutiOperation,
|
||||
BookingBatchOffer,
|
||||
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||
Session,
|
||||
]),
|
||||
forwardRef(() => BookingsModule),
|
||||
BillingModule,
|
||||
@@ -64,8 +70,11 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
TrainCheckpointEventsRepository,
|
||||
BookingBatchService,
|
||||
BookingNotifierService,
|
||||
BookingWindowGateway,
|
||||
WsAuthService,
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
IntercityService,
|
||||
],
|
||||
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
||||
})
|
||||
|
||||
@@ -408,7 +408,9 @@ export class TrainSchedulingService {
|
||||
schedule.ruleImportWindowLeadDays ??
|
||||
liveCfg.importWindowLeadDays,
|
||||
exportBookingLeadHours:
|
||||
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
|
||||
dto.exportBookingLeadHours ??
|
||||
schedule.ruleExportBookingLeadHours ??
|
||||
liveCfg.exportBookingLeadHours,
|
||||
windowOpenHour:
|
||||
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||||
windowCloseHour:
|
||||
@@ -432,6 +434,12 @@ export class TrainSchedulingService {
|
||||
schedule.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
||||
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
|
||||
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'These settings leave no booking window before departure — with the ' +
|
||||
'desk hours applied, the window would only open once the train has left.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
@@ -686,10 +694,9 @@ export class TrainSchedulingService {
|
||||
lockedLocomotives.push(locked);
|
||||
}
|
||||
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
// Frozen on the route at create/update from the yard-country enum;
|
||||
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||||
const direction = this.resolveRouteDirection(route);
|
||||
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||
// Effective capacity is capped by the weakest locomotive in the set.
|
||||
@@ -3447,9 +3454,27 @@ export class TrainSchedulingService {
|
||||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||||
);
|
||||
}
|
||||
// Intercity (same-country) service is not offered yet — only import/export
|
||||
// trains can be scheduled.
|
||||
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
|
||||
throw new BadRequestException(
|
||||
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
|
||||
);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
/** Stored route direction, deriving from yard countries for pre-migration rows. */
|
||||
private resolveRouteDirection(route: Route) {
|
||||
return (
|
||||
route.direction ??
|
||||
deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private mapEligibleBooking(booking: Booking) {
|
||||
return {
|
||||
id: booking.id,
|
||||
@@ -4054,6 +4079,7 @@ export class TrainSchedulingService {
|
||||
: null,
|
||||
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user