feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- planned couples: loose wagons join the train at a route stop, added
  from the schedule yards tab; capacity credits them per corridor edge
  and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
  its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
  every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
  endpoints; builder detaches now always write adjustment-log rows

Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
34 changed files with 2532 additions and 202 deletions

View File

@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -78,6 +79,24 @@ export class TrainBuilderController {
return this.trainBuilderService.getComposition(id);
}
@Get(':id/history')
@ApiOperation({
summary:
"Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike",
})
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getTrainHistory(id, query);
}
@Get(':id/detached-wagons')
@ApiOperation({
summary:
'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach',
})
detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getDetachedWagons(id, query);
}
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })

View File

@@ -231,6 +231,117 @@ export class TrainBuilderService {
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
}
/**
* Wagon adjustment history of one built train, newest first: builder
* attaches/detaches (no schedule) and trip events (real cuts, couples,
* consist adjustments — carrying their schedule reference) alike.
*/
async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
const [countRows, rows]: [
Array<{ total: string }>,
Array<{
id: string;
action: string;
subject: string;
yardLabel: string | null;
actor: string | null;
scheduleReference: string | null;
occurredAt: Date;
}>,
] = await Promise.all([
this.dataSource.query(
`SELECT count(*) AS total
FROM freight.schedule_wagon_adjustment_logs l
WHERE l.train_id = $1
AND l.deleted_at IS NULL`,
[trainId],
),
this.dataSource.query(
`SELECT l.id,
l.action,
l.wagon_number AS "subject",
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
ts.reference AS "scheduleReference",
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id
WHERE l.train_id = $1
AND l.deleted_at IS NULL
ORDER BY l.occurred_at DESC
LIMIT $2 OFFSET $3`,
[trainId, take, skip],
),
]);
const total = Number(countRows[0]?.total ?? 0);
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
}
/**
* Wagons last detached from THIS train that are still loose (no train,
* AVAILABLE) — the re-attach shortlist, with when/where/by whom each was
* last detached. Derived from the adjustment log, no denormalized column.
*/
async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
const lastRemovalSql = `
SELECT DISTINCT ON (l.wagon_id)
l.wagon_id AS "wagonId",
l.occurred_at AS "detachedAt",
COALESCE(y.label, y.code) AS "detachedYardLabel",
COALESCE(u.username, u.email) AS "detachedBy"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
WHERE l.train_id = $1
AND l.action = 'REMOVE'
AND l.deleted_at IS NULL
ORDER BY l.wagon_id, l.occurred_at DESC`;
const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`;
const [countRows, rows]: [
Array<{ total: string }>,
Array<{
wagonId: string;
wagonNumber: string;
wagonTypeCode: string | null;
currentYardLabel: string | null;
detachedAt: Date;
detachedYardLabel: string | null;
detachedBy: string | null;
}>,
] = await Promise.all([
this.dataSource.query(
`SELECT count(*) AS total
FROM (${lastRemovalSql}) last_removal
JOIN freight.wagons w ON w.id = last_removal."wagonId"
WHERE ${stillLoose}`,
[trainId],
),
this.dataSource.query(
`SELECT last_removal."wagonId",
w.wagon_number AS "wagonNumber",
wt.code AS "wagonTypeCode",
COALESCE(cy.label, cy.code) AS "currentYardLabel",
last_removal."detachedAt",
last_removal."detachedYardLabel",
last_removal."detachedBy"
FROM (${lastRemovalSql}) last_removal
JOIN freight.wagons w ON w.id = last_removal."wagonId"
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id
WHERE ${stillLoose}
ORDER BY last_removal."detachedAt" DESC
LIMIT $2 OFFSET $3`,
[trainId, take, skip],
),
]);
const total = Number(countRows[0]?.total ?? 0);
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
@@ -721,6 +832,7 @@ export class TrainBuilderService {
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: notes.movementNote,
movedByUserId: userId,
occurredAt: new Date(),
}),
);
@@ -1097,15 +1209,14 @@ export class TrainBuilderService {
.getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
}
if (!schedule) return null;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
// Log the consist change even when the train has no live schedule — the
// builder's own detach/attach is the train's history too (who removed
// which wagon, when, where), and the detached-wagons tab reads it back.
const now = new Date();
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
changes.map((c) =>
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: schedule.id,
trainScheduleId: schedule?.id ?? null,
trainId,
action: c.action,
wagonId: c.wagonId,
@@ -1117,6 +1228,10 @@ export class TrainBuilderService {
),
);
if (!schedule) return null;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
// The FULL/reopen decision must run AFTER the transaction commits — see
// reconcileWindowAfterConsistChange.
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };