mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
automate the schedule
This commit is contained in:
@@ -22,7 +22,7 @@ export class BookingPaymentService {
|
||||
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission } from '../../common/freight-permission.util';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@@ -73,7 +73,7 @@ export class BookingsController {
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
create(
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@@ -81,7 +81,22 @@ export class BookingsController {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
return this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
||||
@@ -703,6 +703,56 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
|
||||
* Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
|
||||
* Ordered government → priority → contract-sign time.
|
||||
*/
|
||||
findBatchPool(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status = 'AWAITING_PAYMENT'`)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
|
||||
findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.innerJoin(
|
||||
TrainScheduleBooking,
|
||||
'sb',
|
||||
'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('booking.is_government = false')
|
||||
.orderBy('booking.priority_score', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
@@ -40,6 +43,7 @@ const NEEDS_ACTION_STATUSES = [
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -199,6 +203,25 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
if (!schedule) {
|
||||
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
|
||||
}
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== dto.originYardId ||
|
||||
schedule.destinationStationId !== dto.destinationYardId
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
@@ -235,6 +258,7 @@ export class BookingsService {
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
|
||||
@@ -91,6 +91,12 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -274,6 +274,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
/** End of the 1h pay window once the booking is AWAITING_PAYMENT. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as Handlebars from "handlebars";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulingStatus } from "@edr/types";
|
||||
// import { SchedulingStatus } from "@edr/types";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
import {
|
||||
@@ -125,7 +125,13 @@ export class PaymentService {
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||
const booking = await mg.findOne(Booking, { where: { id: resp.refId } })
|
||||
if (booking?.status === "AWAITING_PAYMENT") {
|
||||
// Batch flow: mark paid but keep the reservation — the batch settle job allocates it.
|
||||
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
|
||||
} else {
|
||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||
}
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'max_wagons', type: 'int', default: 53 })
|
||||
maxWagons!: number;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||
scheduleBookings?: TrainScheduleBooking[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Tunables for the demand-batching booking → allocation flow.
|
||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
||||
export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||
export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from './booking-batch.constants';
|
||||
|
||||
/**
|
||||
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
|
||||
* by priority, greedily fills the train to capacity (skipping oversized bookings),
|
||||
* reserves a 1h pay window for commercial customers (government allocated unpaid,
|
||||
* preempting lower-priority commercial if needed), then settles each batch 1h later —
|
||||
* allocating those who paid and expiring those who didn't, topping up from the waiting list.
|
||||
* Capacity here is modelled by wagon count (`schedule.maxWagons`); locomotive weight/length
|
||||
* is still enforced by the existing assignment path when staff pin wagons.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingBatchService implements OnModuleInit {
|
||||
private readonly logger = new Logger(BookingBatchService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
/** On boot, re-arm a settle timeout for any schedule that still has live reservations. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const reserved = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
||||
.where(`b.status = 'AWAITING_PAYMENT'`)
|
||||
.andWhere('b.train_schedule_id IS NOT NULL')
|
||||
.getRawMany<{ scheduleId: string }>();
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
// ---- cron entry point -----------------------------------------------------
|
||||
|
||||
@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) {
|
||||
try {
|
||||
await this.fillSchedule(s.id);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- core fill ------------------------------------------------------------
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
|
||||
if (!schedule.trainSetId || !schedule.trainSet?.locomotive) {
|
||||
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = await this.remainingWagons(schedule);
|
||||
if (remaining <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||
let armed = false;
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.wagonsFor(booking);
|
||||
|
||||
if (need > remaining) {
|
||||
if (booking.isGovernment) {
|
||||
remaining = await this.preemptForGovernment(scheduleId, need, remaining);
|
||||
if (need > remaining) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip oversized commercial, try the next
|
||||
}
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking);
|
||||
armed = true;
|
||||
}
|
||||
remaining -= need;
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
|
||||
if (remaining <= 0) await this.setWindow(scheduleId, 'FULL');
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
// ---- settle (1h after a batch) -------------------------------------------
|
||||
|
||||
/** Allocate paid reservations, expire the rest, then top up. */
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
|
||||
const expired = booking.paymentDeadline
|
||||
? booking.paymentDeadline.getTime() <= now
|
||||
: true;
|
||||
|
||||
if (paid) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
} else if (expired) {
|
||||
await this.expire(booking);
|
||||
}
|
||||
// else: still within window (rare at settle) → leave for the re-armed timeout
|
||||
}
|
||||
|
||||
await this.fillSchedule(scheduleId);
|
||||
}
|
||||
|
||||
// ---- staff override actions ----------------------------------------------
|
||||
|
||||
/** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
|
||||
async markPaid(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (!booking.trainScheduleId) {
|
||||
throw new BadRequestException('Booking has no target schedule to allocate to');
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID' });
|
||||
await this.allocate(booking.trainScheduleId, booking, 'paid');
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
|
||||
* Used for EXPIRED or full-schedule bookings — no re-approval.
|
||||
*/
|
||||
async moveToSchedule(bookingId: string, newScheduleId: string): Promise<void> {
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: newScheduleId } });
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Target schedule is not accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== booking.originYardId ||
|
||||
schedule.destinationStationId !== booking.destinationYardId
|
||||
) {
|
||||
throw new BadRequestException('Target schedule is not on the booking route');
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (booking.trainScheduleId) {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
booking.trainScheduleId,
|
||||
bookingId,
|
||||
manager,
|
||||
);
|
||||
}
|
||||
const restoredStatus =
|
||||
booking.status === 'EXPIRED'
|
||||
? booking.isGovernment
|
||||
? 'APPROVED'
|
||||
: 'FULLY_EXECUTED'
|
||||
: booking.status;
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: newScheduleId,
|
||||
status: restoredStatus,
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
} as never);
|
||||
});
|
||||
}
|
||||
|
||||
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
|
||||
async expireReservation(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
await this.expire(booking);
|
||||
if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId);
|
||||
}
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its 1h pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
const deadline = new Date(Date.now() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'AWAITING_PAYMENT',
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||
private async allocate(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
reason: 'paid' | 'gov',
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const exists = await this.trainScheduleBookingsRepository.existsForBooking(
|
||||
booking.id,
|
||||
manager,
|
||||
);
|
||||
if (!exists) {
|
||||
await this.trainScheduleBookingsRepository.createMany(
|
||||
[{ trainScheduleId: scheduleId, bookingId: booking.id }],
|
||||
manager,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
status: reason === 'paid' ? 'PAID' : booking.status,
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: new Date(),
|
||||
paymentDeadline: null,
|
||||
} as never);
|
||||
});
|
||||
this.notifier.secured(booking, reason);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
} as never);
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||
*/
|
||||
private async preemptForGovernment(
|
||||
scheduleId: string,
|
||||
need: number,
|
||||
remaining: number,
|
||||
): Promise<number> {
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
).filter((b) => !b.isGovernment);
|
||||
const allocatedCommercial =
|
||||
await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId);
|
||||
|
||||
// lowest priority first; reserved are cheaper to free than allocated
|
||||
const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
|
||||
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
||||
);
|
||||
|
||||
for (const victim of candidates) {
|
||||
if (need <= remaining) break;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
victim.id,
|
||||
manager,
|
||||
);
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
} as never);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
remaining += this.wagonsFor(victim);
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
// ---- capacity helpers -----------------------------------------------------
|
||||
|
||||
private wagonsFor(booking: Booking): number {
|
||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||
return Math.ceil(booking.wagonsRequired);
|
||||
}
|
||||
const fromContainers = (booking.bookingContainers ?? []).reduce(
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
|
||||
}
|
||||
|
||||
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
|
||||
const used =
|
||||
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
|
||||
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
|
||||
return (schedule.maxWagons ?? 0) - used;
|
||||
}
|
||||
|
||||
private async setWindow(
|
||||
scheduleId: string,
|
||||
status: 'OPEN' | 'FULL' | 'CLOSED',
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { bookingWindowStatus: status });
|
||||
}
|
||||
|
||||
// ---- timer plumbing -------------------------------------------------------
|
||||
|
||||
private timeoutName(scheduleId: string): string {
|
||||
return `settle:${scheduleId}`;
|
||||
}
|
||||
|
||||
private armSettle(scheduleId: string): void {
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`),
|
||||
);
|
||||
}, PAYMENT_WINDOW_MS);
|
||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||
}
|
||||
|
||||
private removeTimeout(scheduleId: string): void {
|
||||
const name = this.timeoutName(scheduleId);
|
||||
try {
|
||||
if (this.scheduler.doesExist('timeout', name)) {
|
||||
this.scheduler.deleteTimeout(name);
|
||||
}
|
||||
} catch {
|
||||
// ignore — not armed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Stub notifier for the batch flow — **console.log only** for now.
|
||||
* Injectable so it can later be swapped for the real NotificationsService without
|
||||
* touching the batch engine.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger('BookingNotifier');
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
payNow(b: Booking, deadline: Date): void {
|
||||
this.logger.log(
|
||||
`PAY NOW — ${this.ref(b)} selected for schedule ${b.trainScheduleId}; pay before ${deadline.toISOString()} (1h).`,
|
||||
);
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
this.logger.log(
|
||||
`ALLOCATED — ${this.ref(b)} secured on schedule ${b.trainScheduleId}${
|
||||
reason === 'gov' ? ' (government, unpaid)' : ''
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`EXPIRED — ${this.ref(b)} did not pay in time; can move to another schedule or cancel (no re-approval).`,
|
||||
);
|
||||
}
|
||||
|
||||
scheduleFull(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`DISPLACED — ${this.ref(b)} bumped by a government booking; move to another schedule or cancel.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class BookableSchedulesQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
}
|
||||
@@ -22,14 +22,19 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
|
||||
@ApiTags('train-scheduling')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-scheduling')
|
||||
export class TrainSchedulingController {
|
||||
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
|
||||
constructor(
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
@Get('global-rules')
|
||||
@TrainSchedulingView()
|
||||
@@ -52,6 +57,16 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getEligibleBookings(query);
|
||||
}
|
||||
|
||||
@Get('bookable-schedules')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
|
||||
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getBookableSchedules(
|
||||
query.originYardId,
|
||||
query.destinationYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('container/eligible-bookings')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List eligible container bookings' })
|
||||
@@ -162,6 +177,54 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post('schedules/:id/run-batch')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
|
||||
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.bookingBatchService.fillSchedule(id);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Patch('schedules/:id/booking-window')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Open or close a schedule booking window' })
|
||||
async setBookingWindow(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('status') status: 'OPEN' | 'CLOSED',
|
||||
) {
|
||||
await this.trainSchedulingService.setBookingWindow(id, status === 'CLOSED' ? 'CLOSED' : 'OPEN');
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/mark-paid')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Staff: mark a reserved booking paid and allocate it now' })
|
||||
async markBookingPaid(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
await this.bookingBatchService.markPaid(bookingId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/expire')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Staff: expire a reservation and free its capacity' })
|
||||
async expireBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
await this.bookingBatchService.expireReservation(bookingId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/move-schedule')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Re-point a booking to another OPEN same-route schedule' })
|
||||
async moveBookingSchedule(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body('trainScheduleId', ParseUUIDPipe) trainScheduleId: string,
|
||||
) {
|
||||
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('schedules/:id/checkpoints')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
|
||||
|
||||
@@ -19,6 +19,8 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,7 +43,12 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
providers: [TrainSchedulingService, TrainCheckpointEventsRepository],
|
||||
exports: [TrainSchedulingService],
|
||||
providers: [
|
||||
TrainSchedulingService,
|
||||
TrainCheckpointEventsRepository,
|
||||
BookingBatchService,
|
||||
BookingNotifierService,
|
||||
],
|
||||
exports: [TrainSchedulingService, BookingBatchService],
|
||||
})
|
||||
export class TrainSchedulingModule {}
|
||||
|
||||
@@ -588,11 +588,34 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
}
|
||||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||
await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { bookingWindowStatus: 'CLOSED' });
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: SchedulingStatus.Eligible,
|
||||
paymentDeadline: null,
|
||||
})
|
||||
.where('train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`status = 'AWAITING_PAYMENT'`)
|
||||
.execute();
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/** Open or close a schedule's booking window (staff override). */
|
||||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { bookingWindowStatus: status });
|
||||
}
|
||||
|
||||
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
||||
private async buildScheduleStations(schedule: TrainSchedule) {
|
||||
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
|
||||
@@ -937,7 +960,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const invalidStatus = bookings.filter(
|
||||
(b) => !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID'),
|
||||
(b) =>
|
||||
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && !b.isGovernment,
|
||||
);
|
||||
if (invalidStatus.length) {
|
||||
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
||||
@@ -1591,9 +1615,37 @@ export class TrainSchedulingService {
|
||||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
status: schedule.status,
|
||||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
||||
maxWagons: schedule.maxWagons ?? 0,
|
||||
remainingWagons: Math.max(
|
||||
0,
|
||||
(schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
...(originYardId ? { originStationId: originYardId } : {}),
|
||||
...(destinationYardId ? { destinationStationId: destinationYardId } : {}),
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
route: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
return schedules
|
||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||
.map((s) => this.mapScheduleListItem(s));
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user