mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
938 lines
33 KiB
TypeScript
938 lines
33 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from "@nestjs/common";
|
||
|
||
import { InjectRepository } from "@nestjs/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";
|
||
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
|
||
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
|
||
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
|
||
import {
|
||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||
TransitAssignment,
|
||
TransitAssignmentStatus,
|
||
} from "./entities/transit-assignment.entity";
|
||
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
|
||
|
||
/** One attached document, flattened for the API. */
|
||
export interface TransitAssignmentFileView {
|
||
id: string;
|
||
name: string;
|
||
title: string | null;
|
||
url: string;
|
||
size: number;
|
||
mimeType: string;
|
||
/** When the file was first uploaded. */
|
||
uploadedAt: string;
|
||
/** When its metadata was last edited — equal to `uploadedAt` if never. */
|
||
updatedAt: string;
|
||
uploadedByUserId: string | null;
|
||
uploadedByName: string | null;
|
||
}
|
||
|
||
export type TransitAssignmentView = TransitAssignment & {
|
||
/**
|
||
* Minutes between the train arriving and the transit work finishing —
|
||
* `finishedAt − booking.arrivedAt`, floored to whole minutes.
|
||
*
|
||
* Null until BOTH exist: an unfinished assignment has no end, and a booking
|
||
* whose arrival was never stamped has no start. Computed rather than stored
|
||
* so a corrected timestamp cannot leave a stale number behind.
|
||
*/
|
||
timeAfterTrainArrives: number | null;
|
||
/**
|
||
* Whether documents may still be added or removed right now. Mirrors
|
||
* `assertUploadAllowed` so the portal can disable its controls instead of
|
||
* letting the agent discover the rule through a 403.
|
||
*/
|
||
canUploadDocuments: boolean;
|
||
/**
|
||
* Whose cargo this is. Flattened off the joined company so the portal grid
|
||
* does not have to reach through `booking.company` — and so a booking with no
|
||
* company (shipping-line bookings carry none) renders as a blank rather than
|
||
* throwing.
|
||
*/
|
||
customerName: string | null;
|
||
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(
|
||
private readonly assignmentsRepository: TransitAssignmentsRepository,
|
||
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||
// The Booking ENTITY, not BookingsModule: this only needs to confirm a
|
||
// booking id exists, and importing that module would pull its whole graph
|
||
// (billing, contracts, scheduling, first/last mile) in behind it.
|
||
@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(
|
||
from?: Date | null,
|
||
to?: Date | null,
|
||
): number | null {
|
||
if (!from || !to) return null;
|
||
return Math.floor((to.getTime() - from.getTime()) / 60_000);
|
||
}
|
||
|
||
private toView(assignment: TransitAssignment): TransitAssignmentView {
|
||
return {
|
||
...assignment,
|
||
timeAfterTrainArrives: TransitAssignmentsService.minutesBetween(
|
||
assignment.booking?.arrivedAt,
|
||
assignment.finishedAt,
|
||
),
|
||
canUploadDocuments:
|
||
assignment.status !== TransitAssignmentStatus.Finished &&
|
||
assignment.booking?.schedulingStatus === "DISPATCHED",
|
||
customerName: assignment.booking?.company?.name ?? null,
|
||
};
|
||
}
|
||
|
||
async findAll(query: TransitAssignmentQueryDto) {
|
||
const page = query.page ?? 1;
|
||
const pageSize = query.pageSize ?? 20;
|
||
const [items, total] = await this.assignmentsRepository.findPaginated(
|
||
{
|
||
bookingId: query.bookingId,
|
||
transitAgentId: query.transitAgentId,
|
||
status: query.status,
|
||
},
|
||
(page - 1) * pageSize,
|
||
pageSize,
|
||
);
|
||
|
||
return {
|
||
items: items.map((item) => this.toView(item)),
|
||
meta: {
|
||
total,
|
||
page,
|
||
pageSize,
|
||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Detail read — the only one that carries the attached documents. */
|
||
async findById(id: string): Promise<TransitAssignmentView> {
|
||
const assignment =
|
||
await this.assignmentsRepository.findOneWithRelations(id);
|
||
if (!assignment) {
|
||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||
}
|
||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||
}
|
||
|
||
/** Every assignment handed to one transit agent — their workload list. */
|
||
async findByTransitAgent(
|
||
transitAgentId: string,
|
||
): Promise<TransitAssignmentView[]> {
|
||
const agent = await this.transitAgentsRepository.findById(transitAgentId);
|
||
if (!agent) {
|
||
throw new NotFoundException(`Transit agent ${transitAgentId} not found`);
|
||
}
|
||
const rows =
|
||
await this.assignmentsRepository.findByTransitAgent(transitAgentId);
|
||
return rows.map((row) => this.toView(row));
|
||
}
|
||
|
||
/** Every agent assigned to one booking. */
|
||
async findByBooking(bookingId: string): Promise<TransitAssignmentView[]> {
|
||
const rows = await this.assignmentsRepository.findByBooking(bookingId);
|
||
return rows.map((row) => this.toView(row));
|
||
}
|
||
|
||
// ── Portal (the signed-in transit agent's own work) ───────────────────────
|
||
// Every one of these resolves the agent from the SESSION and never from a
|
||
// client-supplied id: an agent must not be able to read or edit another
|
||
// agent's assignments by guessing one.
|
||
|
||
/** The transit agent this portal user signs in as. */
|
||
private async requireAgentForUser(userId: string) {
|
||
const agent = await this.transitAgentsRepository.findByUserId(userId);
|
||
if (!agent) {
|
||
throw new ForbiddenException("This account is not a transit agent");
|
||
}
|
||
return agent;
|
||
}
|
||
|
||
/**
|
||
* Dashboard figures for the signed-in agent's own work.
|
||
*
|
||
* Every interval is derived from timestamps that already exist — nothing is
|
||
* stored, so a corrected arrival or finish time changes these on the next
|
||
* read rather than leaving a stale metric behind.
|
||
*
|
||
* The median is used rather than the mean on purpose: one assignment
|
||
* reopened days later drags an average far enough to make the whole panel
|
||
* lie about typical performance.
|
||
*/
|
||
/**
|
||
* 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 [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 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" };
|
||
}
|
||
|
||
return {
|
||
id: row.id,
|
||
bookingId: row.bookingId,
|
||
reference: booking?.reference ?? null,
|
||
customerName: booking?.company?.name ?? null,
|
||
tradeDirection,
|
||
status: row.status,
|
||
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,
|
||
};
|
||
});
|
||
|
||
// 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 {
|
||
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 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: open.length,
|
||
notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted)
|
||
.length,
|
||
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,
|
||
},
|
||
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"),
|
||
},
|
||
documents: {
|
||
ro: sumDocs("ro"),
|
||
do: sumDocs("do"),
|
||
t1: sumDocs("t1"),
|
||
gatePass: sumDocs("gatePass"),
|
||
djiboutiT1: sumDocs("djiboutiT1"),
|
||
own: sumDocs("own"),
|
||
},
|
||
pending: {
|
||
ro: pendingFor("ro"),
|
||
do: pendingFor("do"),
|
||
t1: pendingFor("t1"),
|
||
gatePass: pendingFor("gate_pass"),
|
||
djiboutiT1: pendingFor("djibouti_t1"),
|
||
},
|
||
items: items.slice(0, 20),
|
||
};
|
||
}
|
||
|
||
async findMine(userId: string, query: MyAssignmentsQueryDto = {}) {
|
||
const agent = await this.requireAgentForUser(userId);
|
||
const page = query.page ?? 1;
|
||
const pageSize = query.pageSize ?? 20;
|
||
|
||
const [rows, total] =
|
||
await this.assignmentsRepository.findByTransitAgentPaginated(
|
||
agent.id,
|
||
{
|
||
status: query.status,
|
||
schedulingStatus: query.schedulingStatus,
|
||
search: query.search,
|
||
},
|
||
(page - 1) * pageSize,
|
||
pageSize,
|
||
);
|
||
|
||
// Documents come back with the list so the grid can show a per-row count.
|
||
// Batched deliberately: one lookup for the page, not one per assignment.
|
||
const grouped = rows.length
|
||
? await this.filesService.findByResourceIdsGrouped(
|
||
rows.map((row) => row.id),
|
||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||
)
|
||
: new Map();
|
||
|
||
return {
|
||
items: rows.map((row) => ({
|
||
...this.toView(row),
|
||
files: (grouped.get(row.id) ?? []).map((record: FileRecord) => ({
|
||
id: record.id,
|
||
name: record.name,
|
||
title: record.title,
|
||
url: record.url,
|
||
size: record.size,
|
||
mimeType: record.mimeType,
|
||
uploadedAt: record.createdAt.toISOString(),
|
||
updatedAt: record.updatedAt.toISOString(),
|
||
uploadedByUserId: record.uploadedByUserId,
|
||
uploadedByName: record.uploadedByName,
|
||
})),
|
||
})),
|
||
meta: {
|
||
total,
|
||
page,
|
||
pageSize,
|
||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One of the signed-in agent's own assignments, with its documents.
|
||
* Ownership is asserted rather than filtered: a mismatch is hidden behind a
|
||
* NotFound so assignment ids cannot be probed.
|
||
*/
|
||
async findMineById(
|
||
userId: string,
|
||
id: string,
|
||
): Promise<TransitAssignmentView> {
|
||
const agent = await this.requireAgentForUser(userId);
|
||
const assignment =
|
||
await this.assignmentsRepository.findOneWithRelations(id);
|
||
if (!assignment || assignment.transitAgentId !== agent.id) {
|
||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||
}
|
||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||
}
|
||
|
||
/** Assert the assignment is this user's before any write reaches it. */
|
||
private async assertMine(userId: string, id: string): Promise<void> {
|
||
await this.findMineById(userId, id);
|
||
}
|
||
|
||
async uploadMyFiles(
|
||
userId: string,
|
||
id: string,
|
||
files: Express.Multer.File[],
|
||
uploader: { userId?: string; name?: string },
|
||
titles?: string[],
|
||
): Promise<TransitAssignmentFileView[]> {
|
||
await this.assertMine(userId, id);
|
||
return this.uploadFiles(id, files, uploader, titles);
|
||
}
|
||
|
||
async removeMyFile(
|
||
userId: string,
|
||
id: string,
|
||
fileId: string,
|
||
): Promise<void> {
|
||
await this.assertMine(userId, id);
|
||
return this.removeFile(id, fileId);
|
||
}
|
||
|
||
/**
|
||
* The portal's Save / Finish action.
|
||
*
|
||
* Save keeps the assignment open (moving it to IN_PROGRESS so the work reads
|
||
* as under way); Finish closes it, which also locks its documents — see
|
||
* `assertUploadAllowed`.
|
||
*/
|
||
async submitMine(
|
||
userId: string,
|
||
id: string,
|
||
input: { finish: boolean; note?: string },
|
||
): Promise<TransitAssignmentView> {
|
||
const current = await this.findMineById(userId, id);
|
||
if (current.status === TransitAssignmentStatus.Finished) {
|
||
throw new ForbiddenException("This assignment is already finished.");
|
||
}
|
||
await this.update(id, {
|
||
status: input.finish
|
||
? TransitAssignmentStatus.Finished
|
||
: TransitAssignmentStatus.InProgress,
|
||
note: input.note,
|
||
});
|
||
return this.findMineById(userId, id);
|
||
}
|
||
|
||
/**
|
||
* Make `transitAgentId` the officer working `bookingId`, as the clearance
|
||
* desk's "assign transit assignee" step means it.
|
||
*
|
||
* The booking itself only records the officer's NAME, which is all the
|
||
* clearance UI needs; the officer's own portal reads `transit_assignments`.
|
||
* This keeps the two in step, and is deliberately forgiving where `create()`
|
||
* is strict:
|
||
* - assigning the same agent twice is a no-op, not a 409 — the desk may
|
||
* re-save the step without meaning to start over;
|
||
* - a REASSIGNMENT retires the previous officer's row, so a shipment does
|
||
* not sit in the work list of someone who no longer handles it. Finished
|
||
* rows stay, since they are that officer's record of work already done.
|
||
*/
|
||
async ensureAssignment(
|
||
bookingId: string,
|
||
transitAgentId: string,
|
||
assignedByUserId?: string,
|
||
): Promise<void> {
|
||
const existing =
|
||
await this.assignmentsRepository.findByBooking(bookingId);
|
||
|
||
for (const row of existing) {
|
||
if (
|
||
row.transitAgentId !== transitAgentId &&
|
||
row.status !== TransitAssignmentStatus.Finished
|
||
) {
|
||
await this.assignmentsRepository.softDelete(row.id);
|
||
}
|
||
}
|
||
|
||
if (existing.some((row) => row.transitAgentId === transitAgentId)) return;
|
||
|
||
await this.assignmentsRepository.create({
|
||
bookingId,
|
||
transitAgentId,
|
||
status: TransitAssignmentStatus.NotStarted,
|
||
startedAt: null,
|
||
finishedAt: null,
|
||
assignedByUserId: assignedByUserId ?? null,
|
||
note: null,
|
||
});
|
||
}
|
||
|
||
async create(
|
||
dto: CreateTransitAssignmentDto,
|
||
assignedByUserId?: string,
|
||
): Promise<TransitAssignmentView> {
|
||
const booking = await this.bookingsRepository.findOne({
|
||
where: { id: dto.bookingId },
|
||
select: { id: true },
|
||
});
|
||
if (!booking) {
|
||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||
}
|
||
const agent = await this.transitAgentsRepository.findById(
|
||
dto.transitAgentId,
|
||
);
|
||
if (!agent) {
|
||
throw new NotFoundException(
|
||
`Transit agent ${dto.transitAgentId} not found`,
|
||
);
|
||
}
|
||
if (
|
||
await this.assignmentsRepository.existsForPair(
|
||
dto.bookingId,
|
||
dto.transitAgentId,
|
||
)
|
||
) {
|
||
throw new ConflictException(
|
||
`${agent.name} is already assigned to this booking`,
|
||
);
|
||
}
|
||
|
||
const status = dto.status ?? TransitAssignmentStatus.NotStarted;
|
||
const created = await this.assignmentsRepository.create({
|
||
bookingId: dto.bookingId,
|
||
transitAgentId: dto.transitAgentId,
|
||
status,
|
||
// Creating straight into a working state still has to stamp its clock, or
|
||
// the assignment would report no start.
|
||
startedAt:
|
||
status === TransitAssignmentStatus.NotStarted ? null : new Date(),
|
||
finishedAt:
|
||
status === TransitAssignmentStatus.Finished ? new Date() : null,
|
||
assignedByUserId: assignedByUserId ?? null,
|
||
note: dto.note?.trim() || null,
|
||
});
|
||
|
||
return this.findById(created.id);
|
||
}
|
||
|
||
async update(
|
||
id: string,
|
||
dto: UpdateTransitAssignmentDto,
|
||
): Promise<TransitAssignmentView> {
|
||
const current = await this.assignmentsRepository.findOneWithRelations(id);
|
||
if (!current) {
|
||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||
}
|
||
|
||
const patch: Partial<TransitAssignment> = {};
|
||
if (dto.note !== undefined) patch.note = dto.note.trim() || null;
|
||
|
||
if (dto.status && dto.status !== current.status) {
|
||
patch.status = dto.status;
|
||
if (dto.status === TransitAssignmentStatus.InProgress) {
|
||
// Only the FIRST start is recorded — reopening finished work keeps the
|
||
// original start, so the elapsed time still spans the whole job.
|
||
patch.startedAt = current.startedAt ?? new Date();
|
||
patch.finishedAt = null;
|
||
} else if (dto.status === TransitAssignmentStatus.Finished) {
|
||
patch.startedAt = current.startedAt ?? new Date();
|
||
patch.finishedAt = new Date();
|
||
} else {
|
||
// Back to NOT_STARTED — the work is being reset, so both clocks clear
|
||
// rather than leaving a duration for work that no longer happened.
|
||
patch.startedAt = null;
|
||
patch.finishedAt = null;
|
||
}
|
||
}
|
||
|
||
const updated = await this.assignmentsRepository.update(id, patch);
|
||
if (!updated) {
|
||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||
}
|
||
return this.findById(id);
|
||
}
|
||
|
||
async remove(id: string): Promise<void> {
|
||
await this.findById(id);
|
||
await this.assignmentsRepository.softDelete(id);
|
||
}
|
||
|
||
// ── Documents ─────────────────────────────────────────────────────────────
|
||
// Stored in `freight.files` under TRANSIT_ASSIGNMENT_FILE_RESOURCE rather
|
||
// than a table of their own: that one already carries the MinIO object, the
|
||
// upload time, the uploader and the supersede history.
|
||
|
||
/**
|
||
* Whether an assignment may still receive documents.
|
||
*
|
||
* Two gates, both business rules rather than UI conveniences:
|
||
* - the booking must actually be on its way (`DISPATCHED`), since there is
|
||
* nothing to clear before the train leaves;
|
||
* - the assignment must not be FINISHED — filing closes with the work, so a
|
||
* finished record cannot grow new paperwork afterwards.
|
||
*/
|
||
private assertUploadAllowed(assignment: TransitAssignment): void {
|
||
if (assignment.status === TransitAssignmentStatus.Finished) {
|
||
throw new ForbiddenException(
|
||
"This assignment is finished — its documents can no longer be changed.",
|
||
);
|
||
}
|
||
if (assignment.booking?.schedulingStatus !== "DISPATCHED") {
|
||
throw new ForbiddenException(
|
||
"Documents can only be uploaded once the booking has been dispatched.",
|
||
);
|
||
}
|
||
}
|
||
|
||
async listFiles(id: string): Promise<TransitAssignmentFileView[]> {
|
||
const records = await this.filesService.findByResource(
|
||
id,
|
||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||
);
|
||
return records.map((record) => ({
|
||
id: record.id,
|
||
name: record.name,
|
||
title: record.title,
|
||
url: record.url,
|
||
size: record.size,
|
||
mimeType: record.mimeType,
|
||
uploadedAt: record.createdAt.toISOString(),
|
||
updatedAt: record.updatedAt.toISOString(),
|
||
uploadedByUserId: record.uploadedByUserId,
|
||
uploadedByName: record.uploadedByName,
|
||
}));
|
||
}
|
||
|
||
async uploadFiles(
|
||
id: string,
|
||
files: Express.Multer.File[],
|
||
uploader: { userId?: string; name?: string },
|
||
/**
|
||
* A display name per file, positionally matched to `files`. Multer preserves
|
||
* the multipart part order, and the client appends one `titles` entry per
|
||
* file in the same order, so index N names file N. A missing or blank entry
|
||
* falls back to the original filename.
|
||
*/
|
||
titles?: string[],
|
||
): Promise<TransitAssignmentFileView[]> {
|
||
if (!files?.length) {
|
||
throw new BadRequestException("No files were uploaded");
|
||
}
|
||
// Asserts the assignment exists before anything reaches MinIO — an upload
|
||
// keyed to a missing row would be unreachable storage nobody ever lists.
|
||
const assignment =
|
||
await this.assignmentsRepository.findOneWithRelations(id);
|
||
if (!assignment) {
|
||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||
}
|
||
this.assertUploadAllowed(assignment);
|
||
|
||
await Promise.all(
|
||
files.map((file, index) =>
|
||
this.filesService.upload({
|
||
resourceId: id,
|
||
resource: TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||
code: file.fieldname || "document",
|
||
file,
|
||
title: titles?.[index]?.trim() || null,
|
||
uploadedByUserId: uploader.userId ?? null,
|
||
uploadedByName: uploader.name ?? null,
|
||
}),
|
||
),
|
||
);
|
||
|
||
return this.listFiles(id);
|
||
}
|
||
|
||
async removeFile(id: string, fileId: string): Promise<void> {
|
||
const assignment =
|
||
await this.assignmentsRepository.findOneWithRelations(id);
|
||
if (!assignment) {
|
||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||
}
|
||
// Same gate as upload: a finished assignment's paperwork is fixed, and
|
||
// removal is as much a change as adding.
|
||
this.assertUploadAllowed(assignment);
|
||
|
||
const files = await this.filesService.findByResource(
|
||
id,
|
||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||
);
|
||
// Scoped to this assignment's own documents: a bare file id would let one
|
||
// assignment delete another's paperwork.
|
||
if (!files.some((file) => file.id === fileId)) {
|
||
throw new NotFoundException(
|
||
`File ${fileId} not found on this assignment`,
|
||
);
|
||
}
|
||
await this.filesService.remove(fileId);
|
||
}
|
||
}
|