mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
feat: add wagon usage computation and maintenance logging features
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
@@ -96,6 +96,8 @@ import {
|
||||
} from '../dto/import-djibouti-operation.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
||||
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
||||
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from '../booking-window.config';
|
||||
import { BookingWindowGateway } from '../booking-window.gateway';
|
||||
@@ -132,14 +134,17 @@ import {
|
||||
} from '../utils/wagon-plan.util';
|
||||
import { CorridorBudget } from '../corridor-capacity.util';
|
||||
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
trainHardCaps,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
LocomotiveLimits,
|
||||
@@ -928,6 +933,68 @@ export class TrainSchedulingService {
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct a departure's operational run identifiers — the train number and
|
||||
* voyage number yards and customs quote.
|
||||
*
|
||||
* Editable only until the train leaves: once DISPATCHED (or beyond) the
|
||||
* numbers are printed on paperwork and quoted downstream, so a late edit would
|
||||
* desync records that already left with the train. The audit row is written by
|
||||
* the global AuditInterceptor from the registered route.
|
||||
*/
|
||||
async updateScheduleTrainNumber(
|
||||
id: string,
|
||||
dto: UpdateScheduleTrainNumberDto,
|
||||
): Promise<TrainSchedule> {
|
||||
if (dto.trainNumber === undefined && dto.voyageNumber === undefined) {
|
||||
throw new BadRequestException(
|
||||
'Provide a train number or a voyage number to update.',
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findById(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
|
||||
// Only a train that has not left can be renumbered. CANCELLED is excluded
|
||||
// too — renumbering a dead schedule has no meaning.
|
||||
const editable: string[] = [
|
||||
TrainScheduleStatusEnum.Draft,
|
||||
TrainScheduleStatusEnum.Scheduled,
|
||||
];
|
||||
if (!editable.includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot change the train or voyage number of a ${schedule.status} schedule — ` +
|
||||
'the numbers are fixed once the train is dispatched.',
|
||||
);
|
||||
}
|
||||
|
||||
// An empty string clears the field; an omitted field is left untouched.
|
||||
const patch: Partial<TrainSchedule> = {};
|
||||
if (dto.trainNumber !== undefined) {
|
||||
patch.trainNumber = dto.trainNumber.trim() || null;
|
||||
}
|
||||
if (dto.voyageNumber !== undefined) {
|
||||
patch.voyageNumber = dto.voyageNumber.trim() || null;
|
||||
}
|
||||
|
||||
await this.trainSchedulesRepository.update(id, patch);
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.reference ?? id} renumbered` +
|
||||
(patch.trainNumber !== undefined
|
||||
? ` — train ${schedule.trainNumber ?? '—'} → ${patch.trainNumber ?? '—'}`
|
||||
: '') +
|
||||
(patch.voyageNumber !== undefined
|
||||
? ` — voyage ${schedule.voyageNumber ?? '—'} → ${patch.voyageNumber ?? '—'}`
|
||||
: '') +
|
||||
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
|
||||
);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule ONE train's departure date (staff action on the ops board). Only
|
||||
* allowed while the booking window has not opened yet — an OPEN/past schedule
|
||||
@@ -4072,7 +4139,15 @@ export class TrainSchedulingService {
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
train: true,
|
||||
// Slot allocations back the list's "used wagons" figure — without
|
||||
// them the row can only report the coupled consist size, which is
|
||||
// what made the list disagree with the detail page's wagon plan.
|
||||
wagons: { allocations: true },
|
||||
},
|
||||
// Yards carry the route's display name used by mapScheduleListItem;
|
||||
// milestones (with yards) let it show the full corridor path.
|
||||
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||
@@ -5752,12 +5827,22 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
||||
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
||||
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
||||
computeScheduleWagonUsage({
|
||||
wagonSlots: schedule.trainSet?.wagons,
|
||||
storedWagonCount: schedule.trainSet?.wagonCount,
|
||||
scheduleBookings: schedule.scheduleBookings,
|
||||
});
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
createdAt: schedule.createdAt ?? null,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
voyageNumber: schedule.voyageNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
@@ -5786,6 +5871,14 @@ export class TrainSchedulingService {
|
||||
currentYardId: loco.currentYardId ?? null,
|
||||
})),
|
||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||
/** Coupled slots carrying a booking allocation — matches the wagon plan. */
|
||||
wagonsUsed,
|
||||
/** Coupled consist size; the denominator of "used". */
|
||||
wagonsTotal,
|
||||
/** Claimed by bookings (incl. unpaid) — not bookable. */
|
||||
wagonsReserved,
|
||||
/** Consist minus what bookings have claimed; what is still bookable. */
|
||||
wagonsRemaining,
|
||||
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||||
@@ -7703,6 +7796,7 @@ export class TrainSchedulingService {
|
||||
status: schedule.status,
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
voyageNumber: schedule.voyageNumber ?? null,
|
||||
maxWagons: schedule.maxWagons ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||||
@@ -9122,4 +9216,376 @@ export class TrainSchedulingService {
|
||||
});
|
||||
return new Set(allocations.map((a) => a.bookingId));
|
||||
}
|
||||
|
||||
// ── Train merge ────────────────────────────────────────────────────────────
|
||||
// Combine two trains into one departure. The schedule the action is taken
|
||||
// from ALWAYS survives: its train set is repointed at the target train, the
|
||||
// target's wagons join this consist, and the source train is emptied and
|
||||
// deactivated. When the target also runs a schedule on the SAME DAY, that
|
||||
// schedule's bookings move here and it is soft-deleted; the target's
|
||||
// other-day schedules contribute wagons only.
|
||||
|
||||
/** Statuses whose schedules may take part in a merge. */
|
||||
private static readonly MERGEABLE_STATUSES: string[] = [
|
||||
TrainScheduleStatusEnum.Draft,
|
||||
TrainScheduleStatusEnum.Scheduled,
|
||||
];
|
||||
|
||||
/**
|
||||
* Everything a merge needs to decide, gathered once. Both `previewMerge` and
|
||||
* `mergeScheduleTrain` run this so the modal shows exactly what will happen
|
||||
* and the commit cannot diverge from it.
|
||||
*/
|
||||
private async planMerge(scheduleId: string, targetTrainId: string) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceTrainId = schedule.trainSet?.trainId ?? null;
|
||||
if (sourceTrainId && sourceTrainId === targetTrainId) {
|
||||
throw new BadRequestException(
|
||||
'That is already this schedule\'s train — pick a different one to merge in.',
|
||||
);
|
||||
}
|
||||
|
||||
const targetTrain = await this.dataSource
|
||||
.getRepository(Train)
|
||||
.findOne({ where: { id: targetTrainId } });
|
||||
if (!targetTrain) {
|
||||
throw new NotFoundException(`Train ${targetTrainId} not found`);
|
||||
}
|
||||
|
||||
// Every schedule the target train is committed to, via its train sets.
|
||||
const targetSets = await this.dataSource
|
||||
.getRepository(TrainSet)
|
||||
.find({ where: { trainId: targetTrainId } });
|
||||
const targetSetIds = targetSets.map((s) => s.id);
|
||||
const targetSchedules = targetSetIds.length
|
||||
? await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: { trainSetId: In(targetSetIds) },
|
||||
})
|
||||
: [];
|
||||
|
||||
// The same-day schedule is the one whose bookings move here. Only a
|
||||
// draft/scheduled one qualifies — a dispatched departure keeps its cargo.
|
||||
const sameDay = (a: Date | string, b: Date | string) =>
|
||||
new Date(a).toISOString().slice(0, 10) ===
|
||||
new Date(b).toISOString().slice(0, 10);
|
||||
|
||||
const absorbed =
|
||||
targetSchedules.find(
|
||||
(s) =>
|
||||
s.id !== schedule.id &&
|
||||
sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) &&
|
||||
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
|
||||
) ?? null;
|
||||
|
||||
// Wagons ride with the train, so every OTHER draft/scheduled schedule on it
|
||||
// is affected too — it gains the merged consist but never the bookings.
|
||||
const affectedOthers = targetSchedules.filter(
|
||||
(s) =>
|
||||
s.id !== schedule.id &&
|
||||
s.id !== absorbed?.id &&
|
||||
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
|
||||
);
|
||||
const untouched = targetSchedules.filter(
|
||||
(s) =>
|
||||
s.id !== schedule.id &&
|
||||
s.id !== absorbed?.id &&
|
||||
!TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
|
||||
);
|
||||
|
||||
// The wagons joining this consist: whatever physically sits on the target
|
||||
// train today.
|
||||
const incomingWagons = await this.dataSource
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
|
||||
|
||||
const movingBookings = absorbed
|
||||
? await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||
where: { trainScheduleId: absorbed.id },
|
||||
relations: { booking: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
schedule,
|
||||
sourceTrainId,
|
||||
targetTrain,
|
||||
absorbed,
|
||||
affectedOthers,
|
||||
untouched,
|
||||
incomingWagons,
|
||||
movingBookings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking checks, run against the plan. Returns human-readable reasons; an
|
||||
* empty array means the merge may proceed. Kept separate from `planMerge` so
|
||||
* the preview can SHOW the reasons rather than throwing on them.
|
||||
*/
|
||||
private async mergeBlockers(
|
||||
plan: Awaited<ReturnType<TrainSchedulingService['planMerge']>>,
|
||||
): Promise<string[]> {
|
||||
const blockers: string[] = [];
|
||||
const { schedule, incomingWagons, movingBookings, absorbed } = plan;
|
||||
|
||||
if (incomingWagons.length === 0) {
|
||||
blockers.push(
|
||||
`${plan.targetTrain.code} has no wagons to merge — nothing would move.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capacity: the merged consist must fit this schedule's locomotives ────
|
||||
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
|
||||
lengthMeters: Number(w.lengthMeters) || 0,
|
||||
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
|
||||
cargoTons: 0,
|
||||
}));
|
||||
const wagonTypeIds = [
|
||||
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
|
||||
];
|
||||
const wagonTypes = wagonTypeIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.find({ where: { id: In(wagonTypeIds) } })
|
||||
: [];
|
||||
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
|
||||
const incomingSlots = incomingWagons.map((w) => {
|
||||
const t = typeById.get(w.wagonTypeId);
|
||||
return {
|
||||
lengthMeters: Number(t?.lengthMeters) || 0,
|
||||
tareWeightTons: Number(t?.tareWeightTons) || 0,
|
||||
cargoTons: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const limits = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
if (limits) {
|
||||
const rules = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.find({ take: 1 });
|
||||
const caps = trainHardCaps(limits, {
|
||||
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
|
||||
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
|
||||
});
|
||||
const merged = [...existingSlots, ...incomingSlots];
|
||||
// maxWagons is the schedule's own slot ceiling; fall back to the consist
|
||||
// size when it is unset so the count axis never blocks spuriously.
|
||||
const violations = consistViolations(merged, {
|
||||
maxWeightTons: caps.maxWeightTons,
|
||||
maxLengthMeters: caps.maxLengthMeters,
|
||||
maxWagonSlots: schedule.maxWagons || merged.length,
|
||||
});
|
||||
blockers.push(...violations);
|
||||
}
|
||||
|
||||
// ── Legs: an absorbed booking must be servable by THIS schedule's route ──
|
||||
if (absorbed && movingBookings.length) {
|
||||
const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null);
|
||||
if (routeYardIds.length) {
|
||||
const position = new Map(routeYardIds.map((id, i) => [id, i]));
|
||||
const slotIds = movingBookings.map((mb) => mb.bookingId);
|
||||
const allocations = slotIds.length
|
||||
? await this.dataSource.getRepository(WagonBookingAllocation).find({
|
||||
where: { bookingId: In(slotIds) },
|
||||
relations: { trainSetWagon: true },
|
||||
})
|
||||
: [];
|
||||
const offRoute = new Set<string>();
|
||||
for (const alloc of allocations) {
|
||||
const board = alloc.trainSetWagon?.boardYardId ?? null;
|
||||
const alight = alloc.trainSetWagon?.alightYardId ?? null;
|
||||
// Null on both = rides the whole route; always compatible.
|
||||
if (!board && !alight) continue;
|
||||
const from = board ? position.get(board) : 0;
|
||||
const to = alight ? position.get(alight) : routeYardIds.length - 1;
|
||||
if (from === undefined || to === undefined || from >= to) {
|
||||
offRoute.add(alloc.bookingId);
|
||||
}
|
||||
}
|
||||
if (offRoute.size) {
|
||||
blockers.push(
|
||||
`${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` +
|
||||
'travel legs this schedule\'s route does not serve in the same order.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/** Ordered yard ids along a route, origin first. Empty when unknown. */
|
||||
private async routeYardSequence(routeId: string | null): Promise<string[]> {
|
||||
if (!routeId) return [];
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId }, order: { sequenceNo: 'ASC' } });
|
||||
return milestones
|
||||
.map((m) => m.yardId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a merge WOULD do, without doing it. Drives the confirmation modal:
|
||||
* which schedules gain wagons, which one is absorbed, and why it is blocked.
|
||||
*/
|
||||
async previewMerge(scheduleId: string, targetTrainId: string) {
|
||||
const plan = await this.planMerge(scheduleId, targetTrainId);
|
||||
const blockers = await this.mergeBlockers(plan);
|
||||
|
||||
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
|
||||
return {
|
||||
canMerge: blockers.length === 0,
|
||||
blockers,
|
||||
targetTrain: {
|
||||
id: plan.targetTrain.id,
|
||||
code: plan.targetTrain.code,
|
||||
trainNumber: plan.targetTrain.trainNumber ?? null,
|
||||
},
|
||||
wagons: {
|
||||
current: existingCount,
|
||||
incoming: plan.incomingWagons.length,
|
||||
merged: existingCount + plan.incomingWagons.length,
|
||||
},
|
||||
/** The same-day schedule whose bookings move here and is then removed. */
|
||||
absorbedSchedule: plan.absorbed
|
||||
? {
|
||||
id: plan.absorbed.id,
|
||||
reference: plan.absorbed.reference ?? null,
|
||||
scheduledDepartureDate: plan.absorbed.scheduledDepartureDate,
|
||||
status: plan.absorbed.status,
|
||||
bookingsMoving: plan.movingBookings.length,
|
||||
}
|
||||
: null,
|
||||
/** Other draft/scheduled schedules on the target — wagons only. */
|
||||
affectedSchedules: plan.affectedOthers.map((s) => ({
|
||||
id: s.id,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
status: s.status,
|
||||
})),
|
||||
/** On the target train but left alone (dispatched, cancelled, …). */
|
||||
untouchedSchedules: plan.untouched.map((s) => ({
|
||||
id: s.id,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
status: s.status,
|
||||
})),
|
||||
sourceTrainWillDeactivate: Boolean(plan.sourceTrainId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the merge. One transaction: repoint the train set, move the wagons
|
||||
* (appended last so the builder can reorder them later), carry the absorbed
|
||||
* schedule's bookings across, soft-delete that schedule, and deactivate the
|
||||
* emptied source train.
|
||||
*/
|
||||
async mergeScheduleTrain(
|
||||
scheduleId: string,
|
||||
dto: MergeScheduleTrainDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const plan = await this.planMerge(scheduleId, dto.targetTrainId);
|
||||
const blockers = await this.mergeBlockers(plan);
|
||||
if (blockers.length) {
|
||||
throw new BadRequestException(blockers.join(' '));
|
||||
}
|
||||
|
||||
const {
|
||||
schedule,
|
||||
sourceTrainId,
|
||||
targetTrain,
|
||||
absorbed,
|
||||
incomingWagons,
|
||||
movingBookings,
|
||||
} = plan;
|
||||
const trainSetId = schedule.trainSetId;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
// 1. This schedule's set now runs on the target train.
|
||||
await manager.getRepository(TrainSet).update(trainSetId, {
|
||||
trainId: targetTrain.id,
|
||||
});
|
||||
|
||||
// 2. The physical wagons follow the train.
|
||||
if (incomingWagons.length) {
|
||||
await manager.getRepository(Wagon).update(
|
||||
{ id: In(incomingWagons.map((w) => w.id)) },
|
||||
{ trainId: targetTrain.id },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
||||
// after the existing wagons. Sequence is provisional — staff reorder
|
||||
// in the train builder afterwards.
|
||||
const existing = schedule.trainSet?.wagons ?? [];
|
||||
let nextSequence =
|
||||
existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1;
|
||||
const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({
|
||||
where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) },
|
||||
});
|
||||
for (const row of incomingSetWagons) {
|
||||
if (row.trainSetId === trainSetId) continue;
|
||||
await manager.getRepository(TrainSetWagon).update(row.id, {
|
||||
trainSetId,
|
||||
sequenceNo: nextSequence,
|
||||
});
|
||||
nextSequence += 1;
|
||||
}
|
||||
|
||||
// 4. The absorbed schedule's bookings move here. `bookingId` is uniquely
|
||||
// indexed, so these rows are UPDATED across rather than re-inserted.
|
||||
if (absorbed && movingBookings.length) {
|
||||
await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.update(
|
||||
{ trainScheduleId: absorbed.id },
|
||||
{ trainScheduleId: schedule.id },
|
||||
);
|
||||
}
|
||||
|
||||
// 5. The absorbed schedule is soft-deleted — its bookings still exist and
|
||||
// still depart that day, so nobody is notified and nothing is lost.
|
||||
if (absorbed) {
|
||||
await manager.getRepository(TrainSchedule).softDelete(absorbed.id);
|
||||
}
|
||||
|
||||
// 6. The source train is now empty; park it.
|
||||
if (sourceTrainId) {
|
||||
await manager.getRepository(Train).update(sourceTrainId, {
|
||||
status: Freight.TrainStatus.Deactivated,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Keep the set's cached totals honest.
|
||||
const mergedCount =
|
||||
(schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length;
|
||||
await manager
|
||||
.getRepository(TrainSet)
|
||||
.update(trainSetId, { wagonCount: mergedCount });
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` +
|
||||
` — ${incomingWagons.length} wagon(s) moved` +
|
||||
(absorbed
|
||||
? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))`
|
||||
: '') +
|
||||
(sourceTrainId ? ', source train deactivated' : '') +
|
||||
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
|
||||
);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user