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

@@ -28,6 +28,7 @@
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "^1.4.0",

View File

@@ -1,6 +1,7 @@
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -58,6 +59,7 @@ import { OverviewModule } from './modules/overview/overview.module';
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBatchBookingFields1781000000002 implements MigrationInterface {
name = 'AddBatchBookingFields1781000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
// Booking → target schedule (pool membership) + 1h pay-window deadline.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL,
ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id
ON freight.bookings (train_schedule_id)
WHERE deleted_at IS NULL
`);
// TrainSchedule → booking-window status (OPEN/FULL/CLOSED).
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status
ON freight.train_schedules (booking_window_status)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`,
);
await queryRunner.query(
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS train_schedule_id,
DROP COLUMN IF EXISTS payment_deadline
`);
}
}

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

View File

@@ -0,0 +1,252 @@
import { useMemo, useState } from "react";
import {
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Table,
Text,
ThemeIcon,
} from "@mantine/core";
import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
useBatchActions,
useBookableSchedules,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
interface ScheduleBatchPanelProps {
schedule: TrainScheduleDetail;
}
const windowColor: Record<string, string> = {
OPEN: "green",
FULL: "orange",
CLOSED: "gray",
};
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
const { toast } = useToast();
const actions = useBatchActions(schedule.id);
const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN";
const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED";
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const { data: targets } = useBookableSchedules(
schedule.originStation?.id,
schedule.destinationStation?.id,
);
const moveOptions = useMemo(
() =>
(targets ?? [])
.filter((s) => s.id !== schedule.id)
.map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
})),
[targets, schedule.id],
);
const bookings = schedule.bookings ?? [];
const run = (fn: Promise<unknown>, ok: string) =>
fn
.then(() => toast({ title: ok }))
.catch(() => toast({ title: "Action failed", variant: "destructive" }));
return (
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group justify="space-between" align="center" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={36} radius="md" variant="light" color="green">
<Layers size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Batch allocation</Text>
<Text size="xs" c="dimmed">
{bookings.length} allocated · {schedule.trainSet?.wagonCount ?? 0} wagons used
</Text>
</div>
</Group>
<Group gap="sm">
<Badge color={windowColor[windowStatus] ?? "gray"} variant="light" radius="sm" size="lg">
Window: {windowStatus}
</Badge>
</Group>
</Group>
{!locked && (
<Group gap="sm" mb="md">
<Button
size="compact-sm"
variant="light"
color="green"
leftSection={<PlayCircle size={15} />}
loading={actions.runBatch.isPending}
onClick={() => run(actions.runBatch.mutateAsync(schedule.id), "Batch fill run")}
>
Run batch fill
</Button>
{windowStatus === "CLOSED" ? (
<Button
size="compact-sm"
variant="default"
leftSection={<LockOpen size={15} />}
loading={actions.setWindow.isPending}
onClick={() =>
run(
actions.setWindow.mutateAsync({ id: schedule.id, status: "OPEN" }),
"Window opened",
)
}
>
Open window
</Button>
) : (
<Button
size="compact-sm"
variant="default"
leftSection={<Lock size={15} />}
loading={actions.setWindow.isPending}
onClick={() =>
run(
actions.setWindow.mutateAsync({ id: schedule.id, status: "CLOSED" }),
"Window closed",
)
}
>
Close window
</Button>
)}
</Group>
)}
{bookings.length === 0 ? (
<Text size="sm" c="dimmed">
No bookings allocated yet. The batch cron fills this schedule by priority; paid bookings are
assigned automatically.
</Text>
) : (
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookings.map((b) => (
<Table.Tr key={b.id}>
<Table.Td>
<Text size="sm" fw={600}>
{b.reference ?? b.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{b.customer ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<BookingStatusBadge status={b.status ?? ""} />
</Table.Td>
<Table.Td>
{!locked && (
<Group gap={6} justify="flex-end" wrap="nowrap">
{b.status !== "PAID" && (
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<CheckCircle2 size={13} />}
onClick={() => run(actions.markPaid.mutateAsync(b.id), "Marked paid")}
>
Mark paid
</Button>
)}
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<Repeat size={13} />}
onClick={() => {
setMoveBookingId(b.id);
setMoveTarget(null);
}}
>
Move
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<XCircle size={13} />}
onClick={() => run(actions.expire.mutateAsync(b.id), "Reservation expired")}
>
Expire
</Button>
</Group>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Modal
opened={Boolean(moveBookingId)}
onClose={() => setMoveBookingId(null)}
title="Move booking to another schedule"
centered
radius="lg"
>
<Stack gap="md">
<Select
label="Target schedule (same route)"
placeholder="Select an OPEN schedule"
data={moveOptions}
value={moveTarget}
onChange={setMoveTarget}
searchable
nothingFoundMessage="No other open schedules on this route"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMoveBookingId(null)}>
Cancel
</Button>
<Button
color="green"
disabled={!moveTarget}
loading={actions.moveSchedule.isPending}
onClick={() => {
if (!moveBookingId || !moveTarget) return;
run(
actions.moveSchedule.mutateAsync({
bookingId: moveBookingId,
trainScheduleId: moveTarget,
}),
"Booking moved",
).then(() => setMoveBookingId(null));
}}
>
Move booking
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}

View File

@@ -132,6 +132,15 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/expire`,
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/move-schedule`,
GLOBAL_RULES: "/train-scheduling/global-rules",
PREVIEW: "/train-scheduling/preview",
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,

View File

@@ -154,6 +154,18 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
color: "text-amber-700",
stage: 3,
},
AWAITING_PAYMENT: {
title: "Awaiting Payment",
description: "Selected in a batch — pay within 1 hour to secure the slot.",
color: "text-amber-600",
stage: 3,
},
EXPIRED: {
title: "Expired",
description: "Pay window missed — move to another schedule or cancel.",
color: "text-red-600",
stage: 3,
},
PAID: {
title: "Paid",
description: "Payment confirmed; ready for operations.",

View File

@@ -42,6 +42,63 @@ export const useAvailableLocomotives = () =>
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
export const useBatchActions = (scheduleId?: string) => {
const qc = useQueryClient();
const invalidate = () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
if (scheduleId) {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
});
}
};
const runBatch = useMutation({
mutationFn: (id: string) => trainSchedulingService.runBatch(id),
onSuccess: invalidate,
});
const setWindow = useMutation({
mutationFn: ({ id, status }: { id: string; status: "OPEN" | "CLOSED" }) =>
trainSchedulingService.setBookingWindow(id, status),
onSuccess: invalidate,
});
const markPaid = useMutation({
mutationFn: (bookingId: string) => trainSchedulingService.markBookingPaid(bookingId),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (bookingId: string) => trainSchedulingService.expireBooking(bookingId),
onSuccess: invalidate,
});
const moveSchedule = useMutation({
mutationFn: ({ bookingId, trainScheduleId }: { bookingId: string; trainScheduleId: string }) =>
trainSchedulingService.moveBookingSchedule(bookingId, trainScheduleId),
onSuccess: invalidate,
});
return { runBatch, setWindow, markPaid, expire, moveSchedule, invalidate };
};
export const useBookableSchedules = (
originYardId?: string | null,
destinationYardId?: string | null,
) =>
useQuery({
queryKey: [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"bookable",
originYardId ?? "",
destinationYardId ?? "",
],
queryFn: () =>
trainSchedulingService.getBookableSchedules(
originYardId ?? undefined,
destinationYardId ?? undefined,
),
enabled: Boolean(originYardId && destinationYardId),
});
export const useTrainTrack = (id: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),

View File

@@ -44,6 +44,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -165,6 +166,7 @@ export default function NewBookingPage() {
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
const [tradeDirection, setTradeDirection] = useState("IMPORT");
@@ -207,6 +209,17 @@ export default function NewBookingPage() {
c.id,
}));
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
originYardId,
destinationYardId,
);
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code }));
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
@@ -253,6 +266,7 @@ export default function NewBookingPage() {
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(trainScheduleId) &&
Boolean(serviceTypeId) &&
Boolean(scheduledDate) &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -280,6 +294,7 @@ export default function NewBookingPage() {
scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(),
originYardId,
destinationYardId,
trainScheduleId: trainScheduleId || undefined,
serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
@@ -420,7 +435,10 @@ export default function NewBookingPage() {
placeholder="Select origin"
data={yards}
value={originYardId}
onChange={setOriginYardId}
onChange={(v) => {
setOriginYardId(v);
setTrainScheduleId(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as destination" : undefined}
@@ -430,12 +448,31 @@ export default function NewBookingPage() {
placeholder="Select destination"
data={yards}
value={destinationYardId}
onChange={setDestinationYardId}
onChange={(v) => {
setDestinationYardId(v);
setTrainScheduleId(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
<Select
label="Train schedule"
placeholder={
originYardId && destinationYardId
? "Select an open schedule on this route"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
required
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
/>
<Group grow>
<Select
label="Service type"

View File

@@ -52,6 +52,7 @@ import {
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
@@ -902,6 +903,8 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
{scheduleId ? (
<RescheduleTrainDialog
scheduleId={scheduleId}

View File

@@ -2,6 +2,7 @@ import { api as client } from '../auth/http';
import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
BookableSchedule,
AssignBookingsPayload,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -77,6 +78,53 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getBookableSchedules: async (
originYardId?: string,
destinationYardId?: string,
): Promise<BookableSchedule[]> => {
const response = await client.get<BookableSchedule[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES,
{ params: { originYardId, destinationYardId } },
);
return unwrap(response.data);
},
runBatch: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
{},
);
return unwrap(response.data);
},
setBookingWindow: async (
scheduleId: string,
status: "OPEN" | "CLOSED",
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOW(scheduleId),
{ status },
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId), {});
},
expireBooking: async (bookingId: string): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.EXPIRE_BOOKING(bookingId), {});
},
moveBookingSchedule: async (
bookingId: string,
trainScheduleId: string,
): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId), {
trainScheduleId,
});
},
getScheduleById: async (
id: string,
freightType?: FreightType,

View File

@@ -163,6 +163,21 @@ export interface TrainScheduleListItem {
status: TrainScheduleStatus | string;
}
export interface BookableSchedule {
id: string;
scheduleDate: string;
trainNumber?: string | null;
routeName?: string | null;
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
status: TrainScheduleStatus | string;
bookingWindowStatus: "OPEN" | "FULL" | "CLOSED" | string;
maxWagons: number;
remainingWagons: number;
locomotive: { id: string; code: string; name?: string | null } | null;
}
export interface TrainScheduleWagonAllocation {
id: string;
bookingId: string;

View File

@@ -53,6 +53,10 @@ export enum BookingStatus {
FullyExecuted = "FULLY_EXECUTED",
PnrGenerated = "PNR_GENERATED",
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
/** Selected in a batch and notified to pay within the 1h window. */
AwaitingPayment = "AWAITING_PAYMENT",
/** Missed the 1h pay window — recoverable via move/cancel (no re-approval). */
Expired = "EXPIRED",
Paid = "PAID",
InTransit = "IN_TRANSIT",
Completed = "COMPLETED",
@@ -113,6 +117,13 @@ export enum TrainScheduleStatus {
Cancelled = "CANCELLED",
}
/** Whether a schedule is still accepting / holding bookings (orthogonal to its operational status). */
export enum ScheduleBookingWindow {
Open = "OPEN",
Full = "FULL",
Closed = "CLOSED",
}
export enum AllocationLoadType {
Container = "CONTAINER",
Bulk = "BULK",

3
pnpm-lock.yaml generated
View File

@@ -68,6 +68,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.0
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/swagger':
specifier: ^11.4.2
version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)