mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
automate the schedule
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@tria-plc/api-common": "^1.4.0",
|
"@tria-plc/api-common": "^1.4.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { DataSource, DataSourceOptions } from "typeorm";
|
import { DataSource, DataSourceOptions } from "typeorm";
|
||||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||||
@@ -58,6 +59,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig, telebirrConfig],
|
load: [appConfig, databaseConfig, telebirrConfig],
|
||||||
}),
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
// EventEmitterModule.forRoot(),
|
// EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export class BookingPaymentService {
|
|||||||
|
|
||||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED', 'AWAITING_PAYMENT', '']);
|
||||||
|
|
||||||
const existing = await this.paymentService.findBookingById(bookingId);
|
const existing = await this.paymentService.findBookingById(bookingId);
|
||||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from '../../common/resolve-auth-user-id';
|
} from '../../common/resolve-auth-user-id';
|
||||||
import { assertFreightPermission } from '../../common/freight-permission.util';
|
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
|
||||||
@ApiTags('bookings')
|
@ApiTags('bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -73,7 +73,7 @@ export class BookingsController {
|
|||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||||
@ApiBody({ type: CreateBookingDto })
|
@ApiBody({ type: CreateBookingDto })
|
||||||
create(
|
async create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@@ -81,7 +81,22 @@ export class BookingsController {
|
|||||||
if (dto.isGovernment) {
|
if (dto.isGovernment) {
|
||||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
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')
|
@Patch(':id')
|
||||||
|
|||||||
@@ -703,6 +703,56 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.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[]> {
|
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||||
if (!bookingIds.length) return Promise.resolve([]);
|
if (!bookingIds.length) return Promise.resolve([]);
|
||||||
return this.bookingRepo(manager).find({
|
return this.bookingRepo(manager).find({
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
} from '../rule-engine/rule-engine.service';
|
} 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 { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { assertFreightShape } from './booking-freight.util';
|
import { assertFreightShape } from './booking-freight.util';
|
||||||
@@ -40,6 +43,7 @@ const NEEDS_ACTION_STATUSES = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
@@ -199,6 +203,25 @@ export class BookingsService {
|
|||||||
companyId = company.id;
|
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 reference = dto.reference || (await this.generateReference());
|
||||||
const containers = dto.containers ?? [];
|
const containers = dto.containers ?? [];
|
||||||
assertFreightShape({
|
assertFreightShape({
|
||||||
@@ -235,6 +258,7 @@ export class BookingsService {
|
|||||||
isGovernment,
|
isGovernment,
|
||||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
|
trainScheduleId: dto.trainScheduleId ?? null,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
previousContractId: dto.previousContractId,
|
previousContractId: dto.previousContractId,
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainId?: string;
|
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' })
|
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate!: string;
|
||||||
|
|||||||
@@ -274,6 +274,14 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||||
scheduledAt?: Date | null;
|
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)
|
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||||
bookingContainers?: BookingContainer[];
|
bookingContainers?: BookingContainer[];
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import * as fs from "fs";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as Handlebars from "handlebars";
|
import * as Handlebars from "handlebars";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { SchedulingStatus } from "@edr/types";
|
// import { SchedulingStatus } from "@edr/types";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -125,7 +125,13 @@ export class PaymentService {
|
|||||||
|
|
||||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.datasource.transaction(async (mg) => {
|
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" })
|
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 })
|
@Column({ name: 'max_wagons', type: 'int', default: 53 })
|
||||||
maxWagons!: number;
|
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)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
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 { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.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 { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
|
||||||
@ApiTags('train-scheduling')
|
@ApiTags('train-scheduling')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('train-scheduling')
|
@Controller('train-scheduling')
|
||||||
export class TrainSchedulingController {
|
export class TrainSchedulingController {
|
||||||
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
|
constructor(
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('global-rules')
|
@Get('global-rules')
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@@ -52,6 +57,16 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getEligibleBookings(query);
|
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')
|
@Get('container/eligible-bookings')
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List eligible container bookings' })
|
@ApiOperation({ summary: 'List eligible container bookings' })
|
||||||
@@ -162,6 +177,54 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
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')
|
@Get('schedules/:id/checkpoints')
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
|
@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 { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -41,7 +43,12 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
|||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [TrainSchedulingController],
|
controllers: [TrainSchedulingController],
|
||||||
providers: [TrainSchedulingService, TrainCheckpointEventsRepository],
|
providers: [
|
||||||
exports: [TrainSchedulingService],
|
TrainSchedulingService,
|
||||||
|
TrainCheckpointEventsRepository,
|
||||||
|
BookingBatchService,
|
||||||
|
BookingNotifierService,
|
||||||
|
],
|
||||||
|
exports: [TrainSchedulingService, BookingBatchService],
|
||||||
})
|
})
|
||||||
export class TrainSchedulingModule {}
|
export class TrainSchedulingModule {}
|
||||||
|
|||||||
@@ -588,11 +588,34 @@ export class TrainSchedulingService {
|
|||||||
manager,
|
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);
|
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). */
|
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
||||||
private async buildScheduleStations(schedule: TrainSchedule) {
|
private async buildScheduleStations(schedule: TrainSchedule) {
|
||||||
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
|
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
|
||||||
@@ -937,7 +960,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invalidStatus = bookings.filter(
|
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) {
|
if (invalidStatus.length) {
|
||||||
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
||||||
@@ -1591,9 +1615,37 @@ export class TrainSchedulingService {
|
|||||||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
status: schedule.status,
|
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(
|
private async mapScheduleDetail(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -132,6 +132,15 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
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",
|
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||||
PREVIEW: "/train-scheduling/preview",
|
PREVIEW: "/train-scheduling/preview",
|
||||||
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
||||||
|
|||||||
@@ -154,6 +154,18 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
color: "text-amber-700",
|
color: "text-amber-700",
|
||||||
stage: 3,
|
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: {
|
PAID: {
|
||||||
title: "Paid",
|
title: "Paid",
|
||||||
description: "Payment confirmed; ready for operations.",
|
description: "Payment confirmed; ready for operations.",
|
||||||
|
|||||||
@@ -42,6 +42,63 @@ export const useAvailableLocomotives = () =>
|
|||||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
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) =>
|
export const useTrainTrack = (id: string | undefined) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
@@ -165,6 +166,7 @@ export default function NewBookingPage() {
|
|||||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||||
const [destinationYardId, setDestinationYardId] = 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 [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
const [tradeDirection, setTradeDirection] = useState("IMPORT");
|
const [tradeDirection, setTradeDirection] = useState("IMPORT");
|
||||||
@@ -207,6 +209,17 @@ export default function NewBookingPage() {
|
|||||||
c.id,
|
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 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 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 }));
|
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(originYardId) &&
|
||||||
Boolean(destinationYardId) &&
|
Boolean(destinationYardId) &&
|
||||||
!sameYard &&
|
!sameYard &&
|
||||||
|
Boolean(trainScheduleId) &&
|
||||||
Boolean(serviceTypeId) &&
|
Boolean(serviceTypeId) &&
|
||||||
Boolean(scheduledDate) &&
|
Boolean(scheduledDate) &&
|
||||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||||
@@ -280,6 +294,7 @@ export default function NewBookingPage() {
|
|||||||
scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(),
|
scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(),
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
|
trainScheduleId: trainScheduleId || undefined,
|
||||||
serviceTypeId,
|
serviceTypeId,
|
||||||
shippingLineId: shippingLineId || undefined,
|
shippingLineId: shippingLineId || undefined,
|
||||||
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
||||||
@@ -420,7 +435,10 @@ export default function NewBookingPage() {
|
|||||||
placeholder="Select origin"
|
placeholder="Select origin"
|
||||||
data={yards}
|
data={yards}
|
||||||
value={originYardId}
|
value={originYardId}
|
||||||
onChange={setOriginYardId}
|
onChange={(v) => {
|
||||||
|
setOriginYardId(v);
|
||||||
|
setTrainScheduleId(null);
|
||||||
|
}}
|
||||||
searchable
|
searchable
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
error={sameYard ? "Same as destination" : undefined}
|
error={sameYard ? "Same as destination" : undefined}
|
||||||
@@ -430,12 +448,31 @@ export default function NewBookingPage() {
|
|||||||
placeholder="Select destination"
|
placeholder="Select destination"
|
||||||
data={yards}
|
data={yards}
|
||||||
value={destinationYardId}
|
value={destinationYardId}
|
||||||
onChange={setDestinationYardId}
|
onChange={(v) => {
|
||||||
|
setDestinationYardId(v);
|
||||||
|
setTrainScheduleId(null);
|
||||||
|
}}
|
||||||
searchable
|
searchable
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
error={sameYard ? "Same as origin" : undefined}
|
error={sameYard ? "Same as origin" : undefined}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</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>
|
<Group grow>
|
||||||
<Select
|
<Select
|
||||||
label="Service type"
|
label="Service type"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
scheduleBrand,
|
scheduleBrand,
|
||||||
} from "@/components/trainScheduling/scheduleVisuals";
|
} from "@/components/trainScheduling/scheduleVisuals";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
|
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||||
@@ -902,6 +903,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<ScheduleBatchPanel schedule={schedule} />
|
||||||
|
|
||||||
{scheduleId ? (
|
{scheduleId ? (
|
||||||
<RescheduleTrainDialog
|
<RescheduleTrainDialog
|
||||||
scheduleId={scheduleId}
|
scheduleId={scheduleId}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api as client } from '../auth/http';
|
|||||||
import { unwrap } from '@/utils/endpoint';
|
import { unwrap } from '@/utils/endpoint';
|
||||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
import type {
|
import type {
|
||||||
|
BookableSchedule,
|
||||||
AssignBookingsPayload,
|
AssignBookingsPayload,
|
||||||
CreateTrainSchedulePayload,
|
CreateTrainSchedulePayload,
|
||||||
EligibleContainerBookingsResponse,
|
EligibleContainerBookingsResponse,
|
||||||
@@ -77,6 +78,53 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
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 (
|
getScheduleById: async (
|
||||||
id: string,
|
id: string,
|
||||||
freightType?: FreightType,
|
freightType?: FreightType,
|
||||||
|
|||||||
@@ -163,6 +163,21 @@ export interface TrainScheduleListItem {
|
|||||||
status: TrainScheduleStatus | string;
|
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 {
|
export interface TrainScheduleWagonAllocation {
|
||||||
id: string;
|
id: string;
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export enum BookingStatus {
|
|||||||
FullyExecuted = "FULLY_EXECUTED",
|
FullyExecuted = "FULLY_EXECUTED",
|
||||||
PnrGenerated = "PNR_GENERATED",
|
PnrGenerated = "PNR_GENERATED",
|
||||||
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
|
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",
|
Paid = "PAID",
|
||||||
InTransit = "IN_TRANSIT",
|
InTransit = "IN_TRANSIT",
|
||||||
Completed = "COMPLETED",
|
Completed = "COMPLETED",
|
||||||
@@ -113,6 +117,13 @@ export enum TrainScheduleStatus {
|
|||||||
Cancelled = "CANCELLED",
|
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 {
|
export enum AllocationLoadType {
|
||||||
Container = "CONTAINER",
|
Container = "CONTAINER",
|
||||||
Bulk = "BULK",
|
Bulk = "BULK",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -68,6 +68,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@nestjs/swagger':
|
||||||
specifier: ^11.4.2
|
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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user