automate the schedule

This commit is contained in:
Marshal
2026-06-11 19:42:23 +00:00
parent f85c13ce8c
commit cde3e462ba
28 changed files with 1197 additions and 12 deletions

View File

@@ -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)) {

View File

@@ -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')

View File

@@ -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({

View File

@@ -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,

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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" })
})
}

View File

@@ -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[];
}

View File

@@ -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:0010: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;

View File

@@ -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
}
}
}

View File

@@ -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.`,
);
}
}

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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 {}

View File

@@ -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,
) {