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:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -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;
}
}