mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
feat(train): enhance train history and scheduling features
- Added a reason field to train history entries for detach/maintenance actions. - Updated TrainHistoryPanel to display the reason for wagon detachments. - Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons. - Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason. - Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments. - Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SendWagonToMaintenanceDto {
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
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.',
|
||||
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
|
||||
'status-history log alongside the train it was detached from, and on the ' +
|
||||
"train's wagon-adjustment history.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
|
||||
|
||||
export class CreateWagonDetachRequestDto {
|
||||
/**
|
||||
* Detach a wagon from the consist. The reason is always required — it is
|
||||
* recorded both as an auto-approved wagon_detach_requests audit row and on the
|
||||
* train's wagon-adjustment history (the History tab).
|
||||
*/
|
||||
export class DetachWagonDto {
|
||||
@ApiProperty({
|
||||
enum: WagonDetachRequestAction,
|
||||
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
|
||||
})
|
||||
@IsEnum(WagonDetachRequestAction)
|
||||
action!: WagonDetachRequestAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
|
||||
description: 'Why the wagon leaves the consist — required.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecideWagonDetachRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Decision note — required when rejecting, optional when approving.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ 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 {
|
||||
CreateWagonDetachRequestDto,
|
||||
DecideWagonDetachRequestDto,
|
||||
} from './dto/wagon-detach-request.dto';
|
||||
import { DetachWagonDto } from './dto/wagon-detach-request.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';
|
||||
@@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.trains.approveWagonDetach,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
@@ -185,29 +181,38 @@ export class TrainBuilderController {
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DetachWagonDto,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
|
||||
return this.trainBuilderService.removeWagon(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required',
|
||||
})
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: SendWagonToMaintenanceDto,
|
||||
@Body() dto: SendWagonToMaintenanceDto,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,65 +225,6 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.listDetachRequests(id);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/detach-requests')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
|
||||
})
|
||||
createDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: CreateWagonDetachRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.createDetachRequest(
|
||||
id,
|
||||
wagonId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/approve')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
|
||||
})
|
||||
approveDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'APPROVE',
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/reject')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
|
||||
rejectDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'REJECT',
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity';
|
||||
|
||||
/**
|
||||
* Every detach / send-to-maintenance carries a reason — scheduled run or not
|
||||
* (the reason replaced the old second-staff approval). The recorder rejects a
|
||||
* missing/blank one before anything is written, and stores a trimmed, capped
|
||||
* copy on the audit row otherwise.
|
||||
*/
|
||||
describe('TrainBuilderService — detach reason is always required', () => {
|
||||
const svc = Object.create(TrainBuilderService.prototype) as {
|
||||
recordDetachReason(
|
||||
manager: unknown,
|
||||
trainId: string,
|
||||
wagonId: string,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
/** Minimal EntityManager: records what the recorder would persist. */
|
||||
const managerSpy = () => {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
saved,
|
||||
getRepository: (entity: { name: string }) =>
|
||||
entity.name === 'Wagon'
|
||||
? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) }
|
||||
: {
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
saved.push(row);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => {
|
||||
const manager = managerSpy();
|
||||
await expect(
|
||||
svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// Nothing is written when the reason is missing.
|
||||
expect(manager.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records the reason as an auto-approved audit row', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
' Brake shoe worn through ',
|
||||
'user-1',
|
||||
);
|
||||
expect(manager.saved).toHaveLength(1);
|
||||
const row = manager.saved[0];
|
||||
expect(row).toMatchObject({
|
||||
trainId: 'train-1',
|
||||
wagonId: 'wagon-1',
|
||||
wagonNumber: 'NW5-0412',
|
||||
action: WagonDetachRequestAction.Maintenance,
|
||||
reason: 'Brake shoe worn through',
|
||||
// No second person: the actor is both requester and decider.
|
||||
status: 'APPROVED',
|
||||
requestedBy: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps an over-long reason at the column width', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
'x'.repeat(900),
|
||||
null,
|
||||
);
|
||||
expect(String(manager.saved[0].reason)).toHaveLength(500);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
@@ -253,6 +252,7 @@ export class TrainBuilderService {
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
scheduleReference: string | null;
|
||||
reason: string | null;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
@@ -270,6 +270,7 @@ export class TrainBuilderService {
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
ts.reference AS "scheduleReference",
|
||||
l.reason,
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
@@ -491,8 +492,9 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
},
|
||||
activeSchedules: schedules,
|
||||
// Composition is frozen while the train is out on a dispatched run.
|
||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
||||
// The built train is always editable — dispatched/arrived runs render from
|
||||
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -754,22 +756,40 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
/**
|
||||
* Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run
|
||||
* the detach still executes directly, but a reason is required and recorded
|
||||
* in wagon_detach_requests (auto-approved) — the audit trail without the
|
||||
* former second-staff approval step.
|
||||
*/
|
||||
async removeWagon(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
userId,
|
||||
);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId, reason);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Transactional body of removeWagon — also runs under an approved detach request. */
|
||||
/** Transactional body of removeWagon — `reason` rides into the history log. */
|
||||
private async removeWagonCore(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -791,6 +811,7 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -805,8 +826,16 @@ export class TrainBuilderService {
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
// `note` is the required reason — recordDetachReason rejects it empty.
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
note,
|
||||
userId,
|
||||
);
|
||||
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
@@ -883,96 +912,54 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
yardId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-detach guard: while this train carries a live SCHEDULED run,
|
||||
* removing a wagon changes a departure customers already booked against, so
|
||||
* it is a two-person action — refuse here and point at the request flow.
|
||||
* DRAFT stays freely editable; DISPATCHED is already frozen by
|
||||
* getEditableTrain (the train is IN_SERVICE).
|
||||
* Every detach / send-to-maintenance carries a REASON — scheduled run or
|
||||
* not — and an auto-approved wagon_detach_requests row records who did it
|
||||
* and why (the audit trail that replaced the former second-staff approval).
|
||||
* DISPATCHED trains never reach here: getEditableTrain freezes them.
|
||||
*/
|
||||
private async assertDetachNeedsNoApproval(
|
||||
private async recordDetachReason(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<void> {
|
||||
const scheduled = await this.findScheduledRun(manager, trainId);
|
||||
if (scheduled) {
|
||||
throw new ConflictException(
|
||||
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findScheduledRun(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<{ id: string; reference: string | null } | null> {
|
||||
const rows: { id: string; reference: string | null }[] = await manager.query(
|
||||
`SELECT ts.id, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.status = 'SCHEDULED'
|
||||
AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[trainId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a detach/maintenance approval request for a wagon on a SCHEDULED
|
||||
* train. The request carries the reason; a different staffer with
|
||||
* trains:approve_wagon_detach decides it (approval executes the detach).
|
||||
*/
|
||||
async createDetachRequest(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
dto: CreateWagonDetachRequestDto,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
const scheduled = await this.findScheduledRun(manager, train.id);
|
||||
if (!scheduled) {
|
||||
throw new ConflictException(
|
||||
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
|
||||
);
|
||||
}
|
||||
// Refuse up front what an approval could never execute (booked
|
||||
// allocations pin the wagon) — but release nothing yet: slots are only
|
||||
// touched when the approved detach actually runs.
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const open = await repo.findOne({
|
||||
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
|
||||
});
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
|
||||
);
|
||||
}
|
||||
return repo.save(
|
||||
repo.create({
|
||||
trainId: train.id,
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
action: dto.action,
|
||||
reason: dto.reason.trim(),
|
||||
requestedBy: userId ?? null,
|
||||
}),
|
||||
): Promise<void> {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(
|
||||
`Give a reason for ${
|
||||
action === WagonDetachRequestAction.Maintenance
|
||||
? 'sending this wagon to maintenance'
|
||||
: 'detaching this wagon'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const now = new Date();
|
||||
await repo.save(
|
||||
repo.create({
|
||||
trainId,
|
||||
wagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? wagonId,
|
||||
action,
|
||||
reason: trimmed.slice(0, 500),
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
requestedBy: userId ?? null,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
|
||||
/** All detach/maintenance records of this train, newest first — the audit trail. */
|
||||
async listDetachRequests(trainId: string) {
|
||||
const rows: Array<{
|
||||
id: string;
|
||||
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Approve executes the detach (or maintenance
|
||||
* move) in the same transaction that stamps the decision, so an approved row
|
||||
* can never exist without its detach having happened. The requester cannot
|
||||
* approve their own request; a rejection must carry a note.
|
||||
*/
|
||||
async decideDetachRequest(
|
||||
id: string,
|
||||
requestId: string,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const request = await repo.findOne({
|
||||
where: { id: requestId, trainId: id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!request) {
|
||||
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
|
||||
}
|
||||
if (request.status !== WagonDetachRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This request was already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
const decisionNote = note?.trim() || null;
|
||||
if (decision === 'REJECT') {
|
||||
if (!decisionNote) {
|
||||
throw new BadRequestException('A note explaining the rejection is required');
|
||||
}
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Rejected,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// The 4-eyes point of the gate: requester and approver are different people.
|
||||
if (request.requestedBy && userId && request.requestedBy === userId) {
|
||||
throw new ConflictException(
|
||||
'You filed this request — a different staff member must approve it',
|
||||
);
|
||||
}
|
||||
const pendingCheck =
|
||||
request.action === WagonDetachRequestAction.Maintenance
|
||||
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
|
||||
: await this.removeWagonCore(manager, id, request.wagonId, userId);
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return pendingCheck;
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
|
||||
wagon: Wagon,
|
||||
opts: { checkOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
// Only DRAFT/SCHEDULED runs still follow the live consist, so only they can
|
||||
// pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is
|
||||
// unaffected by what happens to the physical train behind it.
|
||||
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||
await manager.query(
|
||||
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
||||
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL`,
|
||||
[wagon.id],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
|
||||
// Cargo already allocated to a live run keeps its wagon: the booking must be
|
||||
// unassigned from the slot first, so a customer's shipment can never lose its
|
||||
// wagon as a side effect of editing the train.
|
||||
if (rows.some((r) => Number(r.allocs) > 0)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
`Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`,
|
||||
);
|
||||
}
|
||||
if (opts.checkOnly) return;
|
||||
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
|
||||
// is allowed — the pinned schedules' consists are resequenced below so
|
||||
// they can never desync from the built train's real order.
|
||||
const dispatched: { exists: boolean }[] = await manager.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
|
||||
AND ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[[...current]],
|
||||
);
|
||||
if (dispatched.length > 0) {
|
||||
throw new ConflictException(
|
||||
'This train is dispatched — wagons cannot be reordered while it is rolling.',
|
||||
);
|
||||
}
|
||||
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
|
||||
// schedule renders the order frozen in its snapshot, and only the
|
||||
// DRAFT/SCHEDULED consists resequenced below follow the built train.
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
@@ -1402,6 +1317,7 @@ export class TrainBuilderService {
|
||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||
userId: string | null,
|
||||
yardId: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
if (!changes.length) return null;
|
||||
const trainSet = await manager
|
||||
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
|
||||
wagonNumber: c.wagonNumber,
|
||||
adjustedByUserId: userId,
|
||||
yardId,
|
||||
reason: reason?.trim() || null,
|
||||
occurredAt: now,
|
||||
}),
|
||||
),
|
||||
@@ -1481,17 +1398,21 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
/**
|
||||
* The built train is editable at ANY time, including while it is out on a
|
||||
* dispatched run. A dispatched/arrived schedule froze its own wagon plan into
|
||||
* `wagonAllocationSnapshot` at the transition and renders from that, so it can
|
||||
* never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow
|
||||
* the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety
|
||||
* still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon
|
||||
* whose cargo is allocated to a live run.
|
||||
*/
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user