feat: enhance station work logging with user display names and improve loading/unloading logic

This commit is contained in:
Marshal
2026-08-27 02:29:50 +00:00
parent 058535d648
commit 6ec3c8c5e0
6 changed files with 271 additions and 33 deletions

View File

@@ -55,7 +55,10 @@ import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import {
TrainSchedule,
type StationWorkPhaseLog,
} from '../../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
@@ -2910,14 +2913,20 @@ export class TrainSchedulingService {
const boardersToLoad = dto.loadedBookingIds
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
: originBoarders;
if (
boardersToLoad.length &&
!schedule.stationWorkLogs?.[schedule.originStationId]?.loading?.startedAt
) {
const originLoadingLog =
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
if (boardersToLoad.length && !originLoadingLog?.startedAt) {
throw new BadRequestException(
'Start loading at the origin station before dispatching with cargo to load',
);
}
// A train never departs mid-loading: once the origin's loading window was
// opened (or there is cargo to load), it must be ENDED before dispatch.
if ((boardersToLoad.length || originLoadingLog?.startedAt) && !originLoadingLog?.endedAt) {
throw new BadRequestException(
'End the loading window at the origin station before dispatching',
);
}
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
@@ -2963,7 +2972,7 @@ export class TrainSchedulingService {
actualDepartureAt: now,
trainNumber,
// Freeze the wagon plan the moment the train leaves the editable phase.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Dispatched,
now,
@@ -4430,6 +4439,48 @@ export class TrainSchedulingService {
}
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
/** Attach `startedByName` / `endedByName` to each work-window phase (one iam lookup). */
private async stationWorkLogsWithNames(
logs: TrainSchedule['stationWorkLogs'],
): Promise<Record<string, unknown>> {
const workLogs = logs ?? {};
const userIds = [
...new Set(
Object.values(workLogs)
.flatMap((log) => [
log.loading?.startedByUserId,
log.loading?.endedByUserId,
log.unloading?.startedByUserId,
log.unloading?.endedByUserId,
])
.filter((id): id is string => Boolean(id)),
),
];
const rows: Array<{ id: string; name: string | null }> = userIds.length
? await this.dataSource.query(
`SELECT id, COALESCE(username, email) AS name FROM iam.users WHERE id = ANY($1::uuid[])`,
[userIds],
)
: [];
const nameById = new Map(rows.map((r) => [r.id, r.name]));
const withNames = (phase?: StationWorkPhaseLog) =>
phase
? {
...phase,
startedByName: phase.startedByUserId
? nameById.get(phase.startedByUserId) ?? null
: null,
endedByName: phase.endedByUserId ? nameById.get(phase.endedByUserId) ?? null : null,
}
: undefined;
return Object.fromEntries(
Object.entries(workLogs).map(([yardId, log]) => [
yardId,
{ loading: withNames(log.loading), unloading: withNames(log.unloading) },
]),
);
}
async getScheduleCheckpoints(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -4471,8 +4522,9 @@ export class TrainSchedulingService {
destination: stations[stations.length - 1]?.label ?? null,
stations,
// Per-yard loading/unloading time windows for the track page's
// start/end buttons and elapsed-time display.
stationWorkLogs: schedule.stationWorkLogs ?? {},
// start/end buttons and elapsed-time display — with the recorder's
// display name resolved so staff see WHO started/ended each window.
stationWorkLogs: await this.stationWorkLogsWithNames(schedule.stationWorkLogs),
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
@@ -4947,23 +4999,15 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
// Arrival bulk-marks every booking destined for the final yard as arrived
// (autoArriveAtFinalYard) — unloading is tracked per station, so the
// destination's unloading time window must be started before that sweep
// may run. Skipped when nothing on the train alights at the final yard.
const alightsAtFinal = (schedule.scheduleBookings ?? []).some(
(sb) =>
sb.booking?.destinationYardId === schedule.destinationStationId &&
sb.booking?.status === 'IN_TRANSIT',
// Arrival happens BEFORE unloading: the train is marked arrived whenever
// it physically gets there, and the destination's unloading window opens
// afterwards. The bulk booking sweep (autoArriveAtFinalYard) only runs
// when that window is already open — otherwise final-yard bookings stay
// IN_TRANSIT and are unloaded per booking once staff start unloading
// (the per-booking endpoint enforces the window itself).
const destinationUnloadingStarted = Boolean(
schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt,
);
if (
alightsAtFinal &&
!schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt
) {
throw new BadRequestException(
'Start unloading at the destination station before marking the train arrived',
);
}
// The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
@@ -4976,7 +5020,7 @@ export class TrainSchedulingService {
{
actualArrivalAt: now,
// Freeze the plan before the wagons below are released to their yards.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Arrived,
now,
@@ -5004,7 +5048,12 @@ export class TrainSchedulingService {
// operator didn't unload individually get their arrival stamped now as a
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
// their own unload (possibly already done while the train kept rolling).
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
// Runs only when the destination's unloading window is already open —
// otherwise arrival precedes unloading and staff unload per booking
// after starting the window.
if (destinationUnloadingStarted) {
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
}
// Release every locomotive of the set (not just the legacy primary) and move it
// to the destination yard where it physically arrived.
@@ -5350,7 +5399,7 @@ export class TrainSchedulingService {
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
// Freeze the plan before the wagons below are released back to the yard.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Cancelled,
now,
@@ -6137,11 +6186,11 @@ export class TrainSchedulingService {
* physical wagons, so the historical allocation survives those wagons being
* re-pinned onto later trains. `capturedStatus` is the status being applied.
*/
private buildWagonAllocationSnapshot(
private async buildWagonAllocationSnapshot(
schedule: TrainSchedule,
capturedStatus: TrainScheduleStatusEnum,
capturedAt: Date,
): WagonAllocationSnapshot {
): Promise<WagonAllocationSnapshot> {
const slots = [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
@@ -6165,10 +6214,41 @@ export class TrainSchedulingService {
})),
}));
// A built train hauls EVERY coupled wagon, empties included. After this
// transition the physical wagons are released and re-pinned to later
// trains, so capture the empty consist here — it is the only durable
// record of which empties rode this departure (history + yard tracking).
const coveredPhysicalIds = new Set(
slots.map((slot) => slot.physicalWagonId).filter(Boolean),
);
const trainWagons = schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
})
: [];
const emptyConsistWagons = trainWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
sequenceNo: wagon.sequenceNumber ?? slots.length + index + 1,
wagonTypeId: wagon.wagonTypeId ?? null,
wagonTypeCode: wagon.wagonType?.code ?? null,
wagonTypeName: wagon.wagonType?.name ?? null,
capacityTons: Number(wagon.wagonType?.capacityTons ?? 0),
tareWeightTons: wagon.wagonType
? Number(wagon.wagonType.tareWeightTons)
: null,
lengthMeters: Number(wagon.wagonType?.lengthMeters ?? 0),
}));
return {
capturedStatus,
capturedAt: capturedAt.toISOString(),
slots,
emptyConsistWagons,
};
}
@@ -9638,7 +9718,36 @@ export class TrainSchedulingService {
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons = rawConsistWagons
// Frozen schedules: the live wagon↔train joins no longer describe this
// departure, so the empty consist is read from the snapshot captured at
// dispatch/arrival — that keeps "which wagons ran empty" in the history
// views. Snapshots from before empties were recorded simply have none.
const frozenEmptyConsistWagons = (snapshot?.emptyConsistWagons ?? []).map(
(wagon) => ({
id: wagon.physicalWagonId,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(wagon.capacityTons),
lengthMeters: roundTons(wagon.lengthMeters),
assignedWeightTons: 0,
tareWeightTons:
wagon.tareWeightTons != null ? roundTons(wagon.tareWeightTons) : null,
status: 'EMPTY',
boardYardId: null,
alightYardId: null,
physicalWagonId: wagon.physicalWagonId,
physicalWagonNumber: wagon.physicalWagonNumber,
wagonType: wagon.wagonTypeId
? {
id: wagon.wagonTypeId,
code: wagon.wagonTypeCode ?? '',
name: wagon.wagonTypeName ?? '',
}
: null,
allocations: [],
consistOnly: true,
}),
);
const liveEmptyConsistWagons = rawConsistWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
@@ -9671,6 +9780,9 @@ export class TrainSchedulingService {
allocations: [],
consistOnly: true,
}));
const emptyConsistWagons = isWagonAllocationFrozen
? frozenEmptyConsistWagons
: liveEmptyConsistWagons;
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
// is already ASC/DESC per reverseWagonOrder), not in slot order — see