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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-09-04 09:42:45 +03:00
committed by GitHub
21 changed files with 3429 additions and 98 deletions

View File

@@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => {
rate?: Rate;
pending?: RateChangeRequest | null;
applyThrows?: Error;
/** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */
derived?: Partial<Rate>;
previewThrows?: Error;
} = {}) => {
const rate = opts.rate ?? liveRate();
const saved: RateChangeRequest[] = [];
@@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => {
const rates = {
findById: jest.fn(async () => rate),
assertUpdateValid: jest.fn(async () => undefined),
// Stands in for buildUpdate: it resolves a patch into the full column
// set, including columns the form never posts (rateType and friends).
previewUpdate: jest.fn(async (_id: string, dto: Record<string, unknown>) => {
if (opts.previewThrows) throw opts.previewThrows;
return { ...dto, ...(opts.derived ?? {}) } as Partial<Rate>;
}),
applyApprovedUpdate: jest.fn(async () => {
if (opts.applyThrows) throw opts.applyThrows;
return rate;
@@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => {
});
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
const { service, rates } = build();
rates.assertUpdateValid.mockRejectedValueOnce(
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
);
// Resolving the patch IS the validation — buildUpdate throws on a bad
// unit, so previewUpdate surfaces it at submit time.
const { service } = build({
previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
});
await expect(
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
).rejects.toThrow(/not valid for this rate/);
});
it('shows the approver a bulk switch, which only exists as a derived column', async () => {
// The form posts intercityKind: BULK — never stored. The real edit lands
// on rateType (+ the cargo/container swap), so that is what the approver
// must see. Diffing the raw patch showed an empty change list.
const { service } = build({
rate: liveRate({
rateType: 'INTERCITY_CONTAINER',
appliesTo: 'INTERCITY',
containerTypeId: 'ct-1',
}),
derived: {
rateType: 'INTERCITY_BULK',
containerTypeId: null,
cargoTypeId: 'cargo-9',
} as Partial<Rate>,
});
const request = await service.submit({
rateId: 'rate-1',
update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never,
});
expect(request.payload).toMatchObject({
rateType: 'INTERCITY_BULK',
containerTypeId: null,
cargoTypeId: 'cargo-9',
});
expect(request.previousValues).toMatchObject({
rateType: 'INTERCITY_CONTAINER',
containerTypeId: 'ct-1',
});
});
});
describe('approve', () => {

View File

@@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates';
/** Fields a change request may carry — anything else in the patch is ignored. */
/**
* Persisted columns an approver is shown a before→after for.
*
* These are RESOLVED entity columns, not raw form fields: the diff runs
* against `RatesService.previewUpdate`, so a change the form expresses through
* a non-stored selector still shows up here as the column it actually moves
* (a flip to bulk lands on `rateType` + the container/cargo swap).
*/
const DIFFABLE_FIELDS = [
'rateValue',
'currency',
@@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [
'tradeDirection',
'containerTypeId',
'cargoTypeId',
// The container-vs-bulk shape of the rate. Missing here, switching a LIVE
// rate to bulk showed the approver an empty change list — the only column
// that records the kind is rateType, and the form never posts it directly.
'rateType',
// Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping
// line diffed to nothing.
'shippingLineCompanyId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
@@ -82,7 +96,11 @@ export class RateChangeRequestsService {
);
}
const payload = this.changedFieldsOnly(rate, dto.update);
// Diff the RESOLVED columns, not the raw patch: the form's cargoKind /
// intercityKind selectors are never stored, so a bulk switch only shows up
// once the patch is resolved into the columns it moves.
const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto);
const payload = this.changedFieldsOnly(rate, resolved);
if (Object.keys(payload).length === 0) {
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
}
@@ -98,7 +116,9 @@ export class RateChangeRequestsService {
);
}
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
// previewUpdate above already ran the full validation (it IS buildUpdate),
// so re-validating here would only repeat it — and the trimmed payload is
// resolved columns, not a form patch, so it is not the right input for it.
const request = await this.repo.save(
this.repo.create({
@@ -186,13 +206,14 @@ export class RateChangeRequestsService {
}
/**
* Keep only fields the requester actually changed. A form posts every field
* back, so without this the diff would list untouched values as changes.
* Keep only columns the edit actually moves. `buildUpdate` returns a full
* resolved column set (it re-derives scope on every patch), so without this
* the diff would list every untouched column as a change.
*/
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
private changedFieldsOnly(rate: Rate, resolved: Partial<Rate>): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const field of DIFFABLE_FIELDS) {
const proposed = (update as Record<string, unknown>)[field];
const proposed = (resolved as Record<string, unknown>)[field];
if (proposed === undefined) continue;
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
patch[field] = proposed;

View File

@@ -753,6 +753,19 @@ export class RatesService {
await this.buildUpdate(await this.findById(id), dto);
}
/**
* The exact column changes applying this patch would make, without writing.
*
* A change request diffs against THIS rather than the raw patch: the form
* posts selectors that are never stored (`cargoKind`, `intercityKind`), and
* the real edit they encode lands on derived columns — flipping a rate to
* bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing
* the raw patch missed all of it, so the approver saw an empty change list.
*/
async previewUpdate(id: string, dto: UpdateRateDto): Promise<Partial<Rate>> {
return this.buildUpdate(await this.findById(id), dto);
}
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
const updates = await this.buildUpdate(existing, dto);
const updated = await this.repository.update(existing.id, updates);

View File

@@ -867,6 +867,27 @@ export class TrainSchedulingController {
return res.send(buffer);
}
@Get("schedules/:id/wagons/export")
@TrainSchedulingView()
@ApiOperation({
summary:
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
})
async scheduleWagonListExport(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
) {
const { filename, buffer } =
await this.trainSchedulingService.scheduleWagonListWorkbook(id);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
@Get("schedules/:id/export/load-list/document")
@TrainSchedulingView()
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })

View File

@@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { TabularExportService } from '../../exports/tabular-export.service';
/** One line of the schedule wagon-list export (raw SQL projection). */
interface ScheduleWagonListRow {
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
containerNumber: string | null;
containerSizeFt: number | null;
loadType: string | null;
status: string | null;
bulkCargoDescription: string | null;
/** numeric columns arrive as strings from pg. */
vgmTons: string | null;
originLabel: string | null;
destinationLabel: string | null;
bookingReference: string | null;
customerName: string | null;
}
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -427,6 +446,9 @@ export class TrainSchedulingService {
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
@@ -3747,6 +3769,132 @@ export class TrainSchedulingService {
};
}
/**
* The schedule detail page's wagon-list Excel export.
*
* One row per container (a wagon carrying two boxes yields two rows, repeating
* the wagon number) so each container's own VGM is present and totals footable.
* Bulk wagons, having no containers, yield a single row carrying the bulk
* description and the allocated tonnage as the VGM figure.
*
* Only wagon slots that actually carry an allocation are listed — empty slots
* on the consist are omitted.
*/
async scheduleWagonListWorkbook(
scheduleId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.tabularExport) {
throw new BadRequestException('Tabular export service is unavailable');
}
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
// container-less) allocation as one row. `booking_container_units` is joined
// on BOTH container number and its booking_container line — container
// numbers repeat across bookings, so number alone would multiply rows.
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
ci.container_number AS "containerNumber",
cit.size_ft AS "containerSizeFt",
a.load_type AS "loadType",
a.status AS "status",
bl.cargo_description AS "bulkCargoDescription",
COALESCE(
ci.gross_weight_tons,
bcu.vgm_tons,
bc.vgm_per_unit_tons,
a.allocated_weight_tons
) AS "vgmTons",
COALESCE(by_.label, so.label) AS "originLabel",
COALESCE(ay.label, sd.label) AS "destinationLabel",
b.reference AS "bookingReference",
COALESCE(
slc.name,
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
c.name
) AS "customerName"
FROM freight.train_schedules s
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations a
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.bookings b ON b.id = a.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.booking_container bc
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu
ON bcu.container_number = ci.container_number
AND bcu.booking_container_id = bc.id
AND bcu.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
WHERE s.id = $1 AND s.deleted_at IS NULL
ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`,
[scheduleId],
);
// "number" is the printed line number of the sheet, not the wagon sequence —
// a two-container wagon occupies two lines, and the reader counts lines.
const sheetRows = rows.map((row, index) => ({
number: index + 1,
wagonNumber: row.wagonNumber ?? '—',
containerNumber:
row.containerNumber ??
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
originLabel: row.originLabel ?? '—',
destinationLabel: row.destinationLabel ?? '—',
customerName: row.customerName ?? '—',
}));
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${reference}`.slice(0, 31),
description: `Wagon list for train ${reference}`,
label: 'train-schedule:wagon-list',
kpis: [
{ label: 'Lines', value: sheetRows.length },
{
label: 'Wagons',
value: new Set(rows.map((r) => r.sequenceNo)).size,
},
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'number', label: 'No.', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
{ key: 'containerNumber', label: 'Container number', type: 'string' },
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
{ key: 'originLabel', label: 'Origin', type: 'string' },
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
],
rows: sheetRows,
});
return {
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
buffer,
};
}
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {

View File

@@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { ExportsModule } from '../exports/exports.module';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
@@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module';
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
ExportsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,

View File

@@ -105,4 +105,18 @@ export class ListWagonsQueryDto {
@IsOptional()
@IsDateString()
maintenanceTo?: string;
@ApiPropertyOptional({
description:
'Window (days) the per-row load/move counts are counted over. Does not filter rows.',
default: 90,
minimum: 1,
maximum: 3650,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(3650)
statsWindowDays?: number;
}

View File

@@ -177,6 +177,7 @@ export class WagonsService {
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
await this.attachMovementStats(page.items, query.statsWindowDays ?? 90);
return page;
}
@@ -216,6 +217,56 @@ export class WagonsService {
}
}
/**
* Per-wagon movement rollups for the wagon performance report: when the
* wagon last arrived anywhere (the idle clock), and how many loaded / total
* moves it made inside `windowDays`. One grouped query per page, in the same
* shape as `attachStatusDates` above — never one request per row.
*/
private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise<void> {
if (!wagons.length) return;
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
const rows: Array<{
wagonId: string;
lastMovedAt: Date | null;
loadsInWindow: string;
movesInWindow: string;
emptyMovesInWindow: string;
}> = await this.dataSource
.getRepository(WagonMovement)
.createQueryBuilder('m')
.select('m.wagon_id', 'wagonId')
.addSelect('MAX(m.occurred_at)', 'lastMovedAt')
.addSelect(
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)',
'loadsInWindow',
)
.addSelect(
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)',
'emptyMovesInWindow',
)
.addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow')
.where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.setParameters({
since,
loaded: WagonMovementKind.Loaded,
empty: WagonMovementKind.EmptyReposition,
})
.groupBy('m.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMovedAt: r?.lastMovedAt ?? null,
loadsInWindow: Number(r?.loadsInWindow ?? 0),
movesInWindow: Number(r?.movesInWindow ?? 0),
emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0),
});
}
}
async findById(id: string): Promise<Wagon> {
const wagon = await this.wagonRepo.findOne({
where: { id },
@@ -328,11 +379,35 @@ export class WagonsService {
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
async listMovements(wagonId: string): Promise<WagonMovement[]> {
await this.findById(wagonId); // 404 on unknown wagon
return this.dataSource.getRepository(WagonMovement).find({
const movements = await this.dataSource.getRepository(WagonMovement).find({
where: { wagonId },
relations: { fromYard: true, toYard: true },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
await this.attachBookingReferences(movements);
return movements;
}
/**
* Resolve each loaded move's booking to its human reference, so the UI can
* show (and link to) "BKG-11284" rather than a raw uuid. One query for the
* whole ledger; `wagon_movements` deliberately has no FK to bookings, so
* this is a read-time join on primary keys, exactly like the labels in
* `wagon-history.service`.
*/
private async attachBookingReferences(movements: WagonMovement[]): Promise<void> {
const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))];
if (!ids.length) return;
const rows: Array<{ id: string; reference: string }> = await this.dataSource.query(
`SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r.reference]));
for (const m of movements) {
Object.assign(m, {
bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null,
});
}
}
async remove(id: string, userId?: string | null): Promise<void> {

View File

@@ -62,6 +62,8 @@ import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesP
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage";
import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage";
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
@@ -232,6 +234,24 @@ const App = () => {
</RequirePermission>
}
/>
{/* Wagon performance — a read-only executive report beside Overview.
Separate from the Fleet Management wagons desk, which owns CRUD. */}
<Route
path="wagon-performance"
element={
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<WagonPerformancePage />
</RequirePermission>
}
/>
<Route
path="wagon-performance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<WagonPerformanceDetailPage />
</RequirePermission>
}
/>
{/* One drill-down route per overview domain — the old per-tab charts,
now each on its own page. Single source of truth for the
permission gate is OVERVIEW_DOMAINS, shared with the summary

View File

@@ -69,6 +69,12 @@ export const buildSidebarSections = (
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Wagon Performance",
href: "/dashboard/wagon-performance",
icon: <TrainFront />,
permission: FREIGHT_PERMS.wagons.view,
},
{
label: "Customers",
href: "/dashboard/customers",

View File

@@ -537,6 +537,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
SCHEDULE_WAGONS_EXPORT: (id: string) =>
`/train-scheduling/schedules/${id}/wagons/export`,
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
MARSHALLING_STOPS: (id: string) =>

View File

@@ -1,13 +1,10 @@
import { useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Group,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
@@ -27,8 +24,24 @@ const FIELD_LABELS: Record<string, string> = {
cargoTypeId: "Cargo type",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
minKm: "From km",
maxKm: "To km",
baseLiters: "Base liters",
rateType: "Rate type",
};
/**
* A key the backend diffed but the UI has no label for still names a real
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
*/
const labelFor = (field: string): string =>
FIELD_LABELS[field] ??
field
.replace(/([A-Z])/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.replace(/\bId\b/, "")
.trim();
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
@@ -43,13 +56,16 @@ const fmtValue = (
value: unknown,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—";
// "Not set" reads as a real before-state; a bare em dash on both sides of the
// arrow made a newly-set field look like no change at all.
if (value === null || value === undefined || value === "") return "Not set";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
// a pair of uuids. Covers yards, cargo types, container types and lines.
if (field.endsWith("Id")) {
return labels?.[String(value)] ?? String(value);
}
return String(value).replace(/_/g, " ");
@@ -66,13 +82,33 @@ const rateSummary = (r: RateChangeRequest): string => {
return parts.join(" · ") || "Rate";
};
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
const headline = (r: RateChangeRequest): string | null => {
if (!("rateValue" in r.payload)) return null;
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
const before = fmtValue("rateValue", r.previousValues.rateValue);
const after = fmtValue("rateValue", r.payload.rateValue);
return `${before}${after}${currency ? ` ${currency}` : ""}`;
/**
* Every change in the request, as readable before→after pairs. The queue must
* be scannable without expanding: a cargo or direction change is just as much
* the point as a repricing, so it gets the same one-line treatment as the rate.
*/
const summaryRows = (
r: RateChangeRequest,
labels?: Record<string, string>,
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
const currency = String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
);
// Rate first — it is what most changes are about — then the rest in a stable
// order so the same edit always reads the same way.
const fields = Object.keys(r.payload).sort((a, b) =>
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
);
return fields.map((field) => ({
field,
label: labelFor(field),
before: fmtValue(field, r.previousValues[field], labels),
after: fmtValue(field, r.payload[field], labels),
suffix: field === "rateValue" && currency ? ` ${currency}` : "",
}));
};
type Decide = UseMutationResult<
@@ -87,8 +123,9 @@ interface RateApprovalsSectionProps {
canDecide: boolean;
approve: Decide;
reject: Decide;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
/** id → label for every reference a diff can name (yards, cargo/container
* types, shipping lines), so a change reads as names, not UUIDs. */
refLabels?: Record<string, string>;
}
/**
@@ -101,11 +138,8 @@ const RateApprovalsSection = ({
canDecide,
approve,
reject,
yardLabels,
refLabels,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
if (requests.length === 0) return null;
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
@@ -125,9 +159,8 @@ const RateApprovalsSection = ({
<Stack gap={8}>
{requests.map((r) => {
const isOpen = openId === r.id;
const fields = Object.keys(r.payload);
const summaryLine = headline(r);
const rows = summaryRows(r, refLabels);
// Only the row being decided shows a spinner — the mutation's
// isPending is shared across every row.
const busy = decidingId === r.id;
@@ -145,39 +178,26 @@ const RateApprovalsSection = ({
</Text>
</Group>
{summaryLine ? (
<Group gap={6} wrap="nowrap">
{rows.map((row) => (
<Group key={row.field} gap={6} wrap="wrap" align="center">
<Text size="xs" c="dimmed">
{row.label}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue("rateValue", r.previousValues.rateValue)}
{row.before}
</Text>
<ArrowRight size={13} />
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text size="sm" fw={700} c="edr-green">
{fmtValue("rateValue", r.payload.rateValue)}
</Text>
<Text size="sm" c="dimmed">
{String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
)}
{row.after}
{row.suffix}
</Text>
</Group>
) : null}
))}
<Group gap={6}>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOpenId(isOpen ? null : r.id)}
>
{isOpen ? "Hide details" : "See all changes"}
</Button>
</Group>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
</Stack>
{canDecide ? (
@@ -190,7 +210,7 @@ const RateApprovalsSection = ({
loading={busy && reject.isPending}
disabled={busy && approve.isPending}
onClick={() =>
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
reject.mutate({ id: r.id })
}
>
Reject
@@ -202,7 +222,7 @@ const RateApprovalsSection = ({
loading={busy && approve.isPending}
disabled={busy && reject.isPending}
onClick={() =>
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
approve.mutate({ id: r.id })
}
>
Approve &amp; apply
@@ -217,38 +237,6 @@ const RateApprovalsSection = ({
)}
</Group>
<Collapse in={isOpen}>
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
{fields.map((field) => (
<Group key={field} gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field], yardLabels)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field], yardLabels)}
</Text>
</Group>
))}
{canDecide ? (
<Textarea
mt={4}
size="xs"
autosize
minRows={2}
label="Decision note (optional)"
placeholder="Shown to the requester with your decision"
value={notes[r.id] ?? ""}
onChange={(e) =>
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
}
/>
) : null}
</Stack>
</Collapse>
</Card>
);
})}

View File

@@ -322,9 +322,22 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const yardLabelById = useMemo(
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
[yardOptions],
/**
* Every id a rate diff can name, in one map. A pending change that swaps the
* cargo type or the container size stores raw uuids, so without this the
* approver reads "a1b2… → c3d4…" instead of "Perishable → Truck".
*/
const rateRefLabelById = useMemo(
() =>
Object.fromEntries(
[
...(yardOptions ?? []),
...(cargoLeafOptions ?? []),
...(containerTypeOptions ?? []),
...(shippingLineOptions ?? []),
].map((o) => [o.value, o.label]),
),
[yardOptions, cargoLeafOptions, containerTypeOptions, shippingLineOptions],
);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
@@ -868,7 +881,7 @@ const RuleEngineResourcePage = () => {
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
yardLabels={yardLabelById}
refLabels={rateRefLabelById}
/>
) : null}

View File

@@ -29,6 +29,7 @@ import {
Merge,
Container as ContainerIcon,
Eye,
FileSpreadsheet,
FileText,
History as HistoryIcon,
LayoutGrid,
@@ -102,6 +103,7 @@ import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWind
import { api } from "@/services/api";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type {
ContainerPlacement,
EligibleContainerBooking,
@@ -125,6 +127,7 @@ export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [exportingWagons, setExportingWagons] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
@@ -314,6 +317,31 @@ export default function TrainScheduleV2DetailPage() {
);
const marshallingStops = marshallingStopsQuery.data ?? [];
/** Wagon list (one row per container) as an .xlsx download. */
const handleExportWagons = useCallback(async () => {
if (!scheduleId) return;
setExportingWagons(true);
try {
const blob =
await trainSchedulingService.downloadScheduleWagonsWorkbook(scheduleId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagon-list-${schedule?.reference ?? scheduleId}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason rides inside the Blob, so the sync
// decoder would surface only "Request failed with status code 400".
toast({
title: await extractDownloadErrorMessage(error),
variant: "destructive",
});
} finally {
setExportingWagons(false);
}
}, [scheduleId, schedule?.reference, toast]);
useEffect(() => {
const operation = gatepassQuery.data;
if (!operation) return;
@@ -1323,6 +1351,18 @@ export default function TrainScheduleV2DetailPage() {
Load Empty Container
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="light"
color="edr-green"
size="compact-sm"
leftSection={<FileSpreadsheet size={14} />}
loading={exportingWagons}
onClick={() => void handleExportWagons()}
>
Export wagons
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"

View File

@@ -0,0 +1,50 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { Download } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export interface SectionExportButtonProps {
/** What this button downloads, e.g. "wagon list" — used in the tooltip and toast. */
label: string;
/** Runs the download; false means there was nothing to write. */
onExport: () => boolean;
disabled?: boolean;
}
/**
* Excel download for one section of the wagon performance report. Sits in the
* section's own header, so what it exports is unambiguous — the block it is
* attached to, exactly as filtered on screen.
*/
export function SectionExportButton({
label,
onExport,
disabled,
}: SectionExportButtonProps) {
const { toast } = useToast();
return (
<Tooltip label={`Download ${label} as Excel`}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={`Download ${label} as Excel`}
disabled={disabled}
onClick={(e) => {
// The row underneath may navigate; a download must not trigger it.
e.stopPropagation();
const wrote = onExport();
if (!wrote) {
toast({
title: "Nothing to export",
description: `There are no ${label} rows to download yet.`,
});
}
}}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1,80 @@
import * as XLSX from "xlsx";
/**
* Excel download for one section of the wagon performance report.
*
* Each section on the page exports exactly what is on screen — the same rows,
* in the same order, honouring the same filters and date window — so a figure
* in the spreadsheet always reconciles with the figure the CEO just read.
*
* Built client-side from data already in the browser: the report holds the
* whole fleet in memory (see WagonPerformancePage), so there is nothing to
* re-fetch and no server round-trip.
*/
/** A sheet's worth of rows: ordered column headers plus plain-value records. */
export interface SheetSpec {
/** Sheet tab name. Excel caps these at 31 chars and forbids : \ / ? * [ ]. */
name: string;
rows: Array<Record<string, string | number | null>>;
}
/** Excel rejects these in a sheet name, and silently truncates past 31 chars. */
const safeSheetName = (name: string): string =>
name.replace(/[:\\/?*[\]]/g, "-").slice(0, 31) || "Sheet1";
/** Widen each column to its longest cell, so nothing opens as ####. */
function fitColumns(
rows: Array<Record<string, unknown>>,
): Array<{ wch: number }> {
const headers = Object.keys(rows[0] ?? {});
return headers.map((h) => {
const longest = rows.reduce((max, row) => {
const cell = row[h];
const len = cell == null ? 0 : String(cell).length;
return len > max ? len : max;
}, h.length);
// Cap the width so one long note cannot push a column off the screen.
return { wch: Math.min(Math.max(longest + 2, 10), 60) };
});
}
/** Timestamp suffix so repeated downloads don't overwrite each other. */
const stamp = (): string => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
};
/**
* Download one or more sheets as a single .xlsx.
*
* `filenameBase` gets the timestamp and extension appended. Sheets with no
* rows are skipped; if that leaves nothing, the download is skipped entirely
* and the function returns false so the caller can say so.
*/
export function downloadSheets(
filenameBase: string,
sheets: SheetSpec[],
): boolean {
const populated = sheets.filter((s) => s.rows.length > 0);
if (!populated.length) return false;
const workbook = XLSX.utils.book_new();
for (const spec of populated) {
const sheet = XLSX.utils.json_to_sheet(spec.rows);
sheet["!cols"] = fitColumns(spec.rows);
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(spec.name));
}
XLSX.writeFile(workbook, `${filenameBase}-${stamp()}.xlsx`);
return true;
}
/** Single-sheet convenience wrapper — the shape most sections need. */
export function downloadSheet(
filenameBase: string,
sheetName: string,
rows: Array<Record<string, string | number | null>>,
): boolean {
return downloadSheets(filenameBase, [{ name: sheetName, rows }]);
}

View File

@@ -0,0 +1,237 @@
/**
* Derived wagon performance figures for the CEO's wagon report.
*
* Nothing here is stored: every number is computed in the browser from the
* ledgers the API already returns — `wagon_movements` (relocations),
* `wagon_status_logs` (roster flips) and `wagon_events` (unified history).
* Keeping the derivation in one place means the report and the wagon record
* can never disagree about what "idle" or "utilisation" means.
*
* This report is READ-ONLY and lives beside the Overview dashboard. It does
* not replace the Fleet Management wagons desk, which owns wagon CRUD.
*/
import { Freight } from "@edr/types";
import type {
Wagon,
WagonMovementRecord,
WagonStatusLog,
} from "@/services/wagon.service";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days past which a parked wagon is treated as stranded. */
export const IDLE_THRESHOLD_DAYS = 21;
/** Days off the roster past which a repair is treated as overdue. */
export const DOWN_THRESHOLD_DAYS = 30;
/** Statuses that take a wagon off the earning roster. */
export const OFF_ROSTER_STATUSES: Freight.WagonStatus[] = [
Freight.WagonStatus.Maintenance,
Freight.WagonStatus.Detained,
Freight.WagonStatus.OutOfService,
];
export const isOffRoster = (status: Freight.WagonStatus): boolean =>
OFF_ROSTER_STATUSES.includes(status);
/** Whole days between `iso` and now; null when the timestamp is missing. */
export function daysSince(iso: string | null | undefined): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.max(0, Math.floor((Date.now() - t) / DAY_MS));
}
/** Fractional days between two timestamps; `to` null means "still open". */
export function daysBetween(
from: string | null | undefined,
to: string | null | undefined,
): number | null {
if (!from) return null;
const a = new Date(from).getTime();
if (Number.isNaN(a)) return null;
const b = to ? new Date(to).getTime() : Date.now();
if (Number.isNaN(b)) return null;
return Math.max(0, (b - a) / DAY_MS);
}
export interface WagonPerformance {
/** Days since the wagon last arrived anywhere — the idle clock. */
idleDays: number | null;
/** Days in the current off-roster spell; null while in service. */
downDays: number | null;
loads: number;
moves: number;
emptyMoves: number;
manualMoves: number;
/** Share of moves that carried cargo, 0100; null when nothing moved. */
loadedShare: number | null;
lastMovement: WagonMovementRecord | null;
/** Off-roster spells overlapping the window. */
spells: number;
/** Days off roster inside the window. */
downDaysInWindow: number;
/** Share of the window spent on the roster, 0100. */
availability: number;
}
/**
* Roll one wagon's ledgers up into the figures the report shows.
*
* `windowDays` bounds loads, moves and downtime. Idle days and the current
* down spell are "how long has this been true right now" — never windowed.
*/
export function computeWagonPerformance(
wagon: Pick<Wagon, "status" | "lastMaintenanceAt" | "lastAvailableAt">,
movements: WagonMovementRecord[],
statusLogs: WagonStatusLog[],
windowDays: number,
): WagonPerformance {
const since = Date.now() - windowDays * DAY_MS;
// Movements arrive newest-first from the API; don't rely on it.
const ordered = [...movements].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
const lastMovement = ordered[0] ?? null;
const inWindow = ordered.filter((m) => {
const t = new Date(m.occurredAt).getTime();
return !Number.isNaN(t) && t >= since;
});
const loads = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Loaded,
).length;
const emptyMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.EmptyReposition,
).length;
const manualMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Manual,
).length;
const moves = inWindow.length;
const idleDays = daysSince(lastMovement?.occurredAt ?? null);
// Newest first, so a flip's "until" is the log entry before it in the array.
const logs = [...statusLogs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
let downDays: number | null = null;
if (isOffRoster(wagon.status)) {
const entered = logs.find((l) => l.toStatus === wagon.status);
downDays = daysSince(entered?.createdAt ?? wagon.lastMaintenanceAt ?? null);
}
// Downtime inside the window: walk each off-roster entry to the flip that
// ended it, clamping both ends to the window.
let downDaysInWindow = 0;
let spells = 0;
logs.forEach((log, i) => {
if (!isOffRoster(log.toStatus)) return;
const start = new Date(log.createdAt).getTime();
if (Number.isNaN(start)) return;
const closed = logs[i - 1];
const end = closed ? new Date(closed.createdAt).getTime() : Date.now();
const from = Math.max(start, since);
const to = Math.min(end, Date.now());
if (to <= from) return;
downDaysInWindow += (to - from) / DAY_MS;
spells += 1;
});
const availability =
windowDays > 0
? Math.max(
0,
Math.min(
100,
Math.round(((windowDays - downDaysInWindow) / windowDays) * 100),
),
)
: 100;
return {
idleDays,
downDays,
loads,
moves,
emptyMoves,
manualMoves,
loadedShare: moves > 0 ? Math.round((loads / moves) * 100) : null,
lastMovement,
spells,
downDaysInWindow: Math.round(downDaysInWindow),
availability,
};
}
/** Mantine colour per wagon status. */
export function statusColor(status: Freight.WagonStatus): string {
switch (status) {
case Freight.WagonStatus.Available:
return "edr-green";
case Freight.WagonStatus.Assigned:
case Freight.WagonStatus.ImportReady:
return "blue";
case Freight.WagonStatus.ExportReady:
return "teal";
case Freight.WagonStatus.Maintenance:
return "yellow";
case Freight.WagonStatus.Detained:
return "red";
case Freight.WagonStatus.OutOfService:
default:
return "gray";
}
}
/** Mantine colour per movement kind. */
export function movementKindColor(kind: Freight.WagonMovementKind): string {
switch (kind) {
case Freight.WagonMovementKind.Loaded:
return "edr-green";
case Freight.WagonMovementKind.EmptyReposition:
return "teal";
case Freight.WagonMovementKind.Maintenance:
return "yellow";
case Freight.WagonMovementKind.Manual:
default:
return "gray";
}
}
/** Mantine colour per history-event category. */
export function eventCategoryColor(
category: Freight.WagonEventCategory,
): string {
switch (category) {
case Freight.WagonEventCategory.Yard:
return "yellow";
case Freight.WagonEventCategory.Train:
return "blue";
case Freight.WagonEventCategory.Schedule:
return "indigo";
case Freight.WagonEventCategory.Cargo:
return "edr-green";
case Freight.WagonEventCategory.Status:
return "orange";
case Freight.WagonEventCategory.Lifecycle:
default:
return "gray";
}
}
/** Idle banding shared by the table and the distribution chart. */
export function idleBand(
idleDays: number | null,
): "ok" | "watch" | "stranded" | "unknown" {
if (idleDays == null) return "unknown";
if (idleDays > IDLE_THRESHOLD_DAYS) return "stranded";
if (idleDays > Math.round(IDLE_THRESHOLD_DAYS / 2)) return "watch";
return "ok";
}

View File

@@ -756,6 +756,15 @@ export const trainSchedulingService = {
return response.data;
},
/** The schedule detail page's wagon-list Excel export. */
downloadScheduleWagonsWorkbook: async (scheduleId: string): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_WAGONS_EXPORT(scheduleId),
{ responseType: "blob" },
);
return response.data as Blob;
},
downloadIntercityMarshallingDocument: async (
scheduleId: string,
): Promise<Blob> => {

View File

@@ -29,6 +29,12 @@ export interface Wagon {
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
/** Newest wagon_movements arrival — the idle clock's start (list endpoint only). */
lastMovedAt?: string | null;
/** Loaded / empty / total moves inside `statsWindowDays` (list endpoint only). */
loadsInWindow?: number;
movesInWindow?: number;
emptyMovesInWindow?: number;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
@@ -55,6 +61,8 @@ export interface WagonListFilters {
* the latest status-log flip to MAINTENANCE, not a stored column. */
maintenanceFrom?: string;
maintenanceTo?: string;
/** Window (days) the per-row load/move counts cover. Does not filter rows. */
statsWindowDays?: number;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
@@ -73,6 +81,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
if (filters.statsWindowDays)
params.set('statsWindowDays', String(filters.statsWindowDays));
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
@@ -101,6 +111,8 @@ export interface WagonMovementRecord {
note: string | null;
createdAt: string;
wagon?: { id: string; wagonNumber?: string } | null;
/** The booking's human reference, joined at read time. Null when unloaded. */
bookingReference?: string | null;
}
/** One row of the wagon status audit trail. Returned newest first by the API. */