mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
Merge pull request #734 from Tria-plc/freight_feature/usermanagement
fix u=issue
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Admin maintenance reschedule: move a train's departure to a new date/time.
|
||||
* Every allocated booking rides along (links and wagon assignments untouched);
|
||||
* only the dates move — the schedule's train set, route, and window rule
|
||||
* snapshot all stay exactly as they were.
|
||||
*/
|
||||
export class MaintenanceRescheduleDto {
|
||||
@ApiProperty({
|
||||
example: '2026-07-20T05:00:00.000Z',
|
||||
description: 'New scheduled departure date/time (ISO 8601)',
|
||||
})
|
||||
@IsISO8601()
|
||||
newDepartureDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Why the train is being moved (logged)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trigger?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings",
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
incomingBookingIds?: string[];
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/maintenance")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
||||
})
|
||||
async maintenanceReschedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: MaintenanceRescheduleDto,
|
||||
) {
|
||||
await this.trainSchedulingService.maintenanceReschedule(id, dto);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/doc-review-complete")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||||
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||||
* phase and inside the booking lead window — a maintenance move is an
|
||||
* operational fact, not a planning choice. What moves and what stays:
|
||||
*
|
||||
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||||
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||||
* so a booking left on the old day would fall out of its own train's pool).
|
||||
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||||
* maxWagons, and the window RULE snapshot. Stamped window times are only
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||
* schedule mid- or post-window keeps its timeline untouched.
|
||||
*
|
||||
* Customers of every moved booking are notified (maintenanceMoved).
|
||||
*/
|
||||
async maintenanceReschedule(
|
||||
id: string,
|
||||
dto: MaintenanceRescheduleDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||||
);
|
||||
}
|
||||
|
||||
const departure = new Date(dto.newDepartureDate);
|
||||
if (Number.isNaN(departure.getTime())) {
|
||||
throw new BadRequestException('Invalid departure date.');
|
||||
}
|
||||
if (departure.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('New departure must be in the future.');
|
||||
}
|
||||
|
||||
const deltaMs =
|
||||
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||||
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||
: undefined;
|
||||
|
||||
// PRE_WINDOW only: the stamped open/close were derived from the old
|
||||
// departure and the window hasn't opened yet, so re-derive them from the
|
||||
// schedule's own rule snapshot against the new date (joining the target
|
||||
// day's route group timeline when one exists, exactly like
|
||||
// updateScheduleDate). Mid/post-window schedules keep their timeline.
|
||||
const windowFields =
|
||||
schedule.windowPhase === 'PRE_WINDOW'
|
||||
? await (async () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
schedule,
|
||||
await this.getWindowConfig(),
|
||||
);
|
||||
const times =
|
||||
schedule.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(departure, merged)
|
||||
: computeImportWindowTimes(departure, merged, new Date());
|
||||
const anchor =
|
||||
schedule.direction === 'EXPORT'
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
departure,
|
||||
);
|
||||
return anchor
|
||||
? this.groupWindowFieldsFrom(anchor, departure)
|
||||
: {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
};
|
||||
})()
|
||||
: {};
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
scheduledDepartureDate: departure,
|
||||
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
|
||||
...windowFields,
|
||||
});
|
||||
|
||||
// Everything aboard or targeted rides along: bookings linked on the train
|
||||
// (schedule_bookings) plus reservations still pointing at it via
|
||||
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
|
||||
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
|
||||
const targeted = await this.dataSource.getRepository(Booking).find({
|
||||
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
|
||||
relations: { company: true },
|
||||
});
|
||||
const aboard = targeted.filter(
|
||||
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
|
||||
);
|
||||
if (aboard.length) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||||
for (const booking of aboard) {
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
|
||||
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
|
||||
`${aboard.length} booking(s) moved with the train.`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
|
||||
createdAt: schedule.createdAt ?? null,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||
// included (the pull-limit check already counts their tare) — so append the
|
||||
// train's remaining wagons as consist-only entries and the composition views
|
||||
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
|
||||
// really is: loaded slots first, then the empty consist. Skipped for frozen
|
||||
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
|
||||
// later trains, so the live consist no longer describes THIS departure.
|
||||
const coveredPhysicalIds = new Set<string>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(slot.id)
|
||||
: undefined;
|
||||
const physicalId = frozenSlot
|
||||
? frozenSlot.physicalWagonId
|
||||
: slot.physicalWagonId ?? null;
|
||||
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||||
}
|
||||
const maxSlotSequenceNo = Math.max(
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons =
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? (
|
||||
await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
)
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
|
||||
: null,
|
||||
})) ?? [],
|
||||
};
|
||||
}),
|
||||
})
|
||||
.concat(emptyConsistWagons),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
|
||||
Reference in New Issue
Block a user