feat(transit-agent): timed document uploads and a real-data overview

This commit is contained in:
marshal
2026-09-02 23:36:35 +00:00
parent 42ca020a1f
commit 3fd94c9314
20 changed files with 3717 additions and 1152 deletions

View File

@@ -2,7 +2,9 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesModule } from "../files/files.module";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
import { TransitAssignment } from "./entities/transit-assignment.entity";
import { TransitAssignmentsController } from "./transit-assignments.controller";
@@ -14,7 +16,15 @@ import { TransitAssignmentsService } from "./transit-assignments.service";
// `Booking` is registered as an ENTITY rather than importing BookingsModule:
// this module only confirms a booking id exists, and that module would drag
// its whole graph (billing, contracts, scheduling, first/last mile) along.
TypeOrmModule.forFeature([TransitAssignment, Booking]),
// Milestones and train schedules are read for the agent's dashboard
// timings (declaration stamps, departure/arrival fallbacks) — entities
// only, for the same reason as Booking.
TypeOrmModule.forFeature([
TransitAssignment,
Booking,
ClearanceMilestone,
TrainSchedule,
]),
FilesModule,
TransitAgentsModule,
],

View File

@@ -38,6 +38,8 @@ describe("TransitAssignmentsService", () => {
remove: jest.Mock;
};
let service: TransitAssignmentsService;
let milestones: { find: jest.Mock };
let trainSchedules: { find: jest.Mock };
const row = (over: Partial<TransitAssignment> = {}) =>
({
@@ -81,11 +83,16 @@ describe("TransitAssignmentsService", () => {
remove: jest.fn(),
};
milestones = { find: jest.fn().mockResolvedValue([]) };
trainSchedules = { find: jest.fn().mockResolvedValue([]) };
service = new TransitAssignmentsService(
assignments as never,
agents as never,
bookings as never,
files as never,
milestones as never,
trainSchedules as never,
);
});
@@ -294,222 +301,127 @@ describe("TransitAssignmentsService", () => {
describe("myStats", () => {
const at = (iso: string) => new Date(iso);
const DEPARTED = at("2026-08-27T20:00:00Z");
const withRows = (rows: Record<string, unknown>[]) => {
assignments.findByTransitAgent.mockResolvedValue(
rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)),
rows.map((r, i) => row({ id: `ta-${i}`, bookingId: `bk-${i}`, ...r } as never)),
);
};
const bookingFiles = (entries: Record<string, Array<[string, string]>>) => {
files.findByResourceIdsGrouped.mockImplementation(
async (_ids: string[], resource: string) =>
resource === "bookings"
? new Map(
Object.entries(entries).map(([bookingId, list]) => [
bookingId,
list.map(([code, iso]) => ({ code, createdAt: at(iso) })),
]),
)
: new Map(),
);
files.findByResourceIdsGrouped.mockResolvedValue(new Map());
};
it("uses the median, so one reopened assignment cannot skew the headline", async () => {
it("measures transit from the train's departure to its arrival, using the median", async () => {
withRows([
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T10:35:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T12:10:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T13:45:00Z"),
},
// 47h outlier: a mean would report ~12h, which describes nobody.
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-30T08:00:00Z"),
},
{ booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T06:00:00Z"), tradeDirection: "EXPORT" } },
{ booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T08:00:00Z"), tradeDirection: "EXPORT" } },
// 3-day outlier: a mean would describe none of the three.
{ booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-30T20:00:00Z"), tradeDirection: "EXPORT" } },
// Still rolling: contributes nothing, not zero.
{ booking: { loadedAt: DEPARTED, arrivedAt: null, tradeDirection: "EXPORT" } },
]);
bookingFiles({});
const stats = await service.myStats("user-1");
// 95/190/285/2820 -> even count, so the median averages the middle two.
// A mean would be 848 minutes, describing none of the four.
expect(stats.performance.medianClearanceMinutes).toBe(238);
expect(stats.performance.slowestClearanceMinutes).toBe(2820);
expect(stats.timings.transit).toEqual({
median: 720,
fastest: 600,
slowest: 4320,
measured: 3,
});
expect(stats.totals.inTransit).toBe(1);
expect(stats.totals.arrived).toBe(3);
});
it("bands clearance times into the SLA buckets", async () => {
withRows([
it("times the Release Order from the declaration to the LAST RO upload", async () => {
withRows([{ booking: { tradeDirection: "EXPORT", loadedAt: null, arrivedAt: null } }]);
milestones.find.mockResolvedValue([
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T10:30:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T13:00:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-29T09:00:00Z"),
bookingId: "bk-0",
milestoneCode: "DECLARED",
status: "COMPLETED",
triggeredAt: at("2026-08-27T08:00:00Z"),
},
]);
bookingFiles({
"bk-0": [
["release_order_0", "2026-08-27T09:30:00Z"],
// Replaced batch — the later stamp is the one that counts.
["release_order_1", "2026-08-27T11:00:00Z"],
],
});
const stats = await service.myStats("user-1");
const [item] = stats.items;
expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 });
expect(stats.performance.onTimeRate).toBe(67);
expect(item.declaredAt).toBe("2026-08-27T08:00:00.000Z");
expect(item.roAt).toBe("2026-08-27T11:00:00.000Z");
expect(item.timings.declarationToRo).toBe(180);
expect(stats.timings.declarationToRo.median).toBe(180);
expect(item.nextAction).toEqual({ kind: "wait", label: "Awaiting train departure" });
});
it("counts coverage only over dispatched bookings", async () => {
it("points the officer at the next upload the detail page would actually allow", async () => {
withRows([
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
// Scheduled bookings cannot receive documents yet, so counting them
// would report a failure the agent could not have avoided.
{ booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } },
// Import, nothing filed: the DO comes first.
{ booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } },
// Import with a DO but no departure yet: T1 is still locked.
{ booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } },
// Import, departed, no T1: upload it.
{ booking: { tradeDirection: "IMPORT", loadedAt: DEPARTED, arrivedAt: null } },
// Export, arrived with an RO but no gate pass yet.
{
booking: {
tradeDirection: "EXPORT",
loadedAt: DEPARTED,
arrivedAt: at("2026-08-28T06:00:00Z"),
},
},
]);
milestones.find.mockResolvedValue([
{ bookingId: "bk-3", milestoneCode: "DECLARED", status: "COMPLETED", triggeredAt: at("2026-08-26T08:00:00Z") },
]);
bookingFiles({
"bk-1": [["delivery_order_0", "2026-08-26T10:00:00Z"]],
"bk-2": [["delivery_order_0", "2026-08-26T10:00:00Z"]],
"bk-3": [["release_order_0", "2026-08-26T10:00:00Z"]],
});
const stats = await service.myStats("user-1");
const byBooking = new Map(stats.items.map((i) => [i.bookingId, i]));
expect(stats.coverage.dispatched).toBe(2);
expect(stats.coverage.withDocuments).toBe(0);
expect(byBooking.get("bk-0")?.nextAction.document).toBe("do");
expect(byBooking.get("bk-1")?.nextAction).toEqual({
kind: "wait",
label: "Awaiting train departure",
});
expect(byBooking.get("bk-2")?.nextAction.document).toBe("t1");
expect(byBooking.get("bk-3")?.nextAction.document).toBe("gate_pass");
expect(stats.pending).toEqual({ ro: 0, do: 1, t1: 1, gatePass: 1, djiboutiT1: 0 });
expect(stats.totals.actionNeeded).toBe(3);
});
it("reports nulls rather than zero when nothing has been measured", async () => {
withRows([{ status: TransitAssignmentStatus.NotStarted }]);
withRows([{ status: TransitAssignmentStatus.NotStarted, booking: { arrivedAt: null } }]);
bookingFiles({});
const stats = await service.myStats("user-1");
expect(stats.performance.medianClearanceMinutes).toBeNull();
expect(stats.performance.onTimeRate).toBeNull();
expect(stats.timings.transit.median).toBeNull();
expect(stats.timings.arrivalToFinish.median).toBeNull();
expect(stats.totals.open).toBe(1);
});
});
describe("customerName", () => {
it("flattens the booking's company name", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
booking: {
id: "bk-1",
arrivedAt: ARRIVED,
schedulingStatus: "DISPATCHED",
company: { name: "SHAFICI PHARMACEUTICAL" },
} as never,
}),
);
expect((await service.findById("ta-1")).customerName).toBe(
"SHAFICI PHARMACEUTICAL",
);
});
it("is null when the booking has no company", async () => {
expect((await service.findById("ta-1")).customerName).toBeNull();
});
});
describe("canUploadDocuments", () => {
it("is true for an open assignment on a dispatched booking", async () => {
expect((await service.findById("ta-1")).canUploadDocuments).toBe(true);
});
it("is false before dispatch", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
booking: {
id: "bk-1",
arrivedAt: null,
schedulingStatus: "SCHEDULED",
} as never,
}),
);
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
});
it("is false once finished", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date(),
}),
);
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
});
});
describe("portal scoping", () => {
it("hides another agent's assignment behind a NotFound", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({ transitAgentId: "someone-else" }),
);
await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow(
NotFoundException,
);
});
it("rejects an account that is not a transit agent", async () => {
agents.findByUserId.mockResolvedValue(null);
await expect(service.findMine("user-1")).rejects.toThrow(
ForbiddenException,
);
});
it("pins the query to the session's agent and passes the filters through", async () => {
await service.findMine("user-1", {
search: "BK-2026",
status: TransitAssignmentStatus.InProgress,
schedulingStatus: "DISPATCHED",
page: 2,
pageSize: 10,
});
const [agentId, filter, skip, take] =
assignments.findByTransitAgentPaginated.mock.calls[0];
// The agent id comes from the session, never from the query — otherwise
// one agent could page through another agent's work.
expect(agentId).toBe("ag-1");
expect(filter).toMatchObject({
search: "BK-2026",
status: TransitAssignmentStatus.InProgress,
schedulingStatus: "DISPATCHED",
});
expect(skip).toBe(10);
expect(take).toBe(10);
});
it("reports pagination meta", async () => {
assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]);
const result = await service.findMine("user-1", { pageSize: 20 });
expect(result.meta).toEqual({
total: 45,
page: 1,
pageSize: 20,
totalPages: 3,
});
});
it("save moves the assignment to IN_PROGRESS, finish closes it", async () => {
await service.submitMine("user-1", "ta-1", { finish: false });
expect(assignments.update.mock.calls[0][1].status).toBe(
TransitAssignmentStatus.InProgress,
);
assignments.update.mockClear();
await service.submitMine("user-1", "ta-1", { finish: true });
expect(assignments.update.mock.calls[0][1].status).toBe(
TransitAssignmentStatus.Finished,
);
});
it("refuses to re-submit an already finished assignment", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date(),
}),
);
await expect(
service.submitMine("user-1", "ta-1", { finish: true }),
).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -7,10 +7,19 @@ import {
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { In, Repository } from "typeorm";
import {
isDeliveryOrderFileCode,
isDjiboutiT1FileCode,
isGatePassFileCode,
isReleaseOrderFileCode,
isT1TransportFileCode,
} from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesService } from "../files/files.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
import { FileRecord } from "../files/entities/file.entity";
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
@@ -66,6 +75,86 @@ export type TransitAssignmentView = TransitAssignment & {
files?: TransitAssignmentFileView[];
};
export type TransitTradeDirection = "IMPORT" | "EXPORT";
export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1";
export interface TransitNextAction {
kind: "upload" | "wait" | "done";
label: string;
document?: TransitDocumentKind;
}
/** Minutes, or null when nothing has been measured yet — never zero. */
export interface TransitTimingSummary {
median: number | null;
fastest: number | null;
slowest: number | null;
measured: number;
}
export interface TransitStatItem {
id: string;
bookingId: string;
reference: string | null;
customerName: string | null;
tradeDirection: TransitTradeDirection;
status: TransitAssignmentStatus;
schedulingStatus: string | null;
trainLabel: string | null;
assignedAt: string;
startedAt: string | null;
finishedAt: string | null;
bookingCreatedAt: string | null;
departedAt: string | null;
arrivedAt: string | null;
declaredAt: string | null;
roAt: string | null;
doAt: string | null;
t1At: string | null;
t1Closed: boolean;
gatePassAt: string | null;
djiboutiT1At: string | null;
documents: {
ro: number;
do: number;
t1: number;
gatePass: number;
djiboutiT1: number;
own: number;
};
timings: {
transit: number | null;
declarationToRo: number | null;
bookingToDo: number | null;
departureToT1: number | null;
arrivalToT1: number | null;
arrivalToGatePass: number | null;
arrivalToDjiboutiT1: number | null;
arrivalToFinish: number | null;
};
nextAction: TransitNextAction;
}
export interface TransitStats {
totals: {
assignments: number;
open: number;
notStarted: number;
inProgress: number;
finished: number;
imports: number;
exports: number;
awaitingDeparture: number;
inTransit: number;
arrived: number;
actionNeeded: number;
};
timings: Record<keyof TransitStatItem["timings"], TransitTimingSummary>;
documents: TransitStatItem["documents"];
pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number };
items: TransitStatItem[];
}
@Injectable()
export class TransitAssignmentsService {
constructor(
@@ -77,6 +166,10 @@ export class TransitAssignmentsService {
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly filesService: FilesService,
@InjectRepository(ClearanceMilestone)
private readonly milestonesRepository: Repository<ClearanceMilestone>,
@InjectRepository(TrainSchedule)
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
) {}
private static minutesBetween(
@@ -179,106 +272,302 @@ export class TransitAssignmentsService {
* reopened days later drags an average far enough to make the whole panel
* lie about typical performance.
*/
async myStats(userId: string) {
/**
* The agent's dashboard, every figure derived from stamps that already exist:
* the train's departure and arrival, the booking's clearance milestones, and
* the upload time of each document on the booking (RO / DO / T1 / gate pass /
* Djibouti T1). Replaced batches carry a fresh stamp, so an "uploaded" time
* here is always the LAST update, matching the detail page.
*
* Nothing is stored: a corrected timestamp cannot leave a stale number behind.
*/
async myStats(userId: string): Promise<TransitStats> {
const agent = await this.requireAgentForUser(userId);
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
const bookingIds = [...new Set(rows.map((r) => r.bookingId))];
const docCounts = rows.length
? await this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
)
: new Map<string, unknown[]>();
const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([
rows.length
? this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
)
: new Map<string, FileRecord[]>(),
bookingIds.length
? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings")
: new Map<string, FileRecord[]>(),
bookingIds.length
? this.milestonesRepository.find({
where: { bookingId: In(bookingIds) },
select: ["bookingId", "milestoneCode", "status", "triggeredAt"],
})
: [],
(() => {
const ids = [
...new Set(
rows
.map((r) => r.booking?.trainScheduleId)
.filter((id): id is string => Boolean(id)),
),
];
return ids.length
? this.trainSchedulesRepository.find({
where: { id: In(ids) },
select: [
"id",
"trainNumber",
"voyageNumber",
"actualDepartureAt",
"actualArrivalAt",
],
})
: [];
})(),
]);
const minutes = (from?: Date | null, to?: Date | null) =>
from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null;
const scheduleById = new Map(schedules.map((sch) => [sch.id, sch]));
const milestonesByBooking = new Map<string, ClearanceMilestone[]>();
for (const m of milestones) {
if (!m.bookingId) continue;
const bucket = milestonesByBooking.get(m.bookingId);
if (bucket) bucket.push(m);
else milestonesByBooking.set(m.bookingId, [m]);
}
const iso = (d?: Date | string | null): string | null =>
d ? new Date(d).toISOString() : null;
const minutes = (from?: string | null, to?: string | null): number | null =>
from && to
? Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60_000)
: null;
/** Latest upload stamp among files matching a code family. */
const latest = (
files: FileRecord[],
matches: (code: string | null | undefined) => boolean,
): { at: string | null; count: number } => {
const hits = files.filter((f) => matches(f.code));
return {
count: hits.length,
at: hits.reduce<string | null>((max, f) => {
const stamp = iso(f.createdAt);
return stamp && (!max || stamp > max) ? stamp : max;
}, null),
};
};
/** Earliest upload stamp — for append-only sets the FIRST document matters. */
const earliest = (
files: FileRecord[],
matches: (code: string | null | undefined) => boolean,
): { at: string | null; count: number } => {
const hits = files.filter((f) => matches(f.code));
return {
count: hits.length,
at: hits.reduce<string | null>((min, f) => {
const stamp = iso(f.createdAt);
return stamp && (!min || stamp < min) ? stamp : min;
}, null),
};
};
const items: TransitStatItem[] = rows.map((row) => {
const booking = row.booking;
const tradeDirection: TransitTradeDirection =
booking?.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
const schedule = booking?.trainScheduleId
? scheduleById.get(booking.trainScheduleId)
: undefined;
// Same rule as the clearance view's train state: the booking's own
// load/unload stamps first, the schedule's actuals only as a fallback for
// legacy bookings that predate per-booking loading.
const departedAt = iso(booking?.loadedAt ?? schedule?.actualDepartureAt);
const arrivedAt = iso(
booking?.arrivedAt ??
(booking?.loadedAt ? null : schedule?.actualArrivalAt),
);
const files = bookingDocs.get(row.bookingId) ?? [];
const ms = milestonesByBooking.get(row.bookingId) ?? [];
const milestone = (code: string) => ms.find((m) => m.milestoneCode === code);
const done = (code: string) => {
const m = milestone(code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
};
const declared = done("DECLARED");
const declaredAt = iso(milestone("DECLARED")?.triggeredAt);
const ro = latest(files, isReleaseOrderFileCode);
const deliveryOrder = latest(files, isDeliveryOrderFileCode);
const t1 = latest(files, isT1TransportFileCode);
const gatePass = earliest(files, isGatePassFileCode);
const djiboutiT1 = earliest(files, isDjiboutiT1FileCode);
const t1Closed = milestone("T1_CLOSED")?.status === "COMPLETED";
const bookingCreatedAt = iso(booking?.createdAt);
const finishedAt = iso(row.finishedAt);
const finished = row.status === TransitAssignmentStatus.Finished;
const timings: TransitStatItem["timings"] = {
transit: minutes(departedAt, arrivedAt),
declarationToRo: tradeDirection === "EXPORT" ? minutes(declaredAt, ro.at) : null,
bookingToDo:
tradeDirection === "IMPORT" ? minutes(bookingCreatedAt, deliveryOrder.at) : null,
departureToT1: tradeDirection === "IMPORT" ? minutes(departedAt, t1.at) : null,
arrivalToT1: tradeDirection === "IMPORT" ? minutes(arrivedAt, t1.at) : null,
arrivalToGatePass:
tradeDirection === "EXPORT" ? minutes(arrivedAt, gatePass.at) : null,
arrivalToDjiboutiT1:
tradeDirection === "EXPORT" ? minutes(arrivedAt, djiboutiT1.at) : null,
arrivalToFinish: minutes(arrivedAt, finishedAt),
};
// What the officer should do next on this shipment — the same gates the
// detail page enforces, so the dashboard never points at a locked button.
let nextAction: TransitNextAction;
if (finished) {
nextAction = { kind: "done", label: "Assignment finished" };
} else if (tradeDirection === "EXPORT") {
if (!declared) {
nextAction = { kind: "wait", label: "Awaiting customs declaration" };
} else if (ro.count === 0) {
nextAction = { kind: "upload", label: "Upload Release Order", document: "ro" };
} else if (!departedAt) {
nextAction = { kind: "wait", label: "Awaiting train departure" };
} else if (!arrivedAt) {
nextAction = { kind: "wait", label: "Train in transit" };
} else if (gatePass.count === 0) {
nextAction = { kind: "upload", label: "Upload gate pass", document: "gate_pass" };
} else if (djiboutiT1.count === 0) {
nextAction = {
kind: "upload",
label: "Upload Djibouti T1",
document: "djibouti_t1",
};
} else {
nextAction = { kind: "done", label: "Paperwork complete" };
}
} else if (deliveryOrder.count === 0) {
nextAction = { kind: "upload", label: "Upload Delivery Order", document: "do" };
} else if (!departedAt) {
nextAction = { kind: "wait", label: "Awaiting train departure" };
} else if (t1.count === 0 && !t1Closed) {
nextAction = { kind: "upload", label: "Upload T1 documents", document: "t1" };
} else if (!arrivedAt) {
nextAction = { kind: "wait", label: "Train in transit" };
} else {
nextAction = { kind: "done", label: t1Closed ? "T1 closed" : "Paperwork complete" };
}
const items = rows.map((row) => {
const arrivedAt = row.booking?.arrivedAt ?? null;
return {
id: row.id,
reference: row.booking?.reference ?? null,
customerName: row.booking?.company?.name ?? null,
bookingId: row.bookingId,
reference: booking?.reference ?? null,
customerName: booking?.company?.name ?? null,
tradeDirection,
status: row.status,
schedulingStatus: row.booking?.schedulingStatus ?? null,
/** Dispatch (cargo loaded) to the train arriving. */
transitMinutes: minutes(row.booking?.loadedAt, arrivedAt),
/** Arrival to the agent picking the work up. */
pickupMinutes: minutes(arrivedAt, row.startedAt),
/** Arrival to the work being finished — the headline metric. */
clearanceMinutes: minutes(arrivedAt, row.finishedAt),
documentCount: (docCounts.get(row.id) ?? []).length,
schedulingStatus: booking?.schedulingStatus ?? null,
trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null,
assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(),
startedAt: iso(row.startedAt),
finishedAt,
bookingCreatedAt,
departedAt,
arrivedAt,
declaredAt,
roAt: ro.at,
doAt: deliveryOrder.at,
t1At: t1.at,
t1Closed,
gatePassAt: gatePass.at,
djiboutiT1At: djiboutiT1.at,
documents: {
ro: ro.count,
do: deliveryOrder.count,
t1: t1.count,
gatePass: gatePass.count,
djiboutiT1: djiboutiT1.count,
own: (ownDocs.get(row.id) ?? []).length,
},
timings,
nextAction,
};
});
const median = (values: number[]): number | null => {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
// Most recently moving shipment first: arrival, else departure, else when
// it was handed to the agent.
const activity = (i: TransitStatItem) =>
i.arrivedAt ?? i.departedAt ?? i.assignedAt;
items.sort((a, b) => activity(b).localeCompare(activity(a)));
const summarize = (values: Array<number | null>): TransitTimingSummary => {
const measured = values.filter((v): v is number => v !== null && v >= 0);
if (!measured.length) {
return { median: null, fastest: null, slowest: null, measured: 0 };
}
const sorted = [...measured].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
return {
median:
sorted.length % 2
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2),
fastest: sorted[0],
slowest: sorted[sorted.length - 1],
measured: sorted.length,
};
};
const timing = (key: keyof TransitStatItem["timings"]) =>
summarize(items.map((i) => i.timings[key]));
const cleared = items
.map((i) => i.clearanceMinutes)
.filter((v): v is number => v !== null);
const pickups = items
.map((i) => i.pickupMinutes)
.filter((v): v is number => v !== null);
// SLA bands, in minutes: inside 2h, inside 6h, beyond.
const sla = {
under2h: cleared.filter((v) => v <= 120).length,
under6h: cleared.filter((v) => v > 120 && v <= 360).length,
over6h: cleared.filter((v) => v > 360).length,
};
// Coverage counts only bookings that COULD have documents — uploads are
// gated on dispatch, so counting scheduled ones would invent a failure.
const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED");
const withDocs = dispatched.filter((i) => i.documentCount > 0).length;
const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished);
const pendingFor = (document: TransitDocumentKind) =>
items.filter(
(i) => i.nextAction.kind === "upload" && i.nextAction.document === document,
).length;
const sumDocs = (key: keyof TransitStatItem["documents"]) =>
items.reduce((sum, i) => sum + i.documents[key], 0);
return {
totals: {
assignments: items.length,
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
open: open.length,
notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted)
.length,
// The open half split by status, so the roster's tab counts do not have
// to be derived from a single paginated page.
notStarted: items.filter(
(i) => i.status === TransitAssignmentStatus.NotStarted,
).length,
inProgress: items.filter(
(i) => i.status === TransitAssignmentStatus.InProgress,
).length,
finished: items.filter(
(i) => i.status === TransitAssignmentStatus.Finished,
).length,
readyForDocuments: items.filter(
(i) =>
i.schedulingStatus === "DISPATCHED" &&
i.status !== TransitAssignmentStatus.Finished,
).length,
documents: items.reduce((sum, i) => sum + i.documentCount, 0),
inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress)
.length,
finished: items.length - open.length,
imports: items.filter((i) => i.tradeDirection === "IMPORT").length,
exports: items.filter((i) => i.tradeDirection === "EXPORT").length,
awaitingDeparture: open.filter((i) => !i.departedAt).length,
inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length,
arrived: open.filter((i) => Boolean(i.arrivedAt)).length,
actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length,
},
performance: {
medianClearanceMinutes: median(cleared),
medianPickupMinutes: median(pickups),
fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null,
slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null,
onTimeRate: cleared.length
? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100)
: null,
measured: cleared.length,
timings: {
transit: timing("transit"),
declarationToRo: timing("declarationToRo"),
bookingToDo: timing("bookingToDo"),
departureToT1: timing("departureToT1"),
arrivalToT1: timing("arrivalToT1"),
arrivalToGatePass: timing("arrivalToGatePass"),
arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"),
arrivalToFinish: timing("arrivalToFinish"),
},
sla,
coverage: {
dispatched: dispatched.length,
withDocuments: withDocs,
documents: {
ro: sumDocs("ro"),
do: sumDocs("do"),
t1: sumDocs("t1"),
gatePass: sumDocs("gatePass"),
djiboutiT1: sumDocs("djiboutiT1"),
own: sumDocs("own"),
},
/** Newest first, for the timeline and the recent-activity list. */
items: items.slice(0, 12),
pending: {
ro: pendingFor("ro"),
do: pendingFor("do"),
t1: pendingFor("t1"),
gatePass: pendingFor("gate_pass"),
djiboutiT1: pendingFor("djibouti_t1"),
},
items: items.slice(0, 20),
};
}