mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
fix issue
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString } from 'class-validator';
|
||||
|
||||
import { PreviewRescheduleDto } from './preview-reschedule.dto';
|
||||
|
||||
/**
|
||||
* Body for the maintenance-reschedule endpoint. This must be a real class (not
|
||||
* the previous `PreviewRescheduleDto & { newDepartureDate: string }`
|
||||
* intersection): an intersection type carries no class-validator metadata, so
|
||||
* Nest's ValidationPipe silently skipped validation of the whole payload.
|
||||
*/
|
||||
export class MaintenanceRescheduleDto extends PreviewRescheduleDto {
|
||||
@ApiProperty({ example: '2026-06-22T08:00:00.000Z' })
|
||||
@IsDateString()
|
||||
newDepartureDate!: string;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
||||
|
||||
@ApiTags('train-scheduling')
|
||||
@@ -53,7 +54,7 @@ export class SchedulingMaintenanceController {
|
||||
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
||||
maintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||
@Body() dto: MaintenanceRescheduleDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.schedulingRescheduleService.maintenanceReschedule(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
|
||||
|
||||
@@ -12,14 +12,18 @@ export class SchedulingRescheduleRepository {
|
||||
) {}
|
||||
|
||||
/** 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));
|
||||
async createEvent(
|
||||
data: {
|
||||
trainScheduleId: string;
|
||||
trigger: RescheduleTrigger;
|
||||
actorUserId?: string;
|
||||
reason?: string;
|
||||
planSnapshot: Record<string, unknown>;
|
||||
displacedBookingIds: string[];
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<SchedulingEvent> {
|
||||
const repo = manager ? manager.getRepository(SchedulingEvent) : this.repository;
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('SchedulingRescheduleService', () => {
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let trainSchedulingService: Record<string, jest.Mock>;
|
||||
let schedulingRescheduleRepository: Record<string, jest.Mock>;
|
||||
// Sentinel EntityManager the mocked dataSource.transaction hands to the
|
||||
// callback; executeReschedule threads it into updateStatus/createEvent.
|
||||
const txManager = {} as never;
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
trainSchedulesRepository = {
|
||||
@@ -72,6 +76,9 @@ describe('SchedulingRescheduleService', () => {
|
||||
schedulingRescheduleRepository = {
|
||||
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: never) => unknown) => cb(txManager)),
|
||||
};
|
||||
|
||||
service = new SchedulingRescheduleService(
|
||||
trainSchedulesRepository as never,
|
||||
@@ -83,6 +90,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
removedFromTrain: jest.fn(),
|
||||
maintenanceMoved: jest.fn(),
|
||||
} as never, // notifier
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -212,7 +220,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
incomingBookingIds: ['c1'],
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
reason: 'Locomotive service',
|
||||
newDepartureDate: '2026-06-22T10:00:00.000Z',
|
||||
newDepartureDate: '2099-06-22T10:00:00.000Z',
|
||||
},
|
||||
'staff-1',
|
||||
);
|
||||
@@ -220,7 +228,8 @@ describe('SchedulingRescheduleService', () => {
|
||||
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||
'sched-1',
|
||||
'DRAFT',
|
||||
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
|
||||
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
|
||||
txManager,
|
||||
);
|
||||
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -228,6 +237,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
actorUserId: 'staff-1',
|
||||
reason: 'Locomotive service',
|
||||
}),
|
||||
txManager,
|
||||
);
|
||||
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
|
||||
expect(result.plan.finalBookingIds).toEqual(['c1']);
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -12,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||
|
||||
export interface RescheduleBookingSummary {
|
||||
@@ -40,6 +43,8 @@ export class SchedulingRescheduleService {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
||||
@@ -148,21 +153,38 @@ export class SchedulingRescheduleService {
|
||||
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) },
|
||||
);
|
||||
// M7: validate the requested departure BEFORE mutating anything, so a past
|
||||
// or malformed date is rejected up front rather than after (un)assign side
|
||||
// effects have already run. The actual write happens late (below), so a
|
||||
// failing (un)assign step never leaves the train visibly moved.
|
||||
let newDeparture: Date | null = null;
|
||||
if (dto.newDepartureDate) {
|
||||
newDeparture = new Date(dto.newDepartureDate);
|
||||
const now = new Date();
|
||||
if (Number.isNaN(newDeparture.getTime()) || newDeparture <= now) {
|
||||
throw new BadRequestException('New departure date must be in the future');
|
||||
}
|
||||
}
|
||||
|
||||
// H18: run the cross-service (un)assign steps FIRST. They own their own
|
||||
// transactions and are the steps most likely to fail, so doing them before
|
||||
// the date change + audit write means a failure aborts before anything of
|
||||
// ours is committed.
|
||||
for (const bookingId of dto.displacedBookingIds) {
|
||||
try {
|
||||
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
|
||||
} catch {
|
||||
// M11: the unassign failed but this booking is being removed from the
|
||||
// train — also clear its schedule pointer, otherwise it stays linked
|
||||
// (stale trainScheduleId) and risks being double-booked. NOTE: the
|
||||
// TrainScheduleBooking link row / wagon allocations may still persist
|
||||
// (deleting those lives in TrainSchedulingService, not an injected repo
|
||||
// we own), so this is a best-effort detach; a human should finish the
|
||||
// link/allocation cleanup.
|
||||
await this.bookingsRepository.updateSchedulingFields(bookingId, {
|
||||
schedulingStatus: SchedulingStatus.Eligible,
|
||||
wagonsRequired: null,
|
||||
trainScheduleId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -170,7 +192,7 @@ export class SchedulingRescheduleService {
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// departure date below is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
@@ -186,25 +208,50 @@ export class SchedulingRescheduleService {
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
trigger: dto.trigger,
|
||||
actorUserId,
|
||||
reason: dto.reason,
|
||||
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||
displacedBookingIds: dto.displacedBookingIds,
|
||||
// H18: apply the date change and write the audit record LAST, together, in a
|
||||
// single transaction over repositories we own (both updateStatus and
|
||||
// createEvent accept our manager, so the two writes commit or roll back as
|
||||
// one). RESIDUAL RISK: the cross-service (un)assign calls above are NOT
|
||||
// covered by this transaction — they run their own and cannot be threaded
|
||||
// through this manager without editing TrainSchedulingService. A failure
|
||||
// between those steps and this block can still leave partial state; a human
|
||||
// must finish the full cross-service transaction threading.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (newDeparture) {
|
||||
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
|
||||
// delegate to TrainSchedulingService.updateScheduleDate, which only
|
||||
// permits a date change while windowPhase === 'PRE_WINDOW' and would
|
||||
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
|
||||
// the booking-window fields are NOT re-derived for the new date here.
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: newDeparture },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent(
|
||||
{
|
||||
trainScheduleId: scheduleId,
|
||||
trigger: dto.trigger,
|
||||
actorUserId,
|
||||
reason: dto.reason,
|
||||
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||
displacedBookingIds: dto.displacedBookingIds,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
// Notify affected customers (SMS + email). Best-effort — a notification
|
||||
// failure must never fail the reschedule, so each send is fire-and-forget
|
||||
// inside the notifier. Government pre-empt already notifies via the batch
|
||||
// displaced() path, so skip removed-from-train notices for that trigger.
|
||||
// Use the new departure date when the reschedule moved it (the in-memory
|
||||
// `schedule` still holds the pre-update date).
|
||||
const effectiveDeparture = dto.newDepartureDate
|
||||
? new Date(dto.newDepartureDate)
|
||||
: schedule.scheduledDepartureDate;
|
||||
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
|
||||
// M12: only announce a new departure when the date actually moved —
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
}
|
||||
@@ -256,17 +303,26 @@ export class SchedulingRescheduleService {
|
||||
/** Maintenance shortcut: new departure + rebalance. */
|
||||
async maintenanceReschedule(
|
||||
scheduleId: string,
|
||||
dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||
dto: MaintenanceRescheduleDto,
|
||||
actorUserId?: string,
|
||||
) {
|
||||
const currentIds = (
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
|
||||
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
|
||||
|
||||
// M13: merge the bookings already on the train with any caller-supplied
|
||||
// incoming ids and feed the SAME set to both preview and execute. The old
|
||||
// code dropped the caller's ids whenever the train was non-empty (preview)
|
||||
// and then executed against a different (raw) set, so the previewed plan and
|
||||
// the executed plan could diverge.
|
||||
const mergedIncomingIds = Array.from(
|
||||
new Set([...currentIds, ...(dto.incomingBookingIds ?? [])]),
|
||||
);
|
||||
|
||||
const preview = await this.previewReschedule(scheduleId, {
|
||||
...dto,
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
|
||||
incomingBookingIds: mergedIncomingIds,
|
||||
});
|
||||
|
||||
return this.executeReschedule(
|
||||
@@ -274,7 +330,7 @@ export class SchedulingRescheduleService {
|
||||
{
|
||||
...dto,
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
incomingBookingIds: dto.incomingBookingIds,
|
||||
incomingBookingIds: mergedIncomingIds,
|
||||
finalBookingIds: preview.finalBookingIds,
|
||||
displacedBookingIds: preview.displaced.map((b) => b.id),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user