mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user