mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +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:
@@ -51,9 +51,9 @@ export class BuildTrainDto {
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds?: string[];
|
||||
|
||||
@ApiProperty({ maxLength: 100, description: 'Vogue number' })
|
||||
@ApiProperty({ maxLength: 100, description: 'Voyage number' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Vogue number is required' })
|
||||
@IsNotEmpty({ message: 'Voyage number is required' })
|
||||
@MaxLength(100)
|
||||
trainName!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SendWagonToMaintenanceDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
|
||||
'log alongside the train it was detached from, matching the fleet desk flow.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
@@ -135,8 +136,14 @@ export class TrainBuilderController {
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: SendWagonToMaintenanceDto,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user));
|
||||
return this.trainBuilderService.sendWagonToMaintenance(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { buildMaintenanceNotes, formatTrainRunLabel } from './train-builder.service';
|
||||
|
||||
describe('formatTrainRunLabel', () => {
|
||||
it('names the train by its export and import run numbers', () => {
|
||||
// What staff actually recognise — NOT the internal code (TRN-LEDGER-PW2).
|
||||
expect(
|
||||
formatTrainRunLabel({
|
||||
exportTrainNumber: '9201',
|
||||
importTrainNumber: '9202',
|
||||
trainNumber: 'TRN-7',
|
||||
code: 'TRN-LEDGER-PW2',
|
||||
}),
|
||||
).toBe('export 9201 / import 9202');
|
||||
});
|
||||
|
||||
it('shows only the run number that is set', () => {
|
||||
expect(
|
||||
formatTrainRunLabel({ exportTrainNumber: '9201', code: 'TRN-LEDGER-PW2' }),
|
||||
).toBe('export 9201');
|
||||
expect(
|
||||
formatTrainRunLabel({ importTrainNumber: '9202', code: 'TRN-LEDGER-PW2' }),
|
||||
).toBe('import 9202');
|
||||
});
|
||||
|
||||
it('falls back to the train number, then the code, when no run is set', () => {
|
||||
expect(formatTrainRunLabel({ trainNumber: 'TRN-7', code: 'TRN-LEDGER-PW2' })).toBe(
|
||||
'TRN-7',
|
||||
);
|
||||
expect(formatTrainRunLabel({ code: 'TRN-LEDGER-PW2' })).toBe('TRN-LEDGER-PW2');
|
||||
});
|
||||
|
||||
it('ignores blank run numbers rather than printing empty labels', () => {
|
||||
expect(
|
||||
formatTrainRunLabel({ exportTrainNumber: ' ', importTrainNumber: null, code: 'C-1' }),
|
||||
).toBe('C-1');
|
||||
});
|
||||
|
||||
it('never returns an empty label', () => {
|
||||
expect(formatTrainRunLabel({})).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMaintenanceNotes', () => {
|
||||
it('records the operator reason together with the train it came off', () => {
|
||||
const notes = buildMaintenanceNotes('export 9201 / import 9202', 'Brake shoe worn through');
|
||||
|
||||
expect(notes.statusLogNote).toBe(
|
||||
'Brake shoe worn through (detached from train export 9201 / import 9202)',
|
||||
);
|
||||
expect(notes.movementNote).toBe(
|
||||
'Sent to maintenance from train export 9201 / import 9202: Brake shoe worn through',
|
||||
);
|
||||
});
|
||||
|
||||
it('still records the train number when no reason is given', () => {
|
||||
// The reason is optional, but which train a wagon left is never optional —
|
||||
// the history has to answer that on its own.
|
||||
const notes = buildMaintenanceNotes('export 9201 / import 9202');
|
||||
|
||||
expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202');
|
||||
expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202');
|
||||
});
|
||||
|
||||
it('treats a whitespace-only reason as no reason', () => {
|
||||
const notes = buildMaintenanceNotes('export 9201 / import 9202', ' ');
|
||||
|
||||
expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202');
|
||||
expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202');
|
||||
});
|
||||
|
||||
it('trims padding around a real reason', () => {
|
||||
const notes = buildMaintenanceNotes('export 9201 / import 9202', ' Coupler damage ');
|
||||
|
||||
expect(notes.statusLogNote).toBe('Coupler damage (detached from train export 9201 / import 9202)');
|
||||
expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202: Coupler damage');
|
||||
});
|
||||
|
||||
it('handles a null reason from an older client', () => {
|
||||
expect(buildMaintenanceNotes('export 9201 / import 9202', null).statusLogNote).toBe(
|
||||
'Detached from train export 9201 / import 9202',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
@@ -530,7 +531,12 @@ export class TrainBuilderService {
|
||||
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||
* it clears maintenance. The freed sequence gap is closed.
|
||||
*/
|
||||
async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) {
|
||||
async sendWagonToMaintenance(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -542,6 +548,8 @@ export class TrainBuilderService {
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
const previousStatus = wagon.status;
|
||||
const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
@@ -549,6 +557,22 @@ export class TrainBuilderService {
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
});
|
||||
|
||||
// Status-history row, same as the fleet desk's "Send to maintenance" —
|
||||
// without it a maintenance detach made here is invisible in the wagon's
|
||||
// status history. The train number is folded into the note so the history
|
||||
// answers "which train did it come off, and why" in one line.
|
||||
if (previousStatus !== WagonStatus.Maintenance) {
|
||||
await manager.getRepository(WagonStatusLog).save(
|
||||
manager.getRepository(WagonStatusLog).create({
|
||||
wagonId: wagon.id,
|
||||
fromStatus: previousStatus,
|
||||
toStatus: WagonStatus.Maintenance,
|
||||
changedByUserId: userId ?? null,
|
||||
note: notes.statusLogNote,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Audit row: which train it came off and when. The wagon does not change
|
||||
// yard here, so from/to are the same — the ledger is the wagon's history
|
||||
// surface, and a maintenance detach has to be in it.
|
||||
@@ -560,7 +584,7 @@ export class TrainBuilderService {
|
||||
fromYardId: yardId,
|
||||
toYardId: yardId,
|
||||
kind: WagonMovementKind.Maintenance,
|
||||
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
|
||||
note: notes.movementNote,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
@@ -1111,6 +1135,48 @@ export class TrainBuilderService {
|
||||
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
|
||||
* trail behind in their previous relative order.
|
||||
*/
|
||||
/**
|
||||
* How a train is named in a wagon's history. Staff identify a train by its
|
||||
* OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers
|
||||
* typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a
|
||||
* ledger key and means nothing on the ground. Both runs are shown when set,
|
||||
* since one built train carries the pair. Falls back to the train number, then
|
||||
* the code, only when no run number exists.
|
||||
*/
|
||||
export function formatTrainRunLabel(train: {
|
||||
exportTrainNumber?: string | null;
|
||||
importTrainNumber?: string | null;
|
||||
trainNumber?: string | null;
|
||||
code?: string | null;
|
||||
}): string {
|
||||
const exportNo = train.exportTrainNumber?.trim();
|
||||
const importNo = train.importTrainNumber?.trim();
|
||||
const runs = [
|
||||
exportNo ? `export ${exportNo}` : null,
|
||||
importNo ? `import ${importNo}` : null,
|
||||
].filter(Boolean);
|
||||
if (runs.length) return runs.join(' / ');
|
||||
return train.trainNumber?.trim() || train.code?.trim() || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes for a maintenance detach. The train's run numbers are always recorded —
|
||||
* staff need to know which consist a wagon came off — and the operator's reason
|
||||
* is folded in when given, so the wagon's status history answers "which train,
|
||||
* and why" in one line (matching the fleet desk's Send-to-maintenance note).
|
||||
*/
|
||||
export function buildMaintenanceNotes(trainLabel: string, note?: string | null) {
|
||||
const reason = note?.trim();
|
||||
return {
|
||||
statusLogNote: reason
|
||||
? `${reason} (detached from train ${trainLabel})`
|
||||
: `Detached from train ${trainLabel}`,
|
||||
movementNote: reason
|
||||
? `Sent to maintenance from train ${trainLabel}: ${reason}`
|
||||
: `Sent to maintenance from train ${trainLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function orderSlotsByWagonSequence<
|
||||
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
|
||||
>(slots: T[], newSeq: Map<string, number>): T[] {
|
||||
|
||||
Reference in New Issue
Block a user