mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
feat(transit-agent): timed document uploads and a real-data overview
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user