From 6ec3c8c5e09dad0fb610384aef85748071dc5066 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 27 Aug 2026 02:29:50 +0000 Subject: [PATCH] feat: enhance station work logging with user display names and improve loading/unloading logic --- .../services/train-scheduling.service.ts | 172 +++++++++++++++--- .../trainScheduling/StationWorkControls.tsx | 14 ++ .../TrainScheduleTrackPage.tsx | 74 ++++++++ .../TrainScheduleV2DetailPage.tsx | 21 ++- .../backoffice/src/types/trainScheduling.ts | 3 + packages/types/src/freight/index.ts | 20 ++ 6 files changed, 271 insertions(+), 33 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 9cec10ff7..bb171d69f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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> { + 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 { 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 diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx index 1122eb5a2..96b524551 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx @@ -217,6 +217,20 @@ export function StationWorkControls({ {fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"} ( {fmtElapsed(log!.startedAt!, log?.endedAt)}) + {log?.startedByName || log?.endedByName ? ( + + + {log?.endedByName ?? log?.startedByName} + + + ) : null} + {/* ── Loading / unloading windows per station ── */} + + } + title="Loading & unloading windows" + hint="Start and end each station's work window — times, duration and who recorded them" + /> + + {track.stations.map((s, i) => { + const isFirst = i === 0; + const isLast = i === track.stations.length - 1; + const workLog = track.stationWorkLogs?.[s.yardId]; + return ( + + + + {isLast ? : } + + + {s.label} + + {isFirst ? ( + + origin + + ) : null} + {isLast ? ( + + destination + + ) : null} + {track.currentSequenceNo === s.sequenceNo ? ( + + train here + + ) : null} + + + {!isLast ? ( + + ) : null} + {!isFirst ? ( + + ) : null} + + + ); + })} + + + {/* ── Checkpoint log ── */} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 898d1b20f..658299f99 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -499,10 +499,16 @@ export default function TrainScheduleV2DetailPage() { ? schedule.stationWorkLogs?.[originYardId]?.loading : undefined; const originLoadingStarted = Boolean(originLoadingLog?.startedAt); + const originLoadingEnded = Boolean(originLoadingLog?.endedAt); const dispatchBoardersKept = pendingOriginBoarders.some( (b) => b.isGovernment || dispatchLoadedIds.has(b.id), ); const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted; + // A train never departs mid-loading: once the window opened (or cargo is to + // board), it must be ENDED before dispatch — same gate the server enforces. + const dispatchNeedsLoadingEnd = + (dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded; + const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd; const finalizeStep = hasContainerStep ? 3 : 2; const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); @@ -966,6 +972,11 @@ export default function TrainScheduleV2DetailPage() { Start loading before dispatching — the ticked bookings are marked loaded at dispatch, which needs an open loading window. + ) : dispatchNeedsLoadingEnd ? ( + + End the loading window before dispatching — a train never departs + mid-loading. + ) : null} @@ -1699,14 +1710,18 @@ export default function TrainScheduleV2DetailPage() { Cancel