mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 00:03:26 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user