booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsIn,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { RESCHEDULE_TRIGGERS } from '../entities/scheduling-event.entity';
export class PreviewRescheduleDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
incomingBookingIds!: string[];
@ApiProperty({ enum: RESCHEDULE_TRIGGERS })
@IsIn([...RESCHEDULE_TRIGGERS])
trigger!: (typeof RESCHEDULE_TRIGGERS)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ example: '2026-06-22T08:00:00.000Z' })
@IsOptional()
@IsDateString()
newDepartureDate?: string;
}
export class ExecuteRescheduleDto extends PreviewRescheduleDto {
@ApiProperty({ type: [String], description: 'Booking IDs to assign after reschedule' })
@IsArray()
@IsUUID('4', { each: true })
finalBookingIds!: string[];
@ApiProperty({ type: [String], description: 'Booking IDs removed from the schedule' })
@IsArray()
@IsUUID('4', { each: true })
displacedBookingIds!: string[];
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const RESCHEDULE_TRIGGERS = [
'GOVERNMENT_PREEMPT',
'TRAIN_MAINTENANCE',
'MANUAL',
'CAPACITY_REBALANCE',
] as const;
export type RescheduleTrigger = (typeof RESCHEDULE_TRIGGERS)[number];
@Entity({ schema: 'freight', name: 'scheduling_events' })
@Index(['trainScheduleId'])
export class SchedulingEvent extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@Column({ name: 'trigger', type: 'varchar', length: 40 })
trigger!: RescheduleTrigger;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId?: string | null;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'plan_snapshot', type: 'jsonb' })
planSnapshot!: Record<string, unknown>;
@Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' })
displacedBookingIds!: string[];
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { TrainSchedulingManage } from '../../common/booking-guards';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling/schedules/:id/reschedule')
export class SchedulingRescheduleController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('preview')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
preview(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PreviewRescheduleDto,
) {
return this.schedulingRescheduleService.previewReschedule(id, dto);
}
@Post('execute')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
execute(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ExecuteRescheduleDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.schedulingRescheduleService.executeReschedule(
id,
dto,
resolveAuthUserId(user),
);
}
}
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling/schedules/:id')
export class SchedulingMaintenanceController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('maintenance')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
maintenance(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
@CurrentUser() user: AuthUserPayload,
) {
return this.schedulingRescheduleService.maintenanceReschedule(
id,
dto,
resolveAuthUserId(user),
);
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { SchedulingEvent } from './entities/scheduling-event.entity';
import {
SchedulingMaintenanceController,
SchedulingRescheduleController,
} from './scheduling-reschedule.controller';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
@Module({
imports: [
TypeOrmModule.forFeature([SchedulingEvent]),
BookingsModule,
TrainSchedulesModule,
TrainSchedulingModule,
],
controllers: [SchedulingRescheduleController, SchedulingMaintenanceController],
providers: [SchedulingRescheduleRepository, SchedulingRescheduleService],
exports: [SchedulingRescheduleService],
})
export class SchedulingRescheduleModule {}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
@Injectable()
export class SchedulingRescheduleRepository {
constructor(
@InjectRepository(SchedulingEvent)
private readonly repository: Repository<SchedulingEvent>,
) {}
/** Persist an audit record for a completed reschedule. */
async createEvent(data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
}): Promise<SchedulingEvent> {
return this.repository.save(this.repository.create(data));
}
}

View File

@@ -0,0 +1,230 @@
import { BadRequestException } from '@nestjs/common';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
const makeBooking = (
id: string,
reference: string,
extra: Record<string, unknown> = {},
) => ({
id,
reference,
freightType: 'CONTAINER',
cargoTotalWeightVgm: 100,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
status: 'PAID',
isGovernment: false,
priorityScore: 50,
bookingContainers: [
{
id: `${id}-line`,
wagonsRequired: 5,
quantity: 1,
vgmPerUnitTons: 100,
},
],
...extra,
});
describe('compareSchedulingPriority', () => {
it('orders government before commercial', () => {
const sorted = [
{
isGovernment: false,
priorityScore: 50000,
scheduledDate: new Date('2026-06-20'),
},
{
isGovernment: true,
priorityScore: 100,
scheduledDate: new Date('2026-06-25'),
},
].sort(compareSchedulingPriority);
expect(sorted[0]?.isGovernment).toBe(true);
});
});
describe('SchedulingRescheduleService', () => {
let service: SchedulingRescheduleService;
let trainSchedulesRepository: Record<string, jest.Mock>;
let bookingsRepository: Record<string, jest.Mock>;
let trainSchedulingService: Record<string, jest.Mock>;
let schedulingRescheduleRepository: Record<string, jest.Mock>;
beforeEach(() => {
trainSchedulesRepository = {
findByIdWithFullGraph: jest.fn(),
updateStatus: jest.fn(),
};
bookingsRepository = {
findByIdsForScheduling: jest.fn(),
updateSchedulingFields: jest.fn(),
};
trainSchedulingService = {
previewTrainSchedule: jest.fn(),
unassignBooking: jest.fn(),
assignBookingsToSchedule: jest.fn(),
};
schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
};
service = new SchedulingRescheduleService(
trainSchedulesRepository as never,
bookingsRepository as never,
trainSchedulingService as never,
schedulingRescheduleRepository as never,
);
});
it('rejects reschedule on dispatched trains', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'sched-1',
status: 'DISPATCHED',
scheduleBookings: [],
});
await expect(
service.previewReschedule('sched-1', {
incomingBookingIds: ['gov-1'],
trigger: 'GOVERNMENT_PREEMPT',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('displaces lower-priority commercial when government incoming exceeds capacity', async () => {
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false });
const government = makeBooking('g1', 'BKG-GOV', {
isGovernment: true,
priorityScore: 60000,
governmentInstitution: 'Ministry',
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'sched-1',
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
trainSchedulingService.previewTrainSchedule.mockImplementation(
async ({ bookingIds }: { bookingIds: string[] }) => ({
valid: bookingIds.length <= 1,
violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [],
warnings: [],
}),
);
const plan = await service.previewReschedule('sched-1', {
incomingBookingIds: ['g1'],
trigger: 'GOVERNMENT_PREEMPT',
});
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
expect(plan.displaced.map((b) => b.id)).toEqual(['c1']);
expect(plan.finalBookingIds).toEqual(['g1']);
});
it('readmits high-priority commercial when spare capacity remains', async () => {
const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 });
const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 });
const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 });
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'sched-1',
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [
{ bookingId: 'c-low', booking: low },
{ bookingId: 'c-high', booking: high },
],
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
const fitAttempts = new Map<string, number>();
trainSchedulingService.previewTrainSchedule.mockImplementation(
async ({ bookingIds }: { bookingIds: string[] }) => {
const key = [...bookingIds].sort().join(',');
const attempt = (fitAttempts.get(key) ?? 0) + 1;
fitAttempts.set(key, attempt);
const fits =
bookingIds.length === 1 ||
(key === 'c-high,g1' && attempt > 1);
return {
valid: fits,
violations: fits ? [] : ['Train capacity exceeded'],
warnings: [],
};
},
);
const plan = await service.previewReschedule('sched-1', {
incomingBookingIds: ['g1'],
trigger: 'GOVERNMENT_PREEMPT',
});
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']);
expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']);
expect(plan.finalBookingIds).toEqual(['g1', 'c-high']);
});
it('maintenance reschedule updates departure and rebalances bookings', async () => {
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 });
const schedule = {
id: 'sched-1',
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
};
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]);
trainSchedulingService.previewTrainSchedule.mockResolvedValue({
valid: true,
violations: [],
warnings: [],
});
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
const result = await service.maintenanceReschedule(
'sched-1',
{
incomingBookingIds: ['c1'],
trigger: 'TRAIN_MAINTENANCE',
reason: 'Locomotive service',
newDepartureDate: '2026-06-22T10:00:00.000Z',
},
'staff-1',
);
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1',
'DRAFT',
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
trigger: 'TRAIN_MAINTENANCE',
actorUserId: 'staff-1',
reason: 'Locomotive service',
}),
);
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
expect(result.plan.finalBookingIds).toEqual(['c1']);
});
});

View File

@@ -0,0 +1,230 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
export interface RescheduleBookingSummary {
id: string;
reference: string;
isGovernment: boolean;
priorityScore: number;
governmentInstitution?: string | null;
}
export interface ReschedulePlan {
scheduleId: string;
trigger: PreviewRescheduleDto['trigger'];
retained: RescheduleBookingSummary[];
displaced: RescheduleBookingSummary[];
readmitted: RescheduleBookingSummary[];
finalBookingIds: string[];
warnings: string[];
}
@Injectable()
export class SchedulingRescheduleService {
constructor(
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
async previewReschedule(
scheduleId: string,
dto: PreviewRescheduleDto,
): Promise<ReschedulePlan> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status === TrainScheduleStatus.Dispatched) {
throw new BadRequestException('Cannot reschedule a dispatched train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
.map((link) => link.booking)
.filter((b): b is Booking => Boolean(b));
const incoming = await this.bookingsRepository.findByIdsForScheduling(dto.incomingBookingIds);
if (incoming.length !== dto.incomingBookingIds.length) {
throw new BadRequestException('One or more incoming bookings were not found');
}
const mergedMap = new Map<string, Booking>();
for (const booking of [...currentOnSchedule, ...incoming]) {
mergedMap.set(booking.id, booking);
}
const sorted = [...mergedMap.values()].sort(compareSchedulingPriority);
const warnings: string[] = [];
const retained: Booking[] = [];
for (const booking of sorted) {
const candidate = [...retained, booking];
const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId);
if (fits) {
retained.push(booking);
} else if (currentOnSchedule.some((b) => b.id === booking.id)) {
warnings.push(`Booking ${booking.reference} will be displaced from the train`);
}
}
const retainedIds = new Set(retained.map((b) => b.id));
const displacedFromCurrent = currentOnSchedule.filter((b) => !retainedIds.has(b.id));
const readmitted: Booking[] = [];
const displacedCommercial = displacedFromCurrent
.filter((b) => !b.isGovernment)
.sort(compareSchedulingPriority);
for (const booking of displacedCommercial) {
const candidate = [...retained, ...readmitted, booking];
const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId);
if (fits) {
readmitted.push(booking);
warnings.push(`Booking ${booking.reference} readmitted after government placement`);
}
}
const finalIds = [...retained, ...readmitted].map((b) => b.id);
const displacedIds = new Set(displacedFromCurrent.map((b) => b.id));
for (const id of readmitted.map((b) => b.id)) {
displacedIds.delete(id);
}
const displaced = displacedFromCurrent.filter((b) => displacedIds.has(b.id));
return {
scheduleId,
trigger: dto.trigger,
retained: retained.map((b) => this.toSummary(b)),
displaced: displaced.map((b) => this.toSummary(b)),
readmitted: readmitted.map((b) => this.toSummary(b)),
finalBookingIds: finalIds,
warnings,
};
}
/** Execute a confirmed reschedule plan. */
async executeReschedule(
scheduleId: string,
dto: ExecuteRescheduleDto,
actorUserId?: string,
) {
const plan = await this.previewReschedule(scheduleId, dto);
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
const providedDisplaced = new Set(dto.displacedBookingIds);
if (
expectedDisplaced.size !== providedDisplaced.size ||
[...expectedDisplaced].some((id) => !providedDisplaced.has(id))
) {
throw new BadRequestException('Displaced booking list does not match current preview');
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (dto.newDepartureDate && schedule) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
);
}
for (const bookingId of dto.displacedBookingIds) {
try {
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
} catch {
await this.bookingsRepository.updateSchedulingFields(bookingId, {
schedulingStatus: SchedulingStatus.Eligible,
wagonsRequired: null,
});
}
}
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
});
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,
trigger: dto.trigger,
actorUserId,
reason: dto.reason,
planSnapshot: plan as unknown as Record<string, unknown>,
displacedBookingIds: dto.displacedBookingIds,
});
return { plan, schedule: assignResult };
}
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,
dto: PreviewRescheduleDto & { newDepartureDate: string },
actorUserId?: string,
) {
const currentIds = (
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
const preview = await this.previewReschedule(scheduleId, {
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
});
return this.executeReschedule(
scheduleId,
{
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: dto.incomingBookingIds,
finalBookingIds: preview.finalBookingIds,
displacedBookingIds: preview.displaced.map((b) => b.id),
},
actorUserId,
);
}
private async bookingsFitOnSchedule(
bookings: Booking[],
schedule: { scheduledDepartureDate: Date; originStationId: string; destinationStationId: string },
scheduleId: string,
): Promise<boolean> {
if (!bookings.length) return true;
const preview = await this.trainSchedulingService.previewTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
targetScheduleId: scheduleId,
});
return preview.valid;
}
private toSummary(booking: Booking): RescheduleBookingSummary {
return {
id: booking.id,
reference: booking.reference,
isGovernment: booking.isGovernment,
priorityScore: booking.priorityScore,
governmentInstitution: booking.governmentInstitution,
};
}
}