Merge pull request #1420 from Tria-plc/freight_feature/usermanagement

feat: enhance station work logging with user display names and improv…
This commit is contained in:
marshal
2026-08-27 05:30:58 +03:00
committed by GitHub
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,
@@ -4522,6 +4531,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) {
@@ -4563,8 +4614,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,
@@ -5039,23 +5091,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.
@@ -5068,7 +5112,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,
@@ -5096,7 +5140,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.
@@ -5442,7 +5491,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,
@@ -6229,11 +6278,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) => ({
@@ -6257,10 +6306,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,
};
}
@@ -9730,7 +9810,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
@@ -9763,6 +9872,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

View File

@@ -217,6 +217,20 @@ export function StationWorkControls({
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"} (
{fmtElapsed(log!.startedAt!, log?.endedAt)})
</Text>
{log?.startedByName || log?.endedByName ? (
<Tooltip
label={[
log?.startedByName ? `Started by ${log.startedByName}` : null,
log?.endedByName ? `Ended by ${log.endedByName}` : null,
]
.filter(Boolean)
.join(" · ")}
>
<Badge size="xs" variant="light" color="gray" radius="sm">
{log?.endedByName ?? log?.startedByName}
</Badge>
</Tooltip>
) : null}
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}

View File

@@ -5,6 +5,7 @@ import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
FileText,
Flag,
MapPin,
@@ -13,6 +14,7 @@ import {
Pencil,
Train,
} from "lucide-react";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import {
Alert,
Badge,
@@ -653,6 +655,78 @@ export default function TrainScheduleTrackPage() {
</Stack>
</Paper>
{/* ── Loading / unloading windows per station ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<SectionHead
icon={<Clock size={17} />}
title="Loading & unloading windows"
hint="Start and end each station's work window — times, duration and who recorded them"
/>
<Stack gap="sm" mt="md">
{track.stations.map((s, i) => {
const isFirst = i === 0;
const isLast = i === track.stations.length - 1;
const workLog = track.stationWorkLogs?.[s.yardId];
return (
<Paper
key={s.yardId}
withBorder
radius="md"
p="sm"
style={{
background:
track.currentSequenceNo === s.sequenceNo
? "var(--mantine-color-green-0)"
: undefined,
}}
>
<Group gap={10} mb={6} wrap="nowrap">
<ThemeIcon size={30} radius="xl" variant="light" color="edr-green">
{isLast ? <Flag size={15} /> : <MapPin size={15} />}
</ThemeIcon>
<Text fw={700} size="sm">
{s.label}
</Text>
{isFirst ? (
<Badge size="xs" variant="light" color="edr-green">
origin
</Badge>
) : null}
{isLast ? (
<Badge size="xs" variant="light" color="gray">
destination
</Badge>
) : null}
{track.currentSequenceNo === s.sequenceNo ? (
<Badge size="xs" variant="filled" color="edr-green">
train here
</Badge>
) : null}
</Group>
<Stack gap={6} pl={40}>
{!isLast ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!isFirst ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
</Stack>
</Paper>
);
})}
</Stack>
</Paper>
{/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group justify="space-between" wrap="nowrap" mb="md">

View File

@@ -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.
</Text>
) : dispatchNeedsLoadingEnd ? (
<Text size="xs" c="dimmed">
End the loading window before dispatching a train never departs
mid-loading.
</Text>
) : null}
</Stack>
</Paper>
@@ -1699,14 +1710,18 @@ export default function TrainScheduleV2DetailPage() {
Cancel
</Button>
<Tooltip
label="Start loading at the origin station first — dispatch marks the ticked bookings loaded"
disabled={!dispatchNeedsLoadingStart}
label={
dispatchNeedsLoadingStart
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
: "End the loading window at the origin station first — a train never departs mid-loading"
}
disabled={!dispatchBlockedByLoading}
>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={dispatchNeedsLoadingStart}
disabled={dispatchBlockedByLoading}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}

View File

@@ -902,6 +902,9 @@ export interface StationWorkPhaseLog {
endedAt?: string | null;
startedByUserId?: string | null;
endedByUserId?: string | null;
/** Resolved display names of the recorders (track endpoint only). */
startedByName?: string | null;
endedByName?: string | null;
}
export interface StationWorkLog {

View File

@@ -328,12 +328,32 @@ export interface WagonAllocationSnapshotAllocation {
containerNumbers: string[];
}
/**
* Coupled-but-empty consist wagon captured with the frozen plan. The physical
* wagons are released and re-pinned to later trains after dispatch/arrival, so
* this snapshot is the only record of which empties rode THIS departure.
*/
export interface WagonAllocationSnapshotEmptyWagon {
physicalWagonId: string;
physicalWagonNumber: string | null;
/** Real coupling position on the built train at capture time. */
sequenceNo: number;
wagonTypeId: string | null;
wagonTypeCode: string | null;
wagonTypeName: string | null;
capacityTons: number;
tareWeightTons: number | null;
lengthMeters: number;
}
/** Whole-schedule frozen wagon plan written at a terminal/transit transition. */
export interface WagonAllocationSnapshot {
/** Schedule status the snapshot was captured at (DISPATCHED/ARRIVED/CANCELLED). */
capturedStatus: string;
capturedAt: string;
slots: WagonAllocationSnapshotSlot[];
/** Absent on snapshots captured before empties were recorded. */
emptyConsistWagons?: WagonAllocationSnapshotEmptyWagon[];
}
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";