mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
feat: enhance station work logging with user display names and improve loading/unloading logic
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user