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

@@ -15,6 +15,7 @@ import {
PortalCustomer,
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingEditTrainNumber,
TrainSchedulingReschedule,
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
@@ -52,6 +53,8 @@ import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-q
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 { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "../services/train-scheduling.service";
import { BookingBatchService } from "../booking-batch.service";
@@ -795,6 +798,47 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/train-number")
@TrainSchedulingEditTrainNumber()
@ApiOperation({
summary:
"Edit a departure's train number and voyage number — allowed only until the train is dispatched",
})
async updateScheduleTrainNumber(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleTrainNumberDto,
) {
await this.trainSchedulingService.updateScheduleTrainNumber(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get("schedules/:id/merge-preview/:targetTrainId")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"What merging a train into this schedule would do — affected schedules, wagon totals and any blocking reasons. Read-only.",
})
async previewScheduleMerge(
@Param("id", ParseUUIDPipe) id: string,
@Param("targetTrainId", ParseUUIDPipe) targetTrainId: string,
) {
return this.trainSchedulingService.previewMerge(id, targetTrainId);
}
@Post("schedules/:id/merge")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated",
})
async mergeScheduleTrain(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: MergeScheduleTrainDto,
) {
await this.trainSchedulingService.mergeScheduleTrain(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/maintenance")
@TrainSchedulingReschedule()
@ApiOperation({

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
/**
* Merge another train into this schedule's train. The schedule always survives:
* its train set is repointed at `targetTrainId`, that train's wagons join this
* consist, and the source train is left empty and deactivated.
*/
export class MergeScheduleTrainDto {
@ApiProperty({
description: "The train being merged IN. This schedule's train absorbs it.",
})
@IsUUID()
targetTrainId!: string;
@ApiPropertyOptional({
description: 'Why the trains were merged — kept on the audit trail.',
})
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,40 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Edit a departure's operational run identifiers. Both fields are optional so
* either can be corrected alone; the service rejects a body carrying neither,
* so an empty request cannot write an audit row for a no-op.
*
* Sending an empty string clears the field; omitting it leaves it unchanged.
*/
export class UpdateScheduleTrainNumberDto {
@ApiPropertyOptional({
example: '9201',
description: "Run number for this departure. Empty string clears it.",
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
trainNumber?: string;
@ApiPropertyOptional({
example: 'V-2026-014',
description: 'Voyage (sailing) number for this departure. Empty string clears it.',
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
voyageNumber?: string;
@ApiPropertyOptional({
description: 'Why the numbers changed — kept on the audit trail.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,399 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TrainSchedulingService } from './services/train-scheduling.service';
/**
* Merging one train into a schedule. The schedule ALWAYS survives: its train
* set is repointed at the target train, that train's wagons join the consist,
* a same-day schedule on the target is absorbed (bookings move here, it is
* soft-deleted), and the emptied source train is deactivated.
*
* Driven against stub repositories — every rule under test is service logic.
*/
describe('TrainSchedulingService — train merge', () => {
const DAY = '2026-08-12T00:00:00.000Z';
const OTHER_DAY = '2026-08-14T00:00:00.000Z';
/** Rows each repository returns, keyed by entity. */
type Fixture = {
schedule: Record<string, unknown> | null;
train?: Record<string, unknown> | null;
trainSets?: Record<string, unknown>[];
schedules?: Record<string, unknown>[];
wagons?: Record<string, unknown>[];
wagonTypes?: Record<string, unknown>[];
scheduleBookings?: Record<string, unknown>[];
allocations?: Record<string, unknown>[];
milestones?: Record<string, unknown>[];
setWagons?: Record<string, unknown>[];
};
const makeService = (fx: Fixture) => {
const updates: Array<{ entity: string; args: unknown[] }> = [];
const softDeletes: string[] = [];
const repoFor = (entity: unknown) => {
const name = (entity as { name?: string })?.name ?? String(entity);
const rows = (): Record<string, unknown>[] => {
switch (name) {
case 'Train':
return fx.train ? [fx.train] : [];
case 'TrainSet':
return fx.trainSets ?? [];
case 'TrainSchedule':
return fx.schedules ?? [];
case 'Wagon':
return fx.wagons ?? [];
case 'WagonType':
return fx.wagonTypes ?? [];
case 'TrainScheduleBooking':
return fx.scheduleBookings ?? [];
case 'WagonBookingAllocation':
return fx.allocations ?? [];
case 'RouteMilestone':
return fx.milestones ?? [];
case 'TrainSetWagon':
return fx.setWagons ?? [];
default:
return [];
}
};
return {
find: jest.fn().mockImplementation(async () => rows()),
findOne: jest.fn().mockImplementation(async () => rows()[0] ?? null),
update: jest.fn().mockImplementation(async (...args: unknown[]) => {
updates.push({ entity: name, args });
}),
softDelete: jest.fn().mockImplementation(async (id: string) => {
softDeletes.push(id);
}),
};
};
const dataSource = {
getRepository: jest.fn().mockImplementation(repoFor),
transaction: jest
.fn()
.mockImplementation(async (cb: (m: unknown) => Promise<void>) =>
cb({ getRepository: repoFor }),
),
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
Object.assign(service, {
dataSource,
trainSchedulesRepository: {
findByIdWithFullGraph: jest.fn().mockResolvedValue(fx.schedule),
findById: jest.fn().mockResolvedValue(fx.schedule),
},
logger: { log: jest.fn(), warn: jest.fn() },
});
return { service, updates, softDeletes };
};
/** A draft schedule on T1 with 10 wagons and no locomotive caps. */
const baseSchedule = (over: Record<string, unknown> = {}) => ({
id: 'S1',
reference: 'S-2026-00001',
status: 'DRAFT',
scheduledDepartureDate: DAY,
routeId: null,
maxWagons: 0,
trainSetId: 'TS1',
trainSet: {
id: 'TS1',
trainId: 'T1',
wagons: Array.from({ length: 10 }, (_, i) => ({
id: `sw-${i}`,
sequenceNo: i + 1,
lengthMeters: 14,
wagonType: { tareWeightTons: 22.4 },
})),
},
...over,
});
const targetWagons = (n: number) =>
Array.from({ length: n }, (_, i) => ({
id: `w-${i}`,
wagonNumber: `200${i}`,
wagonTypeId: 'wt-1',
trainId: 'T2',
}));
describe('guards', () => {
it('refuses to merge into a dispatched schedule', async () => {
const { service } = makeService({
schedule: baseSchedule({ status: 'DISPATCHED' }),
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses to merge a train into itself', async () => {
const { service } = makeService({ schedule: baseSchedule() });
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T1' }),
).rejects.toThrow(/already this schedule's train/i);
});
it('404s on an unknown schedule', async () => {
const { service } = makeService({ schedule: null });
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('blocks when the target train has no wagons to give', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
wagons: [],
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toThrow(/no wagons to merge/i);
});
});
describe('preview', () => {
it('reports the merged wagon total and the emptied source train', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2', trainNumber: '8002' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
const preview = await service.previewMerge('S1', 'T2');
expect(preview.canMerge).toBe(true);
expect(preview.wagons).toEqual({ current: 10, incoming: 40, merged: 50 });
expect(preview.sourceTrainWillDeactivate).toBe(true);
expect(preview.absorbedSchedule).toBeNull();
});
it('names the same-day schedule whose bookings move here', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S2',
reference: 'S-2026-00002',
status: 'SCHEDULED',
scheduledDepartureDate: DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
scheduleBookings: [
{ id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' },
{ id: 'sb-2', bookingId: 'bk-2', trainScheduleId: 'S2' },
],
});
const preview = await service.previewMerge('S1', 'T2');
expect(preview.absorbedSchedule).toMatchObject({
id: 'S2',
reference: 'S-2026-00002',
bookingsMoving: 2,
});
});
it('lists an other-day schedule as wagons-only, never absorbed', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S3',
reference: 'S-2026-00003',
status: 'DRAFT',
scheduledDepartureDate: OTHER_DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
const preview = await service.previewMerge('S1', 'T2');
expect(preview.absorbedSchedule).toBeNull();
expect(preview.affectedSchedules).toHaveLength(1);
expect(preview.affectedSchedules[0]).toMatchObject({ id: 'S3' });
});
it('leaves a dispatched schedule on the target untouched', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S4',
status: 'DISPATCHED',
scheduledDepartureDate: DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
const preview = await service.previewMerge('S1', 'T2');
// Same day, but dispatched — its cargo stays put.
expect(preview.absorbedSchedule).toBeNull();
expect(preview.affectedSchedules).toHaveLength(0);
expect(preview.untouchedSchedules).toHaveLength(1);
});
});
describe('commit', () => {
it('repoints the set, moves the wagons and deactivates the source train', async () => {
const { service, updates } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' });
const setRepoint = updates.find(
(u) => u.entity === 'TrainSet' && u.args[0] === 'TS1',
);
expect(setRepoint?.args[1]).toMatchObject({ trainId: 'T2' });
const wagonMove = updates.find((u) => u.entity === 'Wagon');
expect(wagonMove?.args[1]).toMatchObject({ trainId: 'T2' });
const trainPark = updates.find(
(u) => u.entity === 'Train' && u.args[0] === 'T1',
);
expect(trainPark?.args[1]).toMatchObject({ status: 'DEACTIVATED' });
});
it('moves the absorbed schedule\'s bookings here and soft-deletes it', async () => {
const { service, updates, softDeletes } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S2',
reference: 'S-2026-00002',
status: 'SCHEDULED',
scheduledDepartureDate: DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
scheduleBookings: [
{ id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' },
],
});
await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' });
const bookingMove = updates.find(
(u) => u.entity === 'TrainScheduleBooking',
);
expect(bookingMove?.args[0]).toMatchObject({ trainScheduleId: 'S2' });
expect(bookingMove?.args[1]).toMatchObject({ trainScheduleId: 'S1' });
// Soft-deleted, not cancelled — the bookings still exist and still depart.
expect(softDeletes).toEqual(['S2']);
});
it('appends merged wagons after the existing consist', async () => {
const { service, updates } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(2),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
setWagons: [
{ id: 'in-0', trainSetId: 'TS2', physicalWagonId: 'w-0' },
{ id: 'in-1', trainSetId: 'TS2', physicalWagonId: 'w-1' },
],
});
await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' });
// 10 existing wagons occupy 1..10, so the merged pair lands at 11 and 12
// — staff reorder them in the train builder afterwards.
const seqs = updates
.filter((u) => u.entity === 'TrainSetWagon')
.map((u) => (u.args[1] as { sequenceNo: number }).sequenceNo);
expect(seqs).toEqual([11, 12]);
});
});
describe('capacity', () => {
it('blocks a merge that overruns the locomotive length cap', async () => {
const { service } = makeService({
schedule: baseSchedule({
trainSet: {
id: 'TS1',
trainId: 'T1',
// A short loco: 100m of train, already 10 × 14m = 140m used.
locomotive: {
maxPullWeightTons: 5000,
maxTrainLengthMeters: 100,
},
wagons: Array.from({ length: 10 }, (_, i) => ({
id: `sw-${i}`,
sequenceNo: i + 1,
lengthMeters: 14,
wagonType: { tareWeightTons: 22.4 },
})),
},
}),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toThrow(/exceeds max train length/i);
});
it('blocks a merge that overruns the pull-weight cap', async () => {
const { service } = makeService({
schedule: baseSchedule({
trainSet: {
id: 'TS1',
trainId: 'T1',
locomotive: {
maxPullWeightTons: 300,
maxTrainLengthMeters: 10000,
},
wagons: [],
},
}),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toThrow(/exceeds max pull weight/i);
});
});
});

View File

@@ -0,0 +1,114 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TrainSchedulingService } from './services/train-scheduling.service';
import type { UpdateScheduleTrainNumberDto } from './dto/update-schedule-train-number.dto';
/**
* Guards around renumbering a departure. Exercised against a stub repository —
* the rules (dispatch lock, empty-body rejection, clear-vs-leave semantics) are
* pure service logic and need no database.
*/
describe('TrainSchedulingService.updateScheduleTrainNumber', () => {
const makeService = (schedule: Record<string, unknown> | null) => {
const update = jest.fn().mockResolvedValue(undefined);
const findById = jest.fn().mockResolvedValue(schedule);
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
Object.assign(service, {
trainSchedulesRepository: { findById, update },
logger: { log: jest.fn(), warn: jest.fn() },
});
return { service, update, findById };
};
const call = (service: TrainSchedulingService, dto: UpdateScheduleTrainNumberDto) =>
service.updateScheduleTrainNumber('sched-1', dto);
it('updates both numbers on a SCHEDULED train', async () => {
const { service, update } = makeService({
id: 'sched-1',
status: 'SCHEDULED',
trainNumber: '9101',
voyageNumber: null,
});
await call(service, { trainNumber: '9201', voyageNumber: 'V-2026-014' });
expect(update).toHaveBeenCalledWith('sched-1', {
trainNumber: '9201',
voyageNumber: 'V-2026-014',
});
});
it('refuses to renumber a dispatched train', async () => {
// The numbers are already printed on paperwork that left with the train.
const { service, update } = makeService({
id: 'sched-1',
status: 'DISPATCHED',
trainNumber: '9101',
});
await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(update).not.toHaveBeenCalled();
});
it.each(['ARRIVED', 'CANCELLED', 'COMPLETED'])(
'refuses to renumber a %s schedule',
async (status) => {
const { service, update } = makeService({ id: 'sched-1', status });
await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(update).not.toHaveBeenCalled();
},
);
it('rejects a body carrying neither number before touching the schedule', async () => {
const { service, update, findById } = makeService({
id: 'sched-1',
status: 'DRAFT',
});
await expect(call(service, {})).rejects.toBeInstanceOf(BadRequestException);
expect(findById).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it('leaves an omitted field untouched rather than clearing it', async () => {
const { service, update } = makeService({
id: 'sched-1',
status: 'DRAFT',
trainNumber: '9101',
voyageNumber: 'V-1',
});
await call(service, { trainNumber: '9201' });
expect(update).toHaveBeenCalledWith('sched-1', { trainNumber: '9201' });
expect(update.mock.calls[0][1]).not.toHaveProperty('voyageNumber');
});
it('clears a field when an empty string is sent', async () => {
const { service, update } = makeService({
id: 'sched-1',
status: 'DRAFT',
voyageNumber: 'V-1',
});
await call(service, { voyageNumber: ' ' });
expect(update).toHaveBeenCalledWith('sched-1', { voyageNumber: null });
});
it('404s on an unknown schedule', async () => {
const { service } = makeService(null);
await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});

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

View File

@@ -0,0 +1,96 @@
import { computeScheduleWagonUsage } from './schedule-wagon-usage.util';
/** A coupled slot; `allocated` = a booking actually sits on it. */
const slot = (allocated = false) => ({ allocations: allocated ? [{}] : [] });
const booking = (wagonsRequired: number | null) => ({ booking: { wagonsRequired } });
describe('computeScheduleWagonUsage', () => {
it('reports allocated slots as used, not the coupled consist size', () => {
// The reported bug: a 37-wagon consist carrying 3 allocated bookings read
// "37 wgn used" in the list while the detail page read "3 in use".
const slots = [...Array(34).fill(slot(false)), ...Array(3).fill(slot(true))];
const usage = computeScheduleWagonUsage({
wagonSlots: slots,
storedWagonCount: 37,
scheduleBookings: [],
});
expect(usage.wagonsUsed).toBe(3);
expect(usage.wagonsTotal).toBe(37);
});
it('counts a built train with no bookings as 0 used', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: Array(40).fill(slot(false)),
storedWagonCount: 40,
scheduleBookings: [],
});
expect(usage.wagonsUsed).toBe(0);
expect(usage.wagonsRemaining).toBe(40);
});
it('treats wagons of an unpaid booking as reserved, so they are not bookable', () => {
// Booking claims 5 wagons but has no wagon plan yet: 0 used, still only 5
// bookable on a 10-wagon train — the reservation is not free space.
const usage = computeScheduleWagonUsage({
wagonSlots: Array(10).fill(slot(false)),
storedWagonCount: 10,
scheduleBookings: [booking(5)],
});
expect(usage.wagonsUsed).toBe(0);
expect(usage.wagonsReserved).toBe(5);
expect(usage.wagonsRemaining).toBe(5);
});
it('does not double-count a booking that is both reserved and allocated', () => {
// 3 allocated slots for a booking that reserved 3 wagons: 7 remain, not 4.
const usage = computeScheduleWagonUsage({
wagonSlots: [...Array(7).fill(slot(false)), ...Array(3).fill(slot(true))],
storedWagonCount: 10,
scheduleBookings: [booking(3)],
});
expect(usage.wagonsUsed).toBe(3);
expect(usage.wagonsReserved).toBe(3);
expect(usage.wagonsRemaining).toBe(7);
});
it('never reports negative remaining when claims exceed the consist', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: Array(2).fill(slot(false)),
storedWagonCount: 2,
scheduleBookings: [booking(5)],
});
expect(usage.wagonsRemaining).toBe(0);
});
it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: [],
storedWagonCount: 12,
scheduleBookings: [],
});
expect(usage.wagonsTotal).toBe(12);
expect(usage.wagonsUsed).toBe(0);
});
it('tolerates missing relations and null wagonsRequired', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: null,
storedWagonCount: null,
scheduleBookings: [booking(null)],
});
expect(usage).toEqual({
wagonsUsed: 0,
wagonsTotal: 0,
wagonsReserved: 0,
wagonsRemaining: 0,
});
});
});

View File

@@ -0,0 +1,58 @@
/**
* Wagon figures for a train-schedule list row.
*
* The list used to report `trainSet.wagonCount` — the COUPLED CONSIST SIZE —
* under the label "wgn used", so a 37-wagon train carrying 3 allocated bookings
* read "37 wgn used" in the list while its detail page (WagonPlanGrid) read
* "37 wagons · 3 in use". These helpers make the list agree with the detail
* page, which is the figure staff trust.
*/
/** The shape this math needs — a slot counts as used when it has allocations. */
export interface WagonSlotLike {
allocations?: unknown[] | null;
}
export interface ScheduleBookingLike {
booking?: { wagonsRequired?: number | null } | null;
}
export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */
wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number;
}
export function computeScheduleWagonUsage(input: {
wagonSlots?: WagonSlotLike[] | null;
/** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null;
}): ScheduleWagonUsage {
const slots = input.wagonSlots ?? [];
// Same predicate as the detail page's WagonPlanGrid: a slot is in use only
// when a booking is actually allocated onto it.
const wagonsUsed = slots.filter((slot) => (slot.allocations?.length ?? 0) > 0).length;
// Prefer live slot rows; the stored counter drifts when a consist is edited
// without a recompute, which is why the list and detail disagreed on totals.
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce(
(sum, link) => sum + (link.booking?.wagonsRequired ?? 0),
0,
);
// Reserved subsumes allocated — an allocated booking still counts its wagons —
// so remaining subtracts whichever claim is larger, never both.
const wagonsRemaining = Math.max(0, wagonsTotal - Math.max(wagonsUsed, wagonsReserved));
return { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining };
}