mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
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:
@@ -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)' })
|
||||
|
||||
@@ -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' };
|
||||
|
||||
Reference in New Issue
Block a user