From 0f1bac8080599fe06e256a84fdc4dd52ed34623e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 11:35:36 +0000 Subject: [PATCH 01/28] feat(freight-api): add non-PII auth context to request log line --- .../src/common/request-log-context.spec.ts | 57 ++++++++++++- .../src/logging/request-log.middleware.ts | 84 +++++++++++++++++-- 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts index 403b1a137..e898cb930 100644 --- a/apps/edr-freight-api/src/common/request-log-context.spec.ts +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => { originalUrl: "/api/bookings/1/submit?dry=1", baseUrl: "/api/bookings", route: { path: "/:id/submit" }, - headers: { "user-agent": "jest", "x-request-id": "req-42" }, + headers: { + "user-agent": "jest", + "x-request-id": "req-42", + authorization: "Bearer tok", + "x-client-app": "freight-backoffice", + "current-project-id": "proj-3", + }, ip: "10.0.0.1", query: { dry: "1" }, - user: { id: "u-7" }, + user: { + id: "u-7", + sessionId: "sess-9", + userType: "STAFF", + status: "ACTIVE", + username: "nati", + email: "nati@example.com", + phoneNumber: "0911000000", + name: { en: "Nati" }, + roles: [{ key: "freight_operations" }], + permissions: [{ key: "a" }, { key: "b" }], + employee: { + id: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + position: { + id: "pos-5", + key: "ops_officer", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + positionType: { key: "operations" }, + }, + }, + }, }; const res = { statusCode: 409, @@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => { bookingId: "b-1", booking: { outcome: "REJECTED" }, }); + expect(JSON.parse(lines[0]).auth).toEqual({ + authenticated: true, + hasBearer: true, + clientApp: "freight-backoffice", + userId: "u-7", + sessionId: "sess-9", + userType: "STAFF", + userStatus: "ACTIVE", + roles: ["freight_operations"], + permissionCount: 2, + employeeId: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + positionId: "pos-5", + positionKey: "ops_officer", + positionType: "operations", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + projectId: "proj-3", + }); + // No personal data reaches the line, whatever the token carried. + expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); jest.restoreAllMocks(); }); diff --git a/packages/api-common/src/logging/request-log.middleware.ts b/packages/api-common/src/logging/request-log.middleware.ts index 5d9ead8b0..4144e72c8 100644 --- a/packages/api-common/src/logging/request-log.middleware.ts +++ b/packages/api-common/src/logging/request-log.middleware.ts @@ -20,7 +20,39 @@ interface LoggedRequest { headers: Record; ip?: string; query?: Record; - user?: Record | null; + /** Set by the IAM JwtGuard AFTER this middleware runs — read at emit time. */ + user?: AuthenticatedUser | null; + currentUnitId?: string; +} + +/** + * The subset of `TCurrentUser` (@tria-plc/api-common) the log line reads. + * Everything here is an identifier, a key or a status — the personal fields on + * that type (name, email, username, phoneNumber) are deliberately absent so + * they cannot be picked up by accident. + */ +interface AuthenticatedUser { + id?: string; + sub?: string; + userId?: string; + sessionId?: string; + userType?: string; + status?: string; + roles?: { key?: string }[]; + permissions?: unknown[]; + employee?: { + id?: string; + organizationId?: string; + unitId?: string; + position?: { + id?: string; + key?: string; + employeePositionId?: string; + isDelegate?: boolean; + delegatorId?: string; + positionType?: { key?: string }; + }; + }; } interface LoggedResponse { @@ -35,13 +67,48 @@ const header = (req: LoggedRequest, name: string): string | undefined => { return Array.isArray(value) ? value[0] : value; }; -const userId = (req: LoggedRequest): string | undefined => { +const userId = (req: LoggedRequest): string | undefined => + req.user?.id ?? req.user?.sub ?? req.user?.userId; + +/** + * Who the caller was acting as — WITHOUT any personal data. Ids, role/position + * keys and statuses only: enough to answer "which desk did this", "was it a + * delegate", "which tenant", and to spot an authorization problem, with nothing + * that identifies the human behind the account beyond the opaque user id. + * + * `authenticated: false` with `hasBearer: true` is the signature of a rejected + * token (expired session, bad signature) as opposed to a missing one. + */ +const authContext = (req: LoggedRequest): Record => { const user = req.user; - if (!user) return undefined; - const id = user.id ?? user.sub ?? user.userId; - return typeof id === "string" || typeof id === "number" - ? String(id) - : undefined; + const position = user?.employee?.position; + return { + authenticated: Boolean(user), + hasBearer: header(req, "authorization")?.startsWith("Bearer ") ?? false, + // Which frontend called — /auth/login rejects cross-audience credentials on it. + clientApp: header(req, "x-client-app"), + userId: userId(req), + sessionId: user?.sessionId, + userType: user?.userType, + userStatus: user?.status, + roles: user?.roles?.map((role) => role.key).filter(Boolean), + // Count only: the full grant list is hundreds of keys and would dwarf the line. + permissionCount: user?.permissions?.length, + employeeId: user?.employee?.id, + organizationId: user?.employee?.organizationId, + unitId: user?.employee?.unitId ?? req.currentUnitId, + positionId: position?.id, + positionKey: position?.key, + positionType: position?.positionType?.key, + employeePositionId: position?.employeePositionId, + // Acting on someone else's behalf — the first thing to check when a staff + // action lands under an unexpected desk. + isDelegate: position?.isDelegate, + delegatorId: position?.delegatorId, + // Tenant/scope headers the frontends send alongside the token. + projectId: + header(req, "current-project-id") ?? header(req, "x-current-project-id"), + }; }; /** @@ -101,6 +168,9 @@ export class RequestLogMiddleware implements NestMiddleware { status, durationMs, userId: userId(req), + // Read at emit time on purpose: the guard populates req.user long + // after this middleware handed control on. + auth: authContext(req), ip: req.ip, userAgent: header(req, "user-agent"), query: From 5a856027af0d8a92daa32e832ab1809fa0fb889f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 12:27:15 +0000 Subject: [PATCH 02/28] feat: add email and password --- .../components/profile/ChangeEmailCard.tsx | 172 ++++++++++++++++++ .../components/profile/ChangePasswordCard.tsx | 154 ++++++++++++++++ .../backoffice/src/layout/TopBar.tsx | 2 +- .../backoffice/src/pages/HomePage/Header.tsx | 2 +- .../src/pages/dashboard/MyProfilePage.tsx | 4 + .../components/common/Top.tsx | 2 +- .../src/services/account.service.ts | 50 +++++ .../backoffice/src/services/api.ts | 29 +++ 8 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/account.service.ts diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx new file mode 100644 index 000000000..650616049 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { Mail, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own email. Goes through + * /me/contact/otp + /me/contact rather than the generic (unverified) + * /auth/update-profile route, so the new address is proven before it's + * written — see account.controller.ts on the API side. + */ +export function ChangeEmailCard() { + const { user } = useAuth(); + const sendOtpMutation = useMutation( + api.account.sendContactOtp.mutationOptions(), + ); + const updateContactMutation = useMutation( + api.account.updateContact.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail"); + const [newEmail, setNewEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setStep("enterEmail"); + setNewEmail(""); + setOtp(""); + setFormError(""); + }; + + const sendOtp = () => { + setFormError(""); + if (!newEmail.trim()) { + setFormError("Enter the new email address."); + return; + } + + sendOtpMutation.mutate( + { channel: "email", value: newEmail.trim() }, + { + onSuccess: (result) => { + toast.success(`Verification code sent to ${result.sentTo}`); + setStep("enterOtp"); + }, + }, + ); + }; + + const confirmOtp = () => { + setFormError(""); + if (!otp.trim()) { + setFormError("Enter the verification code."); + return; + } + + updateContactMutation.mutate( + { channel: "email", value: newEmail.trim(), otp: otp.trim() }, + { + onSuccess: () => { + toast.success("Email updated."); + closeDialog(); + // Refetches the session so the new email shows everywhere — simplest + // way to refresh the cached user without a dedicated context method. + window.location.reload(); + }, + }, + ); + }; + + return ( + + + + + Email + + + {user?.email ? `Current email: ${user.email}` : "Change your account email."} + + + + + + + !next && closeDialog()}> + + + Change email + + {step === "enterEmail" + ? "We'll send a verification code to the new address." + : `Enter the code sent to ${newEmail}.`} + + + + {step === "enterEmail" ? ( +
+ + setNewEmail(e.target.value)} + /> + {formError &&

{formError}

} +
+ ) : ( +
+ + setOtp(e.target.value)} + /> + {formError &&

{formError}

} +
+ )} + + + + {step === "enterEmail" ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx new file mode 100644 index 000000000..fb5f9086e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { KeyRound, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own password. The account + * is logged out on success — the old token was issued under the old + * password, and this forces a clean re-login rather than trusting the + * server to keep the existing session valid. + */ +export function ChangePasswordCard() { + const { logout } = useAuth(); + const changePasswordMutation = useMutation( + api.account.changePassword.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setOldPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setFormError(""); + }; + + const submit = () => { + setFormError(""); + + if (!oldPassword || !newPassword || !confirmPassword) { + setFormError("All fields are required."); + return; + } + if (newPassword.length < 8) { + setFormError("New password must be at least 8 characters."); + return; + } + if (newPassword === oldPassword) { + setFormError("New password must be different from the current one."); + return; + } + if (newPassword !== confirmPassword) { + setFormError("New password and confirmation do not match."); + return; + } + + changePasswordMutation.mutate( + { oldPassword, newPassword, confirmPassword }, + { + onSuccess: () => { + toast.success("Password changed. Please sign in again."); + closeDialog(); + setTimeout(logout, 1200); + }, + }, + ); + }; + + return ( + + + + + Password + + Change the password for your account. + + + + + + !next && closeDialog()}> + + + Change password + + You'll be signed out and asked to log in again once it's changed. + + +
+
+ + setOldPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+ {formError &&

{formError}

} +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx index 708a5a3ca..c7bd1b197 100644 --- a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx +++ b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx @@ -319,7 +319,7 @@ export const TopBar = () => { {t("header.viewProfile")} navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5"> {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx index ed569a83d..46ddcfe7d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx @@ -502,7 +502,7 @@ const Header = () => { navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx index baf548d92..37f91909e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx @@ -1,8 +1,12 @@ import { MySignatureCard } from "@/components/profile/MySignatureCard"; +import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard"; +import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard"; export default function MyProfilePage() { return (
+ +
diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index ff7678856..4f58ae6b7 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -551,7 +551,7 @@ const Top: React.FC = ({ navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/services/account.service.ts b/apps/edr-freight-web/backoffice/src/services/account.service.ts new file mode 100644 index 000000000..40511bf79 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/account.service.ts @@ -0,0 +1,50 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; + +export type ContactChannel = "email" | "phone"; + +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + +export interface SendContactOtpPayload { + channel: ContactChannel; + /** The NEW email/phone to verify — the OTP is sent here, not to the current one. */ + value: string; +} + +export interface UpdateContactPayload extends SendContactOtpPayload { + otp: string; +} + +export const accountService = { + /** PATCH /auth/change-password — generic IAM route, works for any user type. */ + changePassword: async (payload: ChangePasswordPayload): Promise => { + const response = await client.patch("/auth/change-password", payload); + unwrap(response.data); + }, + + /** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */ + sendContactOtp: async ( + payload: SendContactOtpPayload, + ): Promise<{ sentTo: string }> => { + const response = await client.post<{ sentTo: string }>( + "/me/contact/otp", + payload, + ); + return unwrap(response.data); + }, + + /** PATCH /me/contact — verifies the OTP and writes the new email/phone. */ + updateContact: async ( + payload: UpdateContactPayload, + ): Promise<{ success: true; value: string }> => { + const response = await client.patch<{ success: true; value: string }>( + "/me/contact", + payload, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6c9c69788..2a1963f8d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -136,6 +136,12 @@ import type { WarehouseZone, } from "@/types/warehouse"; import { endpoint } from "@/utils/endpoint"; +import { + accountService, + type ChangePasswordPayload, + type SendContactOtpPayload, + type UpdateContactPayload, +} from "./account.service"; import { BookingListFilter, bookingsService, @@ -2182,6 +2188,29 @@ export const api = { ), }, + account: { + changePassword: endpoint( + "me", + "change-password", + (payload) => accountService.changePassword(payload), + ), + + sendContactOtp: endpoint( + "me", + "send-contact-otp", + (payload) => accountService.sendContactOtp(payload), + ), + + updateContact: endpoint< + UpdateContactPayload, + { success: true; value: string } + >( + "me", + "update-contact", + (payload) => accountService.updateContact(payload), + ), + }, + signatures: { mySignature: endpoint( "me", From 5c2afe3454a3fc2ff30eaa9174415e8c2b8cb8dd Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 07:30:28 +0000 Subject: [PATCH 03/28] fix(notifications): guard emitNew/emitUnreadCount against no WS server @WebSocketServer() only wires `server` once the WS adapter attaches to a running HTTP listener. It never does under NestFactory.createApplicationContext (scripts, one-off jobs) -- confirmed live tonight, when the EIMS self-test registration's failure alert crashed with "Cannot read properties of null (reading 'to')" instead of just logging that no socket was available. The registration result itself was unaffected (postSigned already resolved, the EimsApiException was correctly re-thrown), but the crash happened inside an await'd call in the same chain -- in a context where it wasn't caught, it would have masked whatever result the caller actually cared about. Both push methods now skip and log at debug level when no server is attached, since the notification row is already persisted by the time they're called -- a missing socket just means "no live push this time", not a reason to lose the caller's own outcome. `server` drops its `!` non-null assertion to match. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications.gateway.spec.ts | 29 +++++++++++++++++++ .../notifications.gateway.ts | 18 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts new file mode 100644 index 000000000..bc79d8acb --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts @@ -0,0 +1,29 @@ +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +const gateway = () => new NotificationsGateway({} as WsAuthService); + +describe("NotificationsGateway", () => { + it("skips emitNew rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow(); + }); + + it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow(); + }); + + it("pushes to the user's room once a server is attached", () => { + const g = gateway(); + const emit = jest.fn(); + const to = jest.fn().mockReturnValue({ emit }); + (g as unknown as { server: { to: typeof to } }).server = { to }; + + g.emitNew("user-1", { id: "n-1" } as never, 3); + + expect(to).toHaveBeenCalledWith("user:user-1"); + expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" }); + expect(emit).toHaveBeenCalledWith("notification:unread-count", 3); + }); +}); diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index 0c4704170..4658dfac0 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service"; export class NotificationsGateway implements OnGatewayConnection { private readonly logger = new Logger(NotificationsGateway.name); + // Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP + // listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`. @WebSocketServer() - private readonly server!: Server; + private readonly server?: Server; constructor(private readonly wsAuth: WsAuthService) {} @@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push a freshly-created notification + the new unread count to a user. */ emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + if (!this.server) return this.skip("emitNew"); const room = this.server.to(this.room(userId)); room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); @@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push only an updated unread count (e.g. after a read on another tab). */ emitUnreadCount(userId: string, unreadCount: number): void { + if (!this.server) return this.skip("emitUnreadCount"); this.server .to(this.room(userId)) .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); } + /** + * `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP + * listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not + * for the brief window before `app.listen()` completes in a real boot either. The notification row + * is already persisted by this point (the caller writes it before pushing), so a missing socket + * server just means "no live push this time" — skip it rather than throw and lose the caller's + * own result (e.g. an EIMS registration outcome that already succeeded or failed for real). + */ + private skip(method: string): void { + this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`); + } + private room(userId: string): string { return `user:${userId}`; } From 8d53dc17ce997b52c7d5b8d703105c2290f660a0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 08:08:03 +0000 Subject: [PATCH 04/28] docs(eims): confirm signing algorithm and cert format against MoR's guide Cross-checked our RSA-SHA512 signing and raw-bytes certificate encoding against MoR's own "Guide to Generating and Using Certificate for E-Invoicing" (supplied today). Both were previously documented as our best inference from the Postman collection; the guide names SHA512withRSA explicitly (PKCS#1v1.5, matching Node's createSign default) and its own worked example certificate is byte-for-byte the same Subject:/Issuer: + 3-cert PEM chain text-file format ours is. No behavior change -- the comment now says confirmed, not assumed. Field order, section names, date format and the {request, signature, certificate} envelope in the guide's worked example all match our mapper exactly (order doesn't matter per the guide, but it's a further concordance check). The one guide/live disagreement -- its example shows "NatureOfSupplies": "Goods" where our actual 400 SCHEMA ERROR demanded lowercase "goods"/"service" -- is left as-is: the live, machine-generated schema error outranks a static doc example that may predate a schema change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/eims/eims-signer.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts index babec6b44..b91f306ab 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types"; * * 1. compact `JSON.stringify` of the **inner** request object only, * 2. those exact UTF-8 bytes, - * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding), + * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not + * assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names + * `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named + * `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default. * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), - * 5. base64 of the certificate file's exact bytes. + * 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own + * worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain + * text-file format ours is, base64'd with no re-encoding. * * The outer `{request, signature, certificate}` envelope is never itself signed, and the request * object is never mutated after serialization. From 2d4da8110b2fe0bbbbba86ea41892e6b65861638 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 14:08:28 +0000 Subject: [PATCH 05/28] eims integration master test complete --- apps/edr-freight-api/.env.example | 13 +- .../edr-freight-api/src/config/eims.config.ts | 28 ++ .../migrations/3450000000000-WidenEimsIrn.ts | 33 +++ .../3460000000000-AddEimsSignedQr.ts | 20 ++ .../3470000000000-EimsCancellation.ts | 26 ++ .../3480000000000-WidenPreviousIrn.ts | 24 ++ .../migrations/3490000000000-EimsReceipts.ts | 34 +++ .../modules/billing/billing.service.spec.ts | 108 ++++++++ .../src/modules/billing/billing.service.ts | 77 ++++-- .../invoice-document.service.spec.ts | 46 ++++ .../documents/invoice-document.service.ts | 34 ++- .../billing/eims-invoice.mapper.spec.ts | 22 +- .../modules/billing/eims-invoice.mapper.ts | 16 +- .../billing/entities/invoice.entity.ts | 36 ++- .../eims/dto/cancel-eims-registration.dto.ts | 19 ++ .../eims/dto/register-sales-receipt.dto.ts | 88 ++++++ .../dto/register-withholding-receipt.dto.ts | 40 +++ .../eims/dto/resolve-eims-registration.dto.ts | 4 +- .../eims/eims-cancellation.service.spec.ts | 153 +++++++++++ .../modules/eims/eims-cancellation.service.ts | 117 ++++++++ .../modules/eims/eims-invoice-context.spec.ts | 118 ++++++++ .../src/modules/eims/eims-invoice-context.ts | 66 ++++- .../eims-invoice-registration.service.spec.ts | 109 +++++++- .../eims/eims-invoice-registration.service.ts | 128 +++++++-- .../modules/eims/eims-invoice-view.util.ts | 27 ++ .../modules/eims/eims-invoice.controller.ts | 55 +++- .../modules/eims/eims-receipt.service.spec.ts | 231 ++++++++++++++++ .../src/modules/eims/eims-receipt.service.ts | 259 ++++++++++++++++++ .../src/modules/eims/eims-receipt.types.ts | 97 +++++++ .../modules/eims/eims-registration.types.ts | 30 ++ .../src/modules/eims/eims-test-fixtures.ts | 6 + .../src/modules/eims/eims.module.ts | 17 +- .../eims/entities/eims-receipt.entity.ts | 68 +++++ .../eims/entities/eims-system-state.entity.ts | 7 +- .../services/train-scheduling.service.ts | 71 +++++ .../src/seed/freight-permissions.registry.ts | 24 +- .../backoffice/src/types/eims.ts | 25 +- pnpm-lock.yaml | 167 +++++------ 38 files changed, 2270 insertions(+), 173 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts create mode 100644 apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts create mode 100644 apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts create mode 100644 apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts create mode 100644 apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/cancel-eims-registration.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/register-withholding-receipt.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-view.util.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts create mode 100644 apps/edr-freight-api/src/modules/eims/entities/eims-receipt.entity.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 2c636eee4..5a145a0db 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -# Required, and deliberately unset: the choice is a tax position, not a default. +# Required, and deliberately unset here: the choice is a tax position, not a default. # MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH -# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env. EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a +# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material. +# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above. +# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types. +EIMS_TAX_CODE_BY_CHARGE_TYPE= +EIMS_TAX_RATE_BY_CHARGE_TYPE= +# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively. +EIMS_EXCISE_BY_CHARGE_TYPE= +EIMS_DISCOUNT_BY_CHARGE_TYPE= # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B # Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index a8a929ca5..cf77072e0 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -89,8 +89,30 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to + * `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax + * treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above + * cannot express that. Values are raw strings; the context builder parses/validates them. + */ + taxCodeByChargeType: Record; + taxRateByChargeType: Record; + /** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge + * types not listed fall back to `exciseTaxValue` / 0 respectively. */ + exciseByChargeType: Record; + discountByChargeType: Record; cashierName: string | null; salesPersonName: string | null; + /** + * TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every + * buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be + * one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead + * of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits. + * Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists. + */ + buyerIdType: string | null; + buyerIdNumber: string | null; } const REQUIRED_VARS = [ @@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => { buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), + taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), + exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), + discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null, + buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null, }, }; diff --git a/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts new file mode 100644 index 000000000..ec3faf9eb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed + * live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as + * `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-` + * prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already + * accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING` + * and the system-wide reservation stuck in-flight with no block/alert (see + * `EimsInvoiceRegistrationService` for the accompanying code fix). + * + * Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or + * length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's + * real production shape — guessing another fixed bound risks the exact same failure again. + */ +export class WidenEimsIrn3450000000000 implements MigrationInterface { + name = "WidenEimsIrn3450000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE text + `); + } + + /** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts new file mode 100644 index 000000000..dedf2d05f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */ +export class AddEimsSignedQr3460000000000 implements MigrationInterface { + name = "AddEimsSignedQr3460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_signed_qr text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_signed_qr + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts new file mode 100644 index 000000000..7092e5518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */ +export class EimsCancellation3470000000000 implements MigrationInterface { + name = "EimsCancellation3470000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8), + ADD COLUMN IF NOT EXISTS eims_cancellation_remark text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_cancelled_at, + DROP COLUMN IF EXISTS eims_cancellation_date, + DROP COLUMN IF EXISTS eims_cancellation_reason_code, + DROP COLUMN IF EXISTS eims_cancellation_remark + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts new file mode 100644 index 000000000..15a343f06 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too + * (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same + * varchar(64) on the next successful registration. + */ +export class WidenPreviousIrn3480000000000 implements MigrationInterface { + name = "WidenPreviousIrn3480000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts new file mode 100644 index 000000000..3d5ab02e9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `freight.eims_receipts` — see `EimsReceipt` entity. */ +export class EimsReceipts3490000000000 implements MigrationInterface { + name = "EimsReceipts3490000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_receipts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id uuid NOT NULL REFERENCES freight.invoices(id), + kind varchar(16) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + receipt_number varchar(64) NOT NULL, + rrn text, + qr text, + ack_status varchar(8), + submitted_at timestamptz, + last_error jsonb, + request jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 576c7b166..d9eef5a43 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); }); @@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, + {} as never, // config ); return { service, defaultManager, txManager, transaction }; }; @@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, manager }; }; @@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => }); }); }); + +describe("BillingService.document", () => { + const invoiceRow = (over: Record = {}) => ({ + id: "inv-1", + invoiceNumber: "INV-20260812-00001", + source: "booking", + sourceId: "booking-1", + status: Freight.InvoiceStatus.Pending, + type: "freight", + currency: "ETB", + subtotalAmount: 100, + taxAmount: 0, + totalAmount: 100, + paidAmount: 0, + balanceAmount: 100, + issuedAt: new Date(2026, 7, 12), + dueAt: new Date(2026, 7, 19), + eimsIrn: null, + eimsSignedQr: null, + company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" }, + ...over, + }); + + const build = (invoice: Record) => { + const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const service = new BillingService( + {} as never, + { findById: jest.fn().mockResolvedValue(invoice) } as never, + { findAll: jest.fn().mockResolvedValue([]) } as never, + {} as never, + {} as never, + {} as never, + { render } as never, + {} as never, + { + get: (key: string) => + key === "eims" + ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } + : undefined, + } as never, // config + ); + return { service, render }; + }; + + it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined(); + expect(model.qrImageUrl).toBeNull(); + }); + + it("shows the buyer's name, TIN and VAT number on every invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" }); + expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" }); + expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" }); + }); + + it("omits the VAT row when the buyer company has none", async () => { + const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } })); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined(); + }); + + it("shows EDR's own seller TIN and VAT number from EIMS config", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" }); + expect(model.summary).toContainEqual({ + label: "Seller VAT No.", + value: "43256663343256663322", + }); + }); + + it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => { + const { service, render } = build( + invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); + expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 6c7e8455d..caa25fbaa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,5 @@ import { Freight, PaymentReferenceType } from "@edr/types"; +import { ConfigService } from "@nestjs/config"; import { BadRequestException, forwardRef, @@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; @@ -161,6 +163,7 @@ export class BillingService { private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, + private readonly config: ConfigService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -384,7 +387,7 @@ export class BillingService { async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "INVOICE"), + await this.toDocumentModel(invoice, "INVOICE"), ); } @@ -397,15 +400,24 @@ export class BillingService { ); } return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "RECEIPT"), + await this.toDocumentModel(invoice, "RECEIPT"), ); } + /** + * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the + * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), + * not a payload we encode ourselves. Wrapped in a data URL, nothing more. + */ + private renderEimsQr(signedQr: string): string { + return `data:image/png;base64,${signedQr}`; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ - private toDocumentModel( + private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, kind: "INVOICE" | "RECEIPT", - ): InvoiceDocumentModel { + ): Promise { const title = invoice.source ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) : "EDR"; @@ -423,6 +435,43 @@ export class BillingService { totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + const summary: InvoiceDocumentModel["summary"] = [ + // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was + // filed against, not just the seller. VatNumber shown only when the company has one. + { label: "Buyer", value: invoice.company?.name ?? null }, + { label: "Buyer TIN", value: invoice.company?.tin ?? null }, + ...(invoice.company?.vatNumber + ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }] + : []), + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ]; + + // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this + // codebase). Shown only when actually configured, same as the buyer VAT row. + const eimsCfg = this.config.get("eims"); + if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); + if (eimsCfg?.invoice?.sellerVatNumber) { + summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + } + + // MoR EIMS reference — only once actually registered, never a placeholder row. + if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + return { kind, title, @@ -430,24 +479,7 @@ export class BillingService { issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, - summary: [ - { label: "Status", value: invoice.status }, - { label: "Type", value: invoice.type }, - { label: "Reference", value: invoice.sourceId }, - { label: "Currency", value: invoice.currency }, - { - label: "Issued", - value: invoice.issuedAt - ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") - : null, - }, - { - label: "Due", - value: invoice.dueAt - ? new Date(invoice.dueAt).toLocaleDateString("en-GB") - : null, - }, - ], + summary, categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ description: l.description ?? l.chargeType, @@ -458,6 +490,7 @@ export class BillingService { currency: l.currency, })), totals, + qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, }; } diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts new file mode 100644 index 000000000..00e590e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -0,0 +1,46 @@ +import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; + +const model = (over: Partial = {}): InvoiceDocumentModel => ({ + kind: "INVOICE", + title: "Freight", + documentNumber: "INV-20260812-00001", + issuedAt: new Date(2026, 7, 12), + status: "PENDING", + currency: "ETB", + summary: [{ label: "Status", value: "PENDING" }], + lines: [], + totals: [{ label: "Total", amount: 100, grand: true }], + ...over, +}); + +describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { + const service = new InvoiceDocumentService({} as never, {} as never); + + it("renders no QR block when qrImageUrl is unset", () => { + const html = service.buildHtml(model()); + expect(html).not.toContain('class="qr"'); + }); + + it("renders the QR image when qrImageUrl is set", () => { + const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + }); + + it("still shows the IRN text row via the ordinary summary grid", () => { + const html = service.buildHtml( + model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }), + ); + expect(html).toContain("EIMS IRN"); + expect(html).toContain("IRN-123"); + }); + + it("widens the summary's right margin only when a QR is present, to clear the QR block", () => { + // "summary-with-qr" also appears in the always-present +

${esc(def.title)}

+

${esc(def.description)}

+ ${kpiHtml} + ${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts deleted file mode 100644 index 9e4a6f617..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { DataSource } from 'typeorm'; - -export interface ReportFilters { - /** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */ - dateFrom: string | null; - /** ISO timestamp, exclusive upper bound. null = no upper bound. */ - dateTo: string | null; - granularity: 'day' | 'week' | 'month'; - companyIds: string[] | null; - routeIds: string[] | null; - yardIds: string[] | null; - cargoTypeIds: string[] | null; - statuses: string[] | null; - /** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */ - directions: string[] | null; - freightType: string | null; -} - -export interface ReportKpi { - label: string; - value: number; - unit?: string; -} - -export interface ReportResult { - kpis: ReportKpi[]; - rows: Record[]; -} - -type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; - -// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and -// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -// adjusted_total_amount silently overrides total_amount when set. -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -// GENERAL contract_kind rows are umbrella contracts, not shipments; counting -// them double-counts every child booking (same guard as overview.repository). -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'"; - -const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v)); -const sum = (rows: Record[], col: string): number => - rows.reduce((acc, r) => acc + num(r[col]), 0); - -/** - * Shared WHERE for booking-based reports (alias `b`). - * Params occupy $1..$8 in this fixed order; report SQL continues at $9. - */ -function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } { - return { - where: ` - b.deleted_at IS NULL - AND ${NOT_UMBRELLA} - AND ($1::timestamptz IS NULL OR b.created_at >= $1) - AND ($2::timestamptz IS NULL OR b.created_at < $2) - AND ($3::uuid[] IS NULL OR b.company_id = ANY($3)) - AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4)) - AND ($5::text[] IS NULL OR b.trade_direction = ANY($5)) - AND ($6::text IS NULL OR b.freight_type = $6) - AND (CASE WHEN $7::text[] IS NULL - THEN b.status NOT IN (${DEAD_STATUSES}) - ELSE b.status = ANY($7) END) - AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`, - params: [ - f.dateFrom, - f.dateTo, - f.companyIds, - f.cargoTypeIds, - f.directions, - f.freightType, - f.statuses, - f.yardIds, - ], - }; -} - -/** - * Direction scope for rows that reference a booking through a varchar id - * column (invoices.source_id, payments.ref_id). Rows not pointing at a - * booking stay visible — they carry no direction to scope by. - * (Positional-param port of trade-scope.util's bookingRefScopeSql.) - */ -const refDirScope = (refColumn: string, param: string): string => ` - (${param}::text[] IS NULL OR NOT EXISTS ( - SELECT 1 FROM freight.bookings sb - WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`; - -const bookingsTrend: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - WHERE ${where} - GROUP BY 1 ORDER BY 1`, - [...params, f.granularity], - ); - return { - kpis: [ - { label: 'Bookings', value: sum(rows, 'bookings') }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' }, - ], - rows, - }; -}; - -const revenueByCustomer: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT c.name AS customer, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - JOIN freight.companies c ON c.id = b.company_id - WHERE ${where} - GROUP BY c.name ORDER BY revenue DESC LIMIT 100`, - params, - ); - const total = sum(rows, 'revenue'); - return { - kpis: [ - { label: 'Customers', value: rows.length }, - { label: 'Revenue', value: total, unit: 'ETB' }, - { - label: 'Top customer share', - value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0, - unit: '%', - }, - ], - rows, - }; -}; - -const revenueByLane: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - JOIN freight.yards o ON o.id = b.origin_yard_id - JOIN freight.yards d ON d.id = b.destination_yard_id - WHERE ${where} - GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`, - params, - ); - return { - kpis: [ - { label: 'Lanes', value: rows.length }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' }, - ], - rows, - }; -}; - -const contractUtilization: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind, - to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, - to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, - cap.committed::float8 AS committed, - booked.tons::float8 AS booked_tons, - booked.cnt AS bookings, - CASE WHEN cap.committed > 0 - THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct - FROM freight.contracts ct - LEFT JOIN freight.companies c ON c.id = ct.company_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed - FROM freight.contract_cargo_scope s - WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt - FROM freight.bookings b - WHERE b.contract_id = ct.id AND b.deleted_at IS NULL - AND b.status NOT IN (${DEAD_STATUSES})) booked ON true - WHERE ct.deleted_at IS NULL - AND ct.status NOT IN ('DRAFT') - AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity') - AND (ct.contract_valid_until IS NULL - OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity')) - AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) - AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) - AND ($5::text[] IS NULL OR ct.status = ANY($5)) - ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], - ); - const capped = rows.filter((r: Record) => num(r.committed) > 0); - return { - kpis: [ - { label: 'Contracts', value: rows.length }, - { - label: 'Avg utilization', - value: capped.length - ? Math.round(sum(capped, 'utilization_pct') / capped.length) - : 0, - unit: '%', - }, - { label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' }, - ], - rows, - }; -}; - -// ponytail: 60-min departure grace is a constant; make it a query param if ops -// ever wants a configurable threshold. -const trainOnTime: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, - COUNT(*)::int AS trips, - COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed, - ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60) - FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min, - ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60) - FILTER (WHERE ts.actual_arrival_at IS NOT NULL - AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min, - ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at - <= ts.scheduled_departure_date + interval '60 minutes') - / NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - WHERE ts.deleted_at IS NULL - AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - GROUP BY 1, 2 ORDER BY trips DESC`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - const departed = sum(rows, 'departed'); - const weighted = rows.reduce( - (acc: number, r: Record) => - acc + (num(r.on_time_pct) * num(r.departed)) / 100, - 0, - ); - return { - kpis: [ - { label: 'Trips', value: sum(rows, 'trips') }, - { - label: 'On-time departures', - value: departed > 0 ? Math.round((weighted / departed) * 100) : 0, - unit: '%', - }, - { - label: 'Avg departure delay', - value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0, - unit: 'min', - }, - ], - rows, - }; -}; - -const scheduleFillRate: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ts.train_number, ts.reference, - to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure, - o.label AS origin, d.label AS destination, ts.direction, ts.status, - ts.max_wagons, tset.wagon_count, - ROUND(w.cap_tons)::float8 AS capacity_tons, - ROUND(w.booked_tons)::float8 AS booked_tons, - CASE WHEN w.cap_tons > 0 - THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons, - COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons - FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true - WHERE ts.deleted_at IS NULL - AND ts.status <> 'CANCELLED' - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - ORDER BY ts.scheduled_departure_date DESC LIMIT 200`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - const withCap = rows.filter((r: Record) => num(r.capacity_tons) > 0); - const capTons = sum(withCap, 'capacity_tons'); - return { - kpis: [ - { label: 'Schedules', value: rows.length }, - { - label: 'Avg fill rate', - value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0, - unit: '%', - }, - { label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' }, - ], - rows, - }; -}; - -const tripsPerRoute: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, ts.direction, - COUNT(*)::int AS trips, - ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled, - ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons - FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true - WHERE ts.deleted_at IS NULL - AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - GROUP BY 1, 2, 3 ORDER BY trips DESC`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - return { - kpis: [ - { label: 'Trips', value: sum(rows, 'trips') }, - { label: 'Routes served', value: rows.length }, - { label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' }, - ], - rows, - }; -}; - -const invoicedVsCollected: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period, - COUNT(*)::int AS invoices, - ROUND(SUM(i.total_amount))::float8 AS invoiced, - ROUND(SUM(i.paid_amount))::float8 AS collected, - ROUND(SUM(i.balance_amount))::float8 AS outstanding - FROM freight.invoices i - WHERE i.deleted_at IS NULL - AND i.status NOT IN ('DRAFT', 'CANCELLED') - AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1) - AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2) - AND ($3::uuid[] IS NULL OR i.company_id = ANY($3)) - AND ${refDirScope('i.source_id', '$4')} - GROUP BY 1 ORDER BY 1`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity], - ); - const invoiced = sum(rows, 'invoiced'); - const collected = sum(rows, 'collected'); - return { - kpis: [ - { label: 'Invoiced', value: invoiced, unit: 'ETB' }, - { label: 'Collected', value: collected, unit: 'ETB' }, - { - label: 'Collection rate', - value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0, - unit: '%', - }, - { label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' }, - ], - rows, - }; -}; - -// Aging is an as-of snapshot: dateTo is the as-of moment (default now), -// dateFrom is ignored. -const agingReceivables: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT c.name AS customer, - COUNT(*)::int AS invoices, - ROUND(SUM(i.balance_amount))::float8 AS outstanding, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days' - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days' - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus - FROM freight.invoices i - JOIN freight.companies c ON c.id = i.company_id - WHERE i.deleted_at IS NULL - AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE') - AND i.balance_amount > 0 - AND ($1::timestamptz IS NULL OR i.created_at < $1) - AND ($2::uuid[] IS NULL OR i.company_id = ANY($2)) - AND ${refDirScope('i.source_id', '$3')} - GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`, - [f.dateTo, f.companyIds, f.directions], - ); - const outstanding = sum(rows, 'outstanding'); - return { - kpis: [ - { label: 'Outstanding', value: outstanding, unit: 'ETB' }, - { label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' }, - { label: 'Customers with balance', value: rows.length }, - ], - rows, - }; -}; - -const revenueByPaymentMethod: ReportQuery = async (ds, f) => { - // payments.status values are lowercase-hyphenated ('success'), unlike every - // other status enum in the schema. No deleted_at on this table. - const rows = await ds.query( - `SELECT p.method::text AS method, - COUNT(*)::int AS payments, - ROUND(SUM(p.amount))::float8 AS amount - FROM freight.payments p - WHERE p.status = 'success' - AND ($1::timestamptz IS NULL OR p.created_at >= $1) - AND ($2::timestamptz IS NULL OR p.created_at < $2) - AND ${refDirScope('p.ref_id', '$3')} - GROUP BY 1 ORDER BY amount DESC`, - [f.dateFrom, f.dateTo, f.directions], - ); - const total = sum(rows, 'amount'); - return { - kpis: [ - { label: 'Collected', value: total, unit: 'ETB' }, - { label: 'Payments', value: sum(rows, 'payments') }, - { - label: 'Top method share', - value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0, - unit: '%', - }, - ], - rows, - }; -}; - -// --------------------------------------------------------------------------- -// Record-level list exports. Same engine, raw rows instead of aggregates. -// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table -// ever outgrows that. -const LIST_LIMIT = 5000; - -const bookingsList: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT b.reference, - to_char(b.created_at, 'YYYY-MM-DD') AS created, - c.name AS customer, b.status, b.freight_type, - b.trade_direction AS direction, - o.label AS origin, d.label AS destination, - COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo, - ROUND(${TONS})::float8 AS tons, - ROUND(${REVENUE})::float8 AS amount, - b.payment_status, b.scheduling_status - FROM freight.bookings b - JOIN freight.companies c ON c.id = b.company_id - JOIN freight.yards o ON o.id = b.origin_yard_id - JOIN freight.yards d ON d.id = b.destination_yard_id - LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id - WHERE ${where} - ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`, - params, - ); - return { - kpis: [ - { label: 'Bookings', value: rows.length }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' }, - ], - rows, - }; -}; - -const contractsList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind, - ct.status, ct.trade_direction AS direction, ct.freight_type, - to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, - to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, - to_char(ct.created_at, 'YYYY-MM-DD') AS created - FROM freight.contracts ct - LEFT JOIN freight.companies c ON c.id = ct.company_id - WHERE ct.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR ct.created_at >= $1) - AND ($2::timestamptz IS NULL OR ct.created_at < $2) - AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) - AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) - AND ($5::text[] IS NULL OR ct.status = ANY($5)) - ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], - ); - const active = rows.filter((r: Record) => - ['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)), - ).length; - return { - kpis: [ - { label: 'Contracts', value: rows.length }, - { label: 'Active', value: active }, - ], - rows, - }; -}; - -const schedulesList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ts.train_number, ts.reference, ts.direction, ts.status, - o.label AS origin, d.label AS destination, - to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure, - to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure, - to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival, - to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival, - ts.max_wagons, tset.wagon_count - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id - WHERE ts.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::text[] IS NULL OR ts.direction = ANY($3)) - AND ($4::text[] IS NULL OR ts.status = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds], - ); - const count = (s: string) => - rows.filter((r: Record) => r.status === s).length; - return { - kpis: [ - { label: 'Schedules', value: rows.length }, - { label: 'Dispatched', value: count('DISPATCHED') }, - { label: 'Arrived', value: count('ARRIVED') }, - ], - rows, - }; -}; - -const fleetWagons: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT w.wagon_number, wt.name AS type, - wt.capacity_tons::float8 AS capacity_tons, - w.status, y.label AS current_yard - FROM freight.wagons w - JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id - LEFT JOIN freight.yards y ON y.id = w.current_yard_id - WHERE w.deleted_at IS NULL - AND ($1::text[] IS NULL OR w.status = ANY($1)) - AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2)) - ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`, - [f.statuses, f.yardIds], - ); - const count = (s: string) => - rows.filter((r: Record) => r.status === s).length; - return { - kpis: [ - { label: 'Wagons', value: rows.length }, - { label: 'Available', value: count('AVAILABLE') }, - { label: 'Assigned', value: count('ASSIGNED') }, - { label: 'Maintenance', value: count('MAINTENANCE') }, - ], - rows, - }; -}; - -const fleetLocomotives: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT l.code, l.name, l.locomotive_type, - l.max_pull_weight_tons::float8 AS max_pull_tons, - l.status, y.label AS current_yard - FROM freight.locomotives l - LEFT JOIN freight.yards y ON y.id = l.current_yard_id - WHERE l.deleted_at IS NULL - AND ($1::text[] IS NULL OR l.status = ANY($1)) - AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2)) - ORDER BY l.code LIMIT ${LIST_LIMIT}`, - [f.statuses, f.yardIds], - ); - const available = rows.filter( - (r: Record) => r.status === 'AVAILABLE', - ).length; - return { - kpis: [ - { label: 'Locomotives', value: rows.length }, - { label: 'Available', value: available }, - ], - rows, - }; -}; - -const customersList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT c.name, c.type, c.kind, c.status, c.tin, - to_char(c.approved_at, 'YYYY-MM-DD') AS approved, - to_char(c.created_at, 'YYYY-MM-DD') AS created - FROM freight.companies c - WHERE c.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR c.created_at >= $1) - AND ($2::timestamptz IS NULL OR c.created_at < $2) - AND ($3::text[] IS NULL OR c.status = ANY($3)) - ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.statuses], - ); - const active = rows.filter( - (r: Record) => r.status === 'active', - ).length; - return { - kpis: [ - { label: 'Customers', value: rows.length }, - { label: 'Active', value: active }, - ], - rows, - }; -}; - -const paymentsList: ReportQuery = async (ds, f) => { - // No deleted_at on freight.payments; statuses are lowercase-hyphenated. - const rows = await ds.query( - `SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created, - p.method::text AS method, p.status::text AS status, - p.currency::text AS currency, - ROUND(p.amount)::float8 AS amount, - p.transaction_id, p.merchant_order_id, - to_char(p.paid_at, 'YYYY-MM-DD') AS paid - FROM freight.payments p - WHERE ($1::timestamptz IS NULL OR p.created_at >= $1) - AND ($2::timestamptz IS NULL OR p.created_at < $2) - AND ($3::text[] IS NULL OR p.status::text = ANY($3)) - AND ${refDirScope('p.ref_id', '$4')} - ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.statuses, f.directions], - ); - const success = rows.filter( - (r: Record) => r.status === 'success', - ); - return { - kpis: [ - { label: 'Payments', value: rows.length }, - { label: 'Successful', value: success.length }, - { label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' }, - ], - rows, - }; -}; - -export const REPORT_QUERIES: Record = { - 'bookings-list': bookingsList, - 'contracts-list': contractsList, - 'schedules-list': schedulesList, - 'fleet-wagons': fleetWagons, - 'fleet-locomotives': fleetLocomotives, - 'customers-list': customersList, - 'payments-list': paymentsList, - 'bookings-trend': bookingsTrend, - 'revenue-by-customer': revenueByCustomer, - 'revenue-by-lane': revenueByLane, - 'contract-utilization': contractUtilization, - 'train-on-time': trainOnTime, - 'schedule-fill-rate': scheduleFillRate, - 'trips-per-route': tripsPerRoute, - 'invoiced-vs-collected': invoicedVsCollected, - 'aging-receivables': agingReceivables, - 'revenue-by-payment-method': revenueByPaymentMethod, -}; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts new file mode 100644 index 000000000..5df9ba2e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -0,0 +1,141 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; +import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportDefinition, ReportRunResult } from './report.types'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Raw query params, minus the pagination/sort keys the runner owns. */ +export type RawReportQuery = Record; + +/** + * Coerce raw query strings into typed filter params per the report's own + * filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted` + * can't police a per-report bag, so extras are just dropped, not rejected. + */ +function coerceParams( + def: ReportDefinition, + raw: RawReportQuery, +): Record { + const params: Record = {}; + for (const filter of def.filters) { + if (filter.type === 'daterange') { + const from = raw[`${filter.key}From`]; + const to = raw[`${filter.key}To`]; + params[`${filter.key}From`] = from ? new Date(from).toISOString() : null; + // Inclusive end date, exclusive bound in SQL. + params[`${filter.key}To`] = to + ? new Date(new Date(to).getTime() + DAY_MS).toISOString() + : null; + } else if (filter.type === 'multiselect') { + const csv = raw[filter.key]; + const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; + params[filter.key] = items.length ? items : null; + } else { + params[filter.key] = raw[filter.key]?.trim() || null; + } + } + // idKey, when the report declares one, is a plain string param. + if (def.idKey) { + params[def.idKey.key] = raw[def.idKey.key]?.trim() || null; + } + return params; +} + +/** Resolve a client-requested sort column against the report's own whitelist. */ +function resolveSort( + def: ReportDefinition, + sortBy?: string, + sortOrder?: string, +): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null { + const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); + if (requested) { + return { key: requested.key, expr: requested.sortExpr ?? requested.key, dir }; + } + if (!def.defaultSort) return null; + const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); + if (!fallback) return null; + return { + key: fallback.key, + expr: fallback.sortExpr ?? fallback.key, + dir: def.defaultSort.dir, + }; +} + +@Injectable() +export class ReportRunnerService { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + async run( + def: ReportDefinition, + raw: RawReportQuery, + directions: string[] | null, + ): Promise { + const params = coerceParams(def, raw); + const ctx = { ds: this.ds, params, directions }; + + const qb = def.query(ctx); + const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + + const { page: pageNum, pageSize, skip, take } = normalizePagination({ + page: raw.page ? Number(raw.page) : undefined, + pageSize: raw.pageSize ? Number(raw.pageSize) : undefined, + }); + + const [sql, sqlParams] = qb.getQueryAndParameters(); + // getCount() re-derives its own (wrong) select list for GROUP BY queries — + // wrapping the real query as a subquery counts exactly what will be paged. + const countRow = await this.ds.query( + `SELECT COUNT(*)::int AS c FROM (${sql}) report_count`, + sqlParams, + ); + const total = Number(countRow[0]?.c ?? 0); + + // .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped + // selects through TypeORM's DISTINCT-id subquery path, which is wrong here. + const items = await qb.offset(skip).limit(take).getRawMany(); + + const kpis = def.summary ? await def.summary(ctx) : []; + + return { + columns: def.columns, + items, + meta: buildPaginationMeta(total, pageNum, pageSize), + kpis, + }; + } + + /** Same query, no paging — used by the export path. */ + async runAll( + def: ReportDefinition, + raw: RawReportQuery, + directions: string[] | null, + limit: number, + ): Promise<{ columns: typeof def.columns; items: Record[]; kpis: ReportRunResult['kpis'] }> { + const params = coerceParams(def, raw); + const ctx = { ds: this.ds, params, directions }; + const qb = def.query(ctx); + const sort = resolveSort(def, undefined, undefined); + if (sort) qb.orderBy(sort.expr, sort.dir); + const items = await qb.limit(limit).getRawMany(); + if (items.length >= limit) { + throw new BadRequestException( + `Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, + ); + } + const kpis = def.summary ? await def.summary(ctx) : []; + return { columns: def.columns, items, kpis }; + } +} + +// Re-exported so definitions can scope ACL columns without importing the +// trade-scope module directly. +export { applyBookingRefDirectionScope }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts new file mode 100644 index 000000000..84bbfb27a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -0,0 +1,41 @@ +import { REPORT_KEYS } from '../../seed/freight-permissions.registry'; +import { REPORTS, getReport } from './report.registry'; + +describe('REPORTS', () => { + it('has exactly one definition per seeded REPORT_KEYS entry', () => { + const defKeys = REPORTS.map((r) => r.key).sort(); + expect(defKeys).toEqual([...REPORT_KEYS].sort()); + }); + + it('has no duplicate keys', () => { + const keys = REPORTS.map((r) => r.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('resolves every key via getReport', () => { + for (const key of REPORT_KEYS) { + expect(getReport(key)?.key).toBe(key); + } + }); + + it('every sortable column and defaultSort point at a real column key', () => { + for (const def of REPORTS) { + const columnKeys = new Set(def.columns.map((c) => c.key)); + if (def.defaultSort) { + expect(columnKeys.has(def.defaultSort.key)).toBe(true); + } + // Every column marked sortable must have a resolvable key (itself, since + // the runner falls back to `key` when `sortExpr` is absent). + for (const col of def.columns.filter((c) => c.sortable)) { + expect(col.key.length).toBeGreaterThan(0); + } + } + }); + + it('idKey, when declared, is not also listed as a user-facing filter', () => { + for (const def of REPORTS) { + if (!def.idKey) continue; + expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts new file mode 100644 index 000000000..26474d025 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -0,0 +1,24 @@ +import { ReportKey } from '../../seed/freight-permissions.registry'; +import { bookingsListReport } from './definitions/bookings-list.report'; +import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; +import { agingReceivablesReport } from './definitions/aging-receivables.report'; +import { contractUtilizationReport } from './definitions/contract-utilization.report'; +import { ReportDefinition } from './report.types'; + +/** + * Every report the platform knows about. Adding one = a new file under + * definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) + + * an entry here. Nothing else — no frontend edit, no route, no sidebar edit. + */ +export const REPORTS: ReportDefinition[] = [ + bookingsListReport, + revenueByCustomerReport, + agingReceivablesReport, + contractUtilizationReport, +]; + +const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); + +export function getReport(key: string): ReportDefinition | undefined { + return BY_KEY.get(key as ReportKey); +} diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts new file mode 100644 index 000000000..85b73040c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -0,0 +1,95 @@ +import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportKey } from '../../seed/freight-permissions.registry'; + +export type { ReportKey }; + +export type ReportColumnType = + | 'string' + | 'number' + | 'money' + | 'tons' + | 'percent' + | 'date'; + +export interface ReportColumn { + key: string; + label: string; + type: ReportColumnType; + sortable?: boolean; + /** SQL to ORDER BY when this column is sorted, if different from `key`. */ + sortExpr?: string; +} + +export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; + +export interface ReportFilterOption { + value: string; + label: string; +} + +export interface ReportFilterDef { + key: string; + label: string; + type: ReportFilterType; + /** Static option list for select/multiselect. */ + options?: ReportFilterOption[]; +} + +export interface ReportKpi { + label: string; + value: number; + unit?: string; +} + +/** + * Optional entity scope a report can be embedded against — e.g. a + * contract-utilization report shown on a single contract's detail page. + * Purely descriptive; `query()` reads the resolved value off `ctx.params` + * like any other filter. + */ +export interface ReportIdKey { + key: string; + label: string; +} + +export interface ReportContext { + ds: DataSource; + /** Filter values, already coerced against `def.filters` (CSV → array, etc). */ + params: Record; + /** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */ + directions: string[] | null; +} + +export interface ReportDefinition { + key: ReportKey; + title: string; + description: string; + group: 'Commercial' | 'Operations' | 'Finance'; + idKey?: ReportIdKey; + filters: ReportFilterDef[]; + columns: ReportColumn[]; + defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; + query(ctx: ReportContext): SelectQueryBuilder; + /** KPIs over the same filtered set; shown above the table and in exports. */ + summary?(ctx: ReportContext): Promise; +} + +/** Catalog shape served by GET /reports — metadata only, no rows. */ +export type ReportCatalogEntry = Omit & { + hasSummary: boolean; +}; + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + kpis: ReportKpi[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index dc64773d8..49d0307eb 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -1,34 +1,90 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; +import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { ReportQueryDto } from './dto/report-query.dto'; -import { ReportResultDto } from './dto/report-result.dto'; -import { ReportsService } from './reports.service'; +import { PDF_ROW_CAP, ReportExportService, XLSX_ROW_CAP } from './report-export.service'; +import { RawReportQuery, ReportRunnerService } from './report-runner.service'; +import { REPORTS, getReport } from './report.registry'; +import { ReportCatalogEntry, ReportDefinition } from './report.types'; + +const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => { + const { query: _query, summary, ...meta } = def; + return { ...meta, hasSummary: Boolean(summary) }; +}; @ApiTags('Reports') @ApiBearerAuth() @Controller('reports') +@BookingStaff(FREIGHT_PERMS.reports.view) export class ReportsController { constructor( - private readonly reportsService: ReportsService, + private readonly runner: ReportRunnerService, + private readonly exportService: ReportExportService, private readonly userTradeAccessService: UserTradeAccessService, ) {} + @Get() + @ApiOperation({ summary: 'List reports the caller has permission to run' }) + async catalog(@CurrentUser() user: TCurrentUser): Promise { + return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map( + toCatalogEntry, + ); + } + @Get(':key') - @BookingStaff(FREIGHT_PERMS.reports.view) - @ApiOperation({ summary: 'Run a canned report by key with optional filters' }) - @ApiOkResponse({ type: ReportResultDto }) + @ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' }) async run( @Param('key') key: string, - @Query() query: ReportQueryDto, + @Query() query: RawReportQuery, @CurrentUser() user: TCurrentUser, - ): Promise { - const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); - return this.reportsService.run(key, query, allowed); + ) { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + return this.runner.run(def, query, directions); + } + + @Get(':key/export') + @ApiOperation({ summary: 'Export a report to xlsx or pdf' }) + async export( + @Param('key') key: string, + @Query() query: RawReportQuery & { format?: string }, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + const format = query.format === 'pdf' ? 'pdf' : 'xlsx'; + const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP; + + const { items, kpis } = await this.runner.runAll(def, query, directions, cap); + const buffer = + format === 'pdf' + ? await this.exportService.toPdf(def, items, kpis) + : await this.exportService.toXlsx(def, items, kpis); + + const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader( + 'Content-Type', + format === 'pdf' + ? 'application/pdf' + : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.send(buffer); + } + + private resolve(key: string, user: TCurrentUser): ReportDefinition { + const def = getReport(key); + if (!def) throw new NotFoundException(`Unknown report: ${key}`); + // Exact-match on purpose — unlike FreightPermissionGuard's :view/:read + // fallback, a report's own key is the only thing that opens it. + assertFreightPermission(user, reportPermissionKey(def.key)); + return def; } } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index a7fe792a5..2f98e9e04 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; +import { ReportExportService } from './report-export.service'; +import { ReportRunnerService } from './report-runner.service'; import { ReportsController } from './reports.controller'; -import { ReportsRepository } from './reports.repository'; -import { ReportsService } from './reports.service'; @Module({ - imports: [UserTradeAccessModule], + imports: [UserTradeAccessModule, DocumentsModule], controllers: [ReportsController], - providers: [ReportsService, ReportsRepository], + providers: [ReportRunnerService, ReportExportService], }) export class ReportsModule {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts deleted file mode 100644 index 65f154b22..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.repository.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; - -import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries'; - -@Injectable() -export class ReportsRepository { - constructor(@InjectDataSource() private readonly dataSource: DataSource) {} - - run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise { - return REPORT_QUERIES[key](this.dataSource, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts deleted file mode 100644 index 04e6e9a60..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; - -import { scopedDirections } from '../user-trade-access/trade-scope.util'; -import { ReportQueryDto } from './dto/report-query.dto'; -import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries'; -import { ReportsRepository } from './reports.repository'; -import type { Freight } from '@edr/types'; - -const DAY_MS = 24 * 60 * 60 * 1000; - -const list = (csv?: string): string[] | null => { - const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; - return items.length ? items : null; -}; - -@Injectable() -export class ReportsService { - constructor(private readonly repository: ReportsRepository) {} - - run( - key: string, - dto: ReportQueryDto, - allowedDirections: Freight.ScheduleTradeDirection[] | null, - ): Promise { - if (!(key in REPORT_QUERIES)) { - throw new NotFoundException(`Unknown report: ${key}`); - } - // No default range: absent dates mean all time, so exports cover everything. - const to = dto.dateTo ? new Date(dto.dateTo) : null; - const from = dto.dateFrom ? new Date(dto.dateFrom) : null; - const filters: ReportFilters = { - dateFrom: from ? from.toISOString() : null, - // dateTo is inclusive in the API; queries treat the bound as exclusive. - dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null, - granularity: dto.granularity ?? 'day', - companyIds: list(dto.companyIds), - routeIds: list(dto.routeIds), - yardIds: list(dto.yardIds), - cargoTypeIds: list(dto.cargoTypeIds), - statuses: list(dto.statuses), - directions: scopedDirections(allowedDirections, dto.direction), - freightType: dto.freightType ?? null, - }; - return this.repository.run(key, filters); - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0a9a550e..8e8768819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: dotenv-cli: specifier: ^11.0.0 version: 11.0.0 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -601,7 +604,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13056,11 +13059,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13095,7 +13098,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13104,14 +13107,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13126,9 +13122,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -13143,13 +13139,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13302,18 +13298,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -13798,7 +13782,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -13964,7 +13948,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -14124,7 +14108,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -15723,7 +15707,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -17797,7 +17781,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -18084,130 +18068,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -18605,7 +18465,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -18615,7 +18475,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18634,7 +18494,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -18649,7 +18509,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -18938,7 +18798,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19447,16 +19307,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -19612,7 +19462,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -20661,7 +20511,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -20681,7 +20531,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -20912,7 +20762,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -21040,7 +20890,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -21277,7 +21127,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -21330,7 +21180,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21485,7 +21335,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -21733,7 +21583,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22042,7 +21892,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22055,14 +21905,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22502,7 +22352,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -23162,7 +23012,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -23849,7 +23699,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -24375,7 +24225,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -24725,7 +24575,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -24754,7 +24604,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -25007,15 +24857,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -25626,7 +25467,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -25748,7 +25589,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -25964,7 +25805,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -25974,7 +25815,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -25985,7 +25826,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -25994,7 +25835,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -26006,7 +25847,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -26301,24 +26142,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -26344,7 +26167,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -26857,7 +26680,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -26881,7 +26704,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -27242,7 +27065,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27260,7 +27083,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27307,7 +27130,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -27343,7 +27166,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 From 081b9945cb3bc8731f46b7413bc9e0272f7a8724 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 07:52:29 +0000 Subject: [PATCH 09/28] feat(freight-backoffice): render reports from the server-driven catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop reportConfigs.ts (per-report FE config duplicating the backend) and the chart-drawing ReportPage. Replace with ReportView: one engine that renders any report the GET /reports catalog describes — filters, KPI strip, sortable/paginated DataTable, xlsx/pdf export via blob download. ReportSection embeds a report inline on any page, scoped by idKey, and renders nothing if the caller lacks that report's permission. Sidebar Reports submenu is now built from the live catalog (sidebar-sections.tsx + App.tsx) instead of a hand-listed key — no FE edit needed to add or hide a report. --- apps/edr-freight-web/backoffice/src/App.tsx | 16 +- .../components/layout/sidebar-sections.tsx | 8 +- .../src/components/reports/ReportFilters.tsx | 115 +++++ .../src/components/reports/ReportSection.tsx | 39 ++ .../src/components/reports/ReportView.tsx | 212 +++++++++ .../src/components/reports/report-format.ts | 42 ++ .../backoffice/src/constants/URLS.ts | 2 + .../src/pages/reports/ReportPage.tsx | 440 +---------------- .../src/pages/reports/ReportsHubPage.tsx | 193 ++------ .../src/pages/reports/reportConfigs.ts | 445 ------------------ .../backoffice/src/services/api.ts | 7 +- .../src/services/reports.service.ts | 29 +- .../backoffice/src/types/reports.ts | 88 +++- 13 files changed, 586 insertions(+), 1050 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/report-format.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..94ad11f1b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Navigate, Outlet, @@ -10,6 +11,7 @@ import { } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem } from "@/components/layout"; +import { api } from "@/services/api"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -139,8 +141,18 @@ const DashboardShell = () => { const demoItems: SidebarItem[] = []; + const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions()); + const reportItems: SidebarItem[] = useMemo( + () => + (reportCatalog ?? []).map((report) => ({ + label: report.title, + href: `/dashboard/reports/${report.key}`, + })), + [reportCatalog], + ); + const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), + buildSidebarSections(demoItems, reportItems), user, ); const displayName = user?.name?.en || user?.username || user?.email || "User"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 95608b6db..4548dc134 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -51,7 +51,10 @@ import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources" * a user's first reachable route without importing the route tree (App.tsx * imports RequirePermission, which imports landing — that would cycle). */ -export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ +export const buildSidebarSections = ( + demoItems: SidebarItem[], + reportItems: SidebarItem[] = [], +): SidebarSection[] => [ { title: "Main menu", items: [ @@ -66,6 +69,9 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] href: "/dashboard/reports", icon: , permission: FREIGHT_PERMS.reports.view, + // Populated from the live GET /reports catalog (already permission- + // filtered server-side) — no report key is ever hand-listed here. + ...(reportItems.length ? { children: reportItems } : {}), }, { label: "Customers", diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx new file mode 100644 index 000000000..9412d4db8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx @@ -0,0 +1,115 @@ +import { Group, MultiSelect, Select, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { Search } from "lucide-react"; + +import type { ReportFilterDef } from "@/types/reports"; + +export interface ReportFilterValues { + [param: string]: string | undefined; +} + +interface ReportFiltersProps { + filters: ReportFilterDef[]; + values: ReportFilterValues; + onChange: (values: ReportFilterValues) => void; +} + +const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null); +const fromDate = (value: string | null): string | undefined => value ?? undefined; + +/** Renders one widget per report-declared filter and reports raw param values back up. */ +export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) { + if (!filters.length) return null; + + const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch }); + + return ( + + {filters.map((filter) => { + switch (filter.type) { + case "daterange": + return ( + + set({ [`${filter.key}From`]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + set({ [`${filter.key}To`]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + + ); + case "date": + return ( + set({ [filter.key]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + ); + case "select": + return ( + setParam("granularity", v)} - allowDeselect={false} - /> - ) : null} - {config.filters.includes("yards") ? ( - ({ - value: y.id, - label: y.label, - }))} - value={params.get("yardIds")?.split(",").filter(Boolean) ?? []} - onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)} - placeholder="All yards" - /> - ) : null} - {config.filters.includes("direction") ? ( - setParam("freightType", v)} - placeholder="All" - /> - ) : null} - {config.filters.includes("statuses") && config.statusOptions ? ( - setParam("statuses", v.length ? v.join(",") : null)} - placeholder="Default (active)" - /> - ) : null} - - - - - ({ - label: k.label, - value: k.value.toLocaleString(), - hint: k.unit, - }))} - /> - - - - void reportQuery.refetch(), - } - : undefined - } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: rows.length, - }} - tableOptions={{ - manualPagination: false, - state: { pagination }, - onPaginationChange: setPagination, - autoResetPageIndex: false, - }} - footer={({ table, pagination: p }) => ( - - )} /> + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx index f8f6f161d..172afca82 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx @@ -1,159 +1,64 @@ -import { - ActionIcon, - Badge, - Card, - Group, - SimpleGrid, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { Search, Star } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Alert, Card, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { PageContainer, PageHeader } from "@/components/page"; -import { - REPORT_CONFIGS, - REPORT_DOMAINS, - type ReportConfig, -} from "./reportConfigs"; +import { api } from "@/services/api"; +import type { ReportCatalogEntry } from "@/types/reports"; -const FAVORITES_KEY = "reports.favorites"; - -const loadFavorites = (): string[] => { - try { - return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]"); - } catch { - return []; - } -}; - -function ReportCard({ - config, - favorite, - onToggleFavorite, -}: { - config: ReportConfig; - favorite: boolean; - onToggleFavorite: () => void; -}) { - const navigate = useNavigate(); - return ( - navigate(`/dashboard/reports/${config.key}`)} - > - -
- - {config.title} - - - {config.description} - -
- { - e.stopPropagation(); - onToggleFavorite(); - }} - > - - -
- - {config.domain} - -
- ); -} +const GROUP_ORDER: ReportCatalogEntry["group"][] = ["Commercial", "Operations", "Finance"]; export default function ReportsHubPage() { - const [search, setSearch] = useState(""); - const [favorites, setFavorites] = useState(loadFavorites); + const navigate = useNavigate(); + const { data: catalog, isLoading, isError } = useQuery(api.reports.catalog.queryOptions()); - const toggleFavorite = (key: string) => { - setFavorites((prev) => { - const next = prev.includes(key) - ? prev.filter((k) => k !== key) - : [...prev, key]; - localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)); - return next; - }); - }; - - const visible = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return REPORT_CONFIGS; - return REPORT_CONFIGS.filter( - (c) => - c.title.toLowerCase().includes(q) || - c.description.toLowerCase().includes(q), - ); - }, [search]); - - const pinned = visible.filter((c) => favorites.includes(c.key)); - - const renderGrid = (configs: ReportConfig[]) => ( - - {configs.map((c) => ( - toggleFavorite(c.key)} - /> - ))} - - ); + const groups = GROUP_ORDER.map((group) => ({ + group, + reports: (catalog ?? []).filter((r) => r.group === group), + })).filter((g) => g.reports.length); return ( - } - placeholder="Search reports…" - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - /> - } - /> + - {pinned.length ? ( - - Favorites - {renderGrid(pinned)} + {isError ? Failed to load the report catalog. : null} + + {isLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ) : null} + + {!isLoading && !isError && !groups.length ? ( + You don't have access to any reports yet. + ) : null} + + {groups.map(({ group, reports }) => ( + + {group} + + {reports.map((report) => ( + navigate(`/dashboard/reports/${report.key}`)} + > + + {report.title} + + + {report.description} + + + ))} + - ) : null} - - {REPORT_DOMAINS.map((domain) => { - const configs = visible.filter((c) => c.domain === domain); - if (!configs.length) return null; - return ( - - {domain} - {renderGrid(configs)} - - ); - })} - - {visible.length === 0 ? ( - - No reports match “{search}” - - ) : null} + ))} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts deleted file mode 100644 index ced916fbb..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { BookingStatus } from "@edr/types"; - -export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data"; - -export type ReportColumnUnit = "ETB" | "t" | "%" | "min"; - -export interface ReportColumn { - key: string; - label: string; - /** Numeric unit — formats the cell (thousands separators, suffix). */ - unit?: ReportColumnUnit; - numeric?: boolean; -} - -export interface ReportChart { - type: "area" | "line" | "bar"; - xKey: string; - series: { key: string; label: string }[]; - /** Chart only the first N rows (rows arrive sorted by the backend). */ - topN?: number; -} - -export type ReportFilterKey = - | "granularity" - | "yards" - | "direction" - | "freightType" - | "statuses"; - -export interface ReportConfig { - key: string; - title: string; - description: string; - domain: ReportDomain; - filters: ReportFilterKey[]; - /** Options for the `statuses` filter, when enabled. */ - statusOptions?: string[]; - chart?: ReportChart; - columns: ReportColumn[]; -} - -// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias. -const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))]; - -// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum -// in @edr/types yet). -const CONTRACT_STATUSES = [ - "DRAFT", - "SUBMITTED", - "PRICE_CHANGED_PENDING_CONFIRM", - "CHANGES_REQUESTED", - "PENDING_APPROVAL", - "APPROVED", - "APPROVED_PENDING_SIGNATURE", - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - "CONTRACT_ACTIVE", - "AWAITING_CLEARANCE_DOCUMENTS", - "CLEARANCE_UNDER_REVIEW", - "CLEARANCE_READY_FOR_BOOKING", - "ACTIVE_SHIPMENT_IN_PROGRESS", - "SUSPENDED", - "CONTRACT_CLOSED", - "EXPIRED", - "REJECTED", - "CANCELLED", - "RENEWAL_DRAFT", - "RENEWAL_SUBMITTED", - "RENEWAL_PENDING_APPROVAL", - "AMENDMENTS_PROPOSED", - "ARCHIVED", -]; - -const INVOICE_STATUSES = [ - "ISSUED", - "PENDING", - "PAYMENT_PROCESSING", - "PARTIALLY_PAID", - "PAID", - "OVERDUE", - "REFUNDED", -]; - -export const REPORT_CONFIGS: ReportConfig[] = [ - { - key: "bookings-trend", - title: "Bookings Trend", - description: "Booking volume, tonnage and revenue over time", - domain: "Commercial", - filters: ["granularity", "yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "area", - xKey: "period", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - }, - columns: [ - { key: "period", label: "Period" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "revenue-by-customer", - title: "Revenue by Customer", - description: "Ranked customers by booking revenue", - domain: "Commercial", - filters: ["yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "bar", - xKey: "customer", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - topN: 10, - }, - columns: [ - { key: "customer", label: "Customer" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "revenue-by-lane", - title: "Revenue by Lane", - description: "Origin → destination lanes by tonnage and revenue", - domain: "Commercial", - filters: ["direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - topN: 10, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "contract-utilization", - title: "Contract Utilization", - description: "Committed scope caps vs booked tonnage per contract", - domain: "Commercial", - filters: ["direction", "statuses"], - statusOptions: CONTRACT_STATUSES, - columns: [ - { key: "reference", label: "Contract" }, - { key: "customer", label: "Customer" }, - { key: "status", label: "Status" }, - { key: "kind", label: "Kind" }, - { key: "valid_from", label: "Valid from" }, - { key: "valid_until", label: "Valid until" }, - { key: "committed", label: "Committed", unit: "t" }, - { key: "booked_tons", label: "Booked", unit: "t" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "utilization_pct", label: "Utilization", unit: "%" }, - ], - }, - { - key: "train-on-time", - title: "Train On-Time Performance", - description: "Departure punctuality and delays by lane (60-min grace)", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "on_time_pct", label: "On-time %" }], - topN: 15, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "trips", label: "Trips", numeric: true }, - { key: "departed", label: "Departed", numeric: true }, - { key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" }, - { key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" }, - { key: "on_time_pct", label: "On-time", unit: "%" }, - ], - }, - { - key: "schedule-fill-rate", - title: "Schedule Fill Rate", - description: "Booked tonnage vs wagon capacity per train schedule", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "line", - xKey: "departure", - series: [{ key: "fill_pct", label: "Fill %" }], - }, - columns: [ - { key: "train_number", label: "Train" }, - { key: "departure", label: "Departure" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "direction", label: "Direction" }, - { key: "status", label: "Status" }, - { key: "wagon_count", label: "Wagons", numeric: true }, - { key: "capacity_tons", label: "Capacity", unit: "t" }, - { key: "booked_tons", label: "Booked", unit: "t" }, - { key: "fill_pct", label: "Fill", unit: "%" }, - ], - }, - { - key: "trips-per-route", - title: "Trips per Route", - description: "Completed trips and tonnage hauled per lane", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "trips", label: "Trips" }], - topN: 15, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "direction", label: "Direction" }, - { key: "trips", label: "Trips", numeric: true }, - { key: "tons_hauled", label: "Tonnage hauled", unit: "t" }, - { key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" }, - ], - }, - { - key: "invoiced-vs-collected", - title: "Invoiced vs Collected", - description: "Billing issued vs payments received over time", - domain: "Finance", - filters: ["granularity", "direction"], - chart: { - type: "line", - xKey: "period", - series: [ - { key: "invoiced", label: "Invoiced (ETB)" }, - { key: "collected", label: "Collected (ETB)" }, - ], - }, - columns: [ - { key: "period", label: "Period" }, - { key: "invoices", label: "Invoices", numeric: true }, - { key: "invoiced", label: "Invoiced", unit: "ETB" }, - { key: "collected", label: "Collected", unit: "ETB" }, - { key: "outstanding", label: "Outstanding", unit: "ETB" }, - ], - }, - { - key: "aging-receivables", - title: "Aging Receivables", - description: "Outstanding invoice balances by age bucket per customer", - domain: "Finance", - filters: ["direction", "statuses"], - statusOptions: INVOICE_STATUSES, - chart: { - type: "bar", - xKey: "customer", - series: [{ key: "outstanding", label: "Outstanding (ETB)" }], - topN: 10, - }, - columns: [ - { key: "customer", label: "Customer" }, - { key: "invoices", label: "Invoices", numeric: true }, - { key: "outstanding", label: "Outstanding", unit: "ETB" }, - { key: "current", label: "Current", unit: "ETB" }, - { key: "overdue_0_30", label: "0–30d", unit: "ETB" }, - { key: "overdue_31_60", label: "31–60d", unit: "ETB" }, - { key: "overdue_61_90", label: "61–90d", unit: "ETB" }, - { key: "overdue_90_plus", label: "90d+", unit: "ETB" }, - ], - }, - { - key: "revenue-by-payment-method", - title: "Revenue by Payment Method", - description: "Successful payments broken down by method", - domain: "Finance", - filters: ["direction"], - chart: { - type: "bar", - xKey: "method", - series: [{ key: "amount", label: "Amount (ETB)" }], - }, - columns: [ - { key: "method", label: "Method" }, - { key: "payments", label: "Payments", numeric: true }, - { key: "amount", label: "Amount", unit: "ETB" }, - ], - }, - // --- Record-level list exports (Data domain) — filtered or full dumps --- - { - key: "bookings-list", - title: "Bookings Export", - description: "Booking records with customer, lane, cargo, amounts", - domain: "Data", - filters: ["yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - columns: [ - { key: "reference", label: "Reference" }, - { key: "created", label: "Created" }, - { key: "customer", label: "Customer" }, - { key: "status", label: "Status" }, - { key: "freight_type", label: "Freight" }, - { key: "direction", label: "Direction" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "cargo", label: "Cargo" }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "amount", label: "Amount", unit: "ETB" }, - { key: "payment_status", label: "Payment" }, - { key: "scheduling_status", label: "Scheduling" }, - ], - }, - { - key: "contracts-list", - title: "Contracts Export", - description: "Contract records with validity, status, customer", - domain: "Data", - filters: ["direction", "statuses"], - statusOptions: CONTRACT_STATUSES, - columns: [ - { key: "reference", label: "Reference" }, - { key: "customer", label: "Customer" }, - { key: "kind", label: "Kind" }, - { key: "status", label: "Status" }, - { key: "direction", label: "Direction" }, - { key: "freight_type", label: "Freight" }, - { key: "valid_from", label: "Valid from" }, - { key: "valid_until", label: "Valid until" }, - { key: "created", label: "Created" }, - ], - }, - { - key: "schedules-list", - title: "Train Schedules Export", - description: "Schedule records with planned vs actual times", - domain: "Data", - filters: ["yards", "direction", "statuses"], - statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"], - columns: [ - { key: "train_number", label: "Train" }, - { key: "reference", label: "Reference" }, - { key: "direction", label: "Direction" }, - { key: "status", label: "Status" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "scheduled_departure", label: "Sched. departure" }, - { key: "actual_departure", label: "Actual departure" }, - { key: "scheduled_arrival", label: "Sched. arrival" }, - { key: "actual_arrival", label: "Actual arrival" }, - { key: "max_wagons", label: "Max wagons", numeric: true }, - { key: "wagon_count", label: "Wagons", numeric: true }, - ], - }, - { - key: "fleet-wagons", - title: "Wagons Export", - description: "Wagon fleet with type, capacity, status, location", - domain: "Data", - filters: ["yards", "statuses"], - statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"], - columns: [ - { key: "wagon_number", label: "Wagon" }, - { key: "type", label: "Type" }, - { key: "capacity_tons", label: "Capacity", unit: "t" }, - { key: "status", label: "Status" }, - { key: "current_yard", label: "Current yard" }, - ], - }, - { - key: "fleet-locomotives", - title: "Locomotives Export", - description: "Locomotive fleet with type, pull capacity, status", - domain: "Data", - filters: ["yards", "statuses"], - statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"], - columns: [ - { key: "code", label: "Code" }, - { key: "name", label: "Name" }, - { key: "locomotive_type", label: "Type" }, - { key: "max_pull_tons", label: "Max pull", unit: "t" }, - { key: "status", label: "Status" }, - { key: "current_yard", label: "Current yard" }, - ], - }, - { - key: "customers-list", - title: "Customers Export", - description: "Company records with type, status, TIN", - domain: "Data", - filters: ["statuses"], - statusOptions: ["pending", "active"], - columns: [ - { key: "name", label: "Name" }, - { key: "type", label: "Type" }, - { key: "kind", label: "Kind" }, - { key: "status", label: "Status" }, - { key: "tin", label: "TIN" }, - { key: "approved", label: "Approved" }, - { key: "created", label: "Created" }, - ], - }, - { - key: "payments-list", - title: "Payments Export", - description: "Payment transactions with method, status, references", - domain: "Data", - filters: ["direction", "statuses"], - statusOptions: [ - "action-required", - "processing", - "success", - "failed", - "canceled", - "refunded", - ], - columns: [ - { key: "created", label: "Created" }, - { key: "method", label: "Method" }, - { key: "status", label: "Status" }, - { key: "currency", label: "Currency" }, - { key: "amount", label: "Amount", unit: "ETB" }, - { key: "transaction_id", label: "Transaction" }, - { key: "merchant_order_id", label: "Merchant order" }, - { key: "paid", label: "Paid" }, - ], - }, -]; - -export const REPORT_CONFIG_BY_KEY = new Map( - REPORT_CONFIGS.map((c) => [c.key, c]), -); - -export const REPORT_DOMAINS: ReportDomain[] = [ - "Commercial", - "Operations", - "Finance", - "Data", -]; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 2a1963f8d..8f8068a45 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -174,7 +174,7 @@ import { } from "./locomotives.service"; import { overviewService } from "./overview.service"; import { reportsService } from "./reports.service"; -import type { ReportQueryInput, ReportResult } from "@/types/reports"; +import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports"; import { paymentsService, type PaginatedPayments, @@ -3030,7 +3030,10 @@ export const api = { }, reports: { - run: endpoint( + catalog: endpoint("reports", "catalog", () => + reportsService.catalog(), + ), + run: endpoint( "reports", "run", (input) => reportsService.run(input), diff --git a/apps/edr-freight-web/backoffice/src/services/reports.service.ts b/apps/edr-freight-web/backoffice/src/services/reports.service.ts index 6f6995614..f04bc8c8f 100644 --- a/apps/edr-freight-web/backoffice/src/services/reports.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/reports.service.ts @@ -1,14 +1,31 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; -import type { ReportQueryInput, ReportResult } from "@/types/reports"; +import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports"; + +const R = URL_CONSTANTS.REPORTS; export const reportsService = { - run: async ({ key, ...params }: ReportQueryInput): Promise => { - const response = await client.get( - URL_CONSTANTS.REPORTS.RUN(key), - { params }, - ); + catalog: async (): Promise => { + const response = await client.get(R.CATALOG); return unwrap(response.data); }, + + run: async ({ key, ...params }: ReportRunParams): Promise => { + const response = await client.get(R.RUN(key), { params }); + return unwrap(response.data); + }, + + /** Streams the export file as a blob — caller triggers the browser save. */ + download: async ( + key: string, + format: "xlsx" | "pdf", + params: Omit, + ): Promise => { + const response = await client.get(R.EXPORT(key), { + params: { ...params, format }, + responseType: "blob", + }); + return response.data as Blob; + }, }; diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts index 72326788c..854b1a801 100644 --- a/apps/edr-freight-web/backoffice/src/types/reports.ts +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -1,27 +1,77 @@ +export type ReportColumnType = + | "string" + | "number" + | "money" + | "tons" + | "percent" + | "date"; + +export interface ReportColumn { + key: string; + label: string; + type: ReportColumnType; + sortable?: boolean; +} + +export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text"; + +export interface ReportFilterOption { + value: string; + label: string; +} + +export interface ReportFilterDef { + key: string; + label: string; + type: ReportFilterType; + options?: ReportFilterOption[]; +} + +export interface ReportIdKey { + key: string; + label: string; +} + export interface ReportKpi { label: string; value: number; unit?: string; } -export type ReportRow = Record; - -export interface ReportResult { - kpis: ReportKpi[]; - rows: ReportRow[]; -} - -/** Query params for GET /reports/:key. List filters are comma-separated. */ -export interface ReportQueryInput { +/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */ +export interface ReportCatalogEntry { key: string; - dateFrom?: string; - dateTo?: string; - granularity?: "day" | "week" | "month"; - companyIds?: string; - routeIds?: string; - yardIds?: string; - cargoTypeIds?: string; - statuses?: string; - direction?: string; - freightType?: string; + title: string; + description: string; + group: "Commercial" | "Operations" | "Finance"; + idKey?: ReportIdKey; + filters: ReportFilterDef[]; + columns: ReportColumn[]; + defaultSort?: { key: string; dir: "ASC" | "DESC" }; + hasSummary: boolean; } + +export interface ReportPageMeta { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: ReportPageMeta; + kpis: ReportKpi[]; +} + +/** Query params for GET /reports/:key — page/sort plus whatever filters the report declares. */ +export type ReportRunParams = Record & { + key: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +}; From 9930bef8aafb09e13e94921c4ee784e5b3c87a94 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 07:54:44 +0000 Subject: [PATCH 10/28] fix(freight-api): quote fallback sort aliases Sorting by a column with no explicit sortExpr fell back to the bare select alias unquoted. Postgres folds unquoted identifiers to lowercase, so any camelCase alias (utilizationPct, bookedTons) 42703'd. Quote the fallback to match the case TypeORM's addSelect actually emitted. --- .../src/modules/reports/report-runner.service.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index 5df9ba2e1..09933de42 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -48,6 +48,15 @@ function coerceParams( return params; } +/** + * Sort expression for a column with no explicit `sortExpr`: the SELECT alias + * TypeORM emitted for it, quoted. TypeORM always double-quotes `addSelect` + * aliases in the generated SQL (preserving case) — ordering by the bare, + * unquoted key instead lets Postgres fold it to lowercase and 42703 on any + * camelCase alias (e.g. "utilizationPct" -> unquoted "utilizationpct"). + */ +const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; + /** Resolve a client-requested sort column against the report's own whitelist. */ function resolveSort( def: ReportDefinition, @@ -57,14 +66,14 @@ function resolveSort( const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); if (requested) { - return { key: requested.key, expr: requested.sortExpr ?? requested.key, dir }; + return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; } if (!def.defaultSort) return null; const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); if (!fallback) return null; return { key: fallback.key, - expr: fallback.sortExpr ?? fallback.key, + expr: fallback.sortExpr ?? aliasSortExpr(fallback.key), dir: def.defaultSort.dir, }; } From c0cdf805601b1adc33e0b43baa1c2e791a361867 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:08:13 +0000 Subject: [PATCH 11/28] feat(freight-api): add 4 fleet-ops reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wagon-fleet-status, wagon-status-duration, wagon-requests, locomotive-fleet-status. First batch off the ITLMS dashboard spec — fleet data (wagons/locomotives/transfer-requests) needed no schema work, just resolvers. No frontend changes: catalog is server-driven. --- .../locomotive-fleet-status.report.ts | 67 +++++++++++++ .../definitions/wagon-fleet-status.report.ts | 76 +++++++++++++++ .../definitions/wagon-requests.report.ts | 85 +++++++++++++++++ .../wagon-status-duration.report.ts | 94 +++++++++++++++++++ .../src/modules/reports/report.registry.ts | 8 ++ .../src/seed/freight-permissions.registry.ts | 4 + 6 files changed, 334 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts new file mode 100644 index 000000000..f2b9377f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -0,0 +1,67 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Locomotive, LOCOMOTIVE_STATUSES } from '../../locomotives/entities/locomotive.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = LOCOMOTIVE_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Locomotive, 'l') + .leftJoin(Yard, 'y', 'y.id = l.current_yard_id') + .where('l.deleted_at IS NULL'); + + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses }); + return qb; +} + +export const locomotiveFleetStatusReport: ReportDefinition = { + key: 'locomotive-fleet-status', + title: 'Locomotive Fleet Status', + description: 'Locomotive counts by type, station and status', + group: 'Operations', + filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }], + columns: [ + { key: 'locomotiveType', label: 'Type', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('l.locomotive_type', 'locomotiveType') + .addSelect("COALESCE(y.label, 'Unassigned')", 'station') + .addSelect('l.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('l.locomotive_type') + .addGroupBy('y.label') + .addGroupBy('l.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE l.status = :available)::int', 'available') + .addSelect('COUNT(*) FILTER (WHERE l.status = :assigned)::int', 'assigned') + .addSelect('COUNT(*) FILTER (WHERE l.status = :maintenance)::int', 'maintenance') + .addSelect('COUNT(*) FILTER (WHERE l.status = :outOfService)::int', 'outOfService') + .setParameters({ + available: 'AVAILABLE', + assigned: 'ASSIGNED', + maintenance: 'MAINTENANCE', + outOfService: 'OUT_OF_SERVICE', + }) + .getRawOne(); + return [ + { label: 'Total locomotives', value: Number(row?.total ?? 0) }, + { label: 'Available', value: Number(row?.available ?? 0) }, + { label: 'Assigned', value: Number(row?.assigned ?? 0) }, + { label: 'Under maintenance', value: Number(row?.maintenance ?? 0) }, + { label: 'Out of service', value: Number(row?.outOfService ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts new file mode 100644 index 000000000..1371936f5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts @@ -0,0 +1,76 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WagonStatus } from '@edr/types'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(WagonStatus).map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Wagon, 'w') + .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') + .leftJoin(Yard, 'y', 'y.id = w.current_yard_id') + .where('w.deleted_at IS NULL'); + + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('w.status IN (:...statuses)', { statuses }); + return qb; +} + +export const wagonFleetStatusReport: ReportDefinition = { + key: 'wagon-fleet-status', + title: 'Wagon Fleet Status', + description: 'Wagon counts by type, station and status', + group: 'Operations', + filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }], + columns: [ + { key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('COALESCE(wt.name, \'Unknown\')', 'wagonType') + .addSelect("COALESCE(y.label, 'Unassigned')", 'station') + .addSelect('w.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('wt.name') + .addGroupBy('y.label') + .addGroupBy('w.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE w.status = :available)::int', 'available') + .addSelect('COUNT(*) FILTER (WHERE w.status = :assigned)::int', 'assigned') + .addSelect('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance') + .addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained') + .addSelect('COUNT(*) FILTER (WHERE w.status = :outOfService)::int', 'outOfService') + .setParameters({ + available: WagonStatus.Available, + assigned: WagonStatus.Assigned, + maintenance: WagonStatus.Maintenance, + detained: WagonStatus.Detained, + outOfService: WagonStatus.OutOfService, + }) + .getRawOne(); + return [ + { label: 'Total wagons', value: Number(row?.total ?? 0) }, + { label: 'Available', value: Number(row?.available ?? 0) }, + { label: 'Assigned', value: Number(row?.assigned ?? 0) }, + { label: 'Under maintenance', value: Number(row?.maintenance ?? 0) }, + { label: 'Detained', value: Number(row?.detained ?? 0) }, + { label: 'Out of service', value: Number(row?.outOfService ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts new file mode 100644 index 000000000..df5ac364c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts @@ -0,0 +1,85 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WagonTransferRequestStatus } from '@edr/types'; +import { WagonTransferRequest } from '../../wagons/entities/wagon-transfer-request.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(WagonTransferRequestStatus).map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(WagonTransferRequest, 'r') + .leftJoin(Yard, 'fy', 'fy.id = r.from_yard_id') + .leftJoin(Yard, 'ty', 'ty.id = r.to_yard_id') + .leftJoin(WagonType, 'wt', 'wt.id = r.wagon_type_id') + .where('r.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('r.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('r.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('r.status IN (:...statuses)', { statuses }); + return qb; +} + +export const wagonRequestsReport: ReportDefinition = { + key: 'wagon-requests', + title: 'Wagon Requests', + description: 'Inter-yard wagon transfer requests and fulfilment delay', + group: 'Operations', + filters: [ + { key: 'date', label: 'Requested', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'fromYard', label: 'From', type: 'string', sortable: true, sortExpr: 'fy.label' }, + { key: 'toYard', label: 'To', type: 'string', sortable: true, sortExpr: 'ty.label' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'quantity', label: 'Requested', type: 'number' }, + { key: 'fulfilledQuantity', label: 'Fulfilled', type: 'number' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'r.status' }, + { key: 'requestedAt', label: 'Requested at', type: 'date', sortable: true, sortExpr: 'r.created_at' }, + { key: 'fulfilledAt', label: 'Fulfilled at', type: 'date' }, + { key: 'delayDays', label: 'Delay (days)', type: 'number', sortable: true }, + ], + defaultSort: { key: 'requestedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('fy.label', 'fromYard') + .addSelect('ty.label', 'toYard') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect('r.quantity', 'quantity') + .addSelect('r.fulfilled_quantity', 'fulfilledQuantity') + .addSelect('r.status', 'status') + .addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt') + .addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt') + .addSelect( + `ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`, + 'delayDays', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'requests') + .addSelect('COUNT(*) FILTER (WHERE r.status IN (:...openStatuses))::int', 'open') + .addSelect( + `ROUND(AVG(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400), 1)::float8`, + 'avgDelayDays', + ) + .setParameters({ + openStatuses: [WagonTransferRequestStatus.Pending, WagonTransferRequestStatus.PartiallyFulfilled], + }) + .getRawOne(); + return [ + { label: 'Requests', value: Number(row?.requests ?? 0) }, + { label: 'Still open', value: Number(row?.open ?? 0) }, + { label: 'Avg delay', value: Number(row?.avgDelayDays ?? 0), unit: 'd' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts new file mode 100644 index 000000000..348f681d1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts @@ -0,0 +1,94 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WagonStatus } from '@edr/types'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// Only these two statuses have an operational "how long has it been stuck +// here" question — everything else (Available, Assigned, ...) turns over too +// fast for a days-in-status view to matter. +const TRACKED_STATUSES = [WagonStatus.Maintenance, WagonStatus.Detained]; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Wagon, 'w') + .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') + .leftJoin(Yard, 'y', 'y.id = w.current_yard_id') + // Latest time each wagon flipped INTO its current status, per (wagon, status) + // pair — a plain (non-correlated) derived table, joined on both columns, so + // it stays a normal JOIN rather than needing a LATERAL correlated subquery. + .leftJoin( + (sub) => + sub + .select('l.wagon_id', 'wagon_id') + .addSelect('l.to_status', 'to_status') + .addSelect('MAX(l.created_at)', 'since') + .from('freight.wagon_status_logs', 'l') + .groupBy('l.wagon_id') + .addGroupBy('l.to_status'), + 'log', + 'log.wagon_id = w.id AND log.to_status = w.status', + ) + .where('w.deleted_at IS NULL') + .andWhere('w.status IN (:...trackedStatuses)', { trackedStatuses: TRACKED_STATUSES }); + + const status = params.status as string | null; + if (status) qb.andWhere('w.status = :status', { status }); + return qb; +} + +export const wagonStatusDurationReport: ReportDefinition = { + key: 'wagon-status-duration', + title: 'Wagon Status Duration', + description: 'How long each wagon has sat in Maintenance or Detained', + group: 'Operations', + filters: [ + { + key: 'status', + label: 'Status', + type: 'select', + options: TRACKED_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })), + }, + ], + columns: [ + { key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'station', label: 'Station', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'w.status' }, + { key: 'since', label: 'Since', type: 'date', sortable: true }, + { key: 'daysInStatus', label: 'Days in status', type: 'number', sortable: true }, + ], + defaultSort: { key: 'daysInStatus', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('w.wagon_number', 'wagonNumber') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect("COALESCE(y.label, 'Unassigned')", 'station') + .addSelect('w.status', 'status') + .addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since') + .addSelect( + `FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`, + 'daysInStatus', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance') + .addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained') + .addSelect( + `MAX(FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400))::int`, + 'longest', + ) + .setParameters({ maintenance: WagonStatus.Maintenance, detained: WagonStatus.Detained }) + .getRawOne(); + return [ + { label: 'Under maintenance', value: Number(row?.maintenance ?? 0) }, + { label: 'Detained', value: Number(row?.detained ?? 0) }, + { label: 'Longest days in status', value: Number(row?.longest ?? 0), unit: 'd' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 26474d025..dfc6ff002 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -3,6 +3,10 @@ import { bookingsListReport } from './definitions/bookings-list.report'; import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; import { agingReceivablesReport } from './definitions/aging-receivables.report'; import { contractUtilizationReport } from './definitions/contract-utilization.report'; +import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; +import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; +import { wagonRequestsReport } from './definitions/wagon-requests.report'; +import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; import { ReportDefinition } from './report.types'; /** @@ -15,6 +19,10 @@ export const REPORTS: ReportDefinition[] = [ revenueByCustomerReport, agingReceivablesReport, contractUtilizationReport, + wagonFleetStatusReport, + wagonStatusDurationReport, + wagonRequestsReport, + locomotiveFleetStatusReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 7470ed2b5..f9a0f959f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -59,6 +59,10 @@ export const REPORT_KEYS = [ "revenue-by-customer", "aging-receivables", "contract-utilization", + "wagon-fleet-status", + "wagon-status-duration", + "wagon-requests", + "locomotive-fleet-status", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; From a53a9c71523b443961ef51f823087fc489166710 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:14:17 +0000 Subject: [PATCH 12/28] feat(freight-api): add 6 booking/train-lifecycle reports booking-status-breakdown (dedupes the same 'status per port/train/ cargo/contract' ask across 4 dashboards), train-schedule-status, train-turnaround, wagon-teu-utilization, loaded-capacity, global-logistics-wagons. Dropped freight-weight-variance from this batch: the schema has no 'charged weight' distinct from VGM/actual, so a charged-vs-actual variance report isn't buildable without a product decision on what 'charged' means here. --- .../booking-status-breakdown.report.ts | 108 ++++++++++++++++++ .../global-logistics-wagons.report.ts | 68 +++++++++++ .../definitions/loaded-capacity.report.ts | 78 +++++++++++++ .../train-schedule-status.report.ts | 100 ++++++++++++++++ .../definitions/train-turnaround.report.ts | 86 ++++++++++++++ .../wagon-teu-utilization.report.ts | 76 ++++++++++++ .../src/modules/reports/report.registry.ts | 12 ++ .../src/seed/freight-permissions.registry.ts | 6 + 8 files changed, 534 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts new file mode 100644 index 000000000..a01f714ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -0,0 +1,108 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { BookingStatus } from '@edr/types'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// One resolver behind "Booking per status, per port/train/date/cargo/contract +// type" — the same breakdown Operation, Marketing, Global Logistics and the +// Operation Report each ask for verbatim. Embed once, reuse everywhere. +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; + +const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .leftJoin(Yard, 'o', 'o.id = b.origin_yard_id') + .leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id') + .where('b.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); + if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('b.status IN (:...statuses)', { statuses }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const bookingStatusBreakdownReport: ReportDefinition = { + key: 'booking-status-breakdown', + title: 'Bookings by Status', + description: 'Booking counts by status, direction, origin station, cargo and contract type', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { + key: 'freightType', + label: 'Freight type', + type: 'select', + options: [ + { value: 'CONTAINER', label: 'Container' }, + { value: 'BULK', label: 'Bulk' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' }, + { key: 'direction', label: 'Direction', type: 'string', sortable: true, sortExpr: 'b.trade_direction' }, + { key: 'originStation', label: 'Origin', type: 'string', sortable: true }, + { key: 'cargoType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'contractKind', label: 'Contract type', type: 'string', sortable: true }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'bookings', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.status', 'status') + .addSelect('b.trade_direction', 'direction') + .addSelect("COALESCE(o.label, 'Unknown')", 'originStation') + .addSelect("COALESCE(cty.cargo_type_name, 'Other')", 'cargoType') + .addSelect("COALESCE(b.contract_kind, 'SPOT')", 'contractKind') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount') + .groupBy('b.status') + .addGroupBy('b.trade_direction') + .addGroupBy('o.label') + .addGroupBy('cty.cargo_type_name') + .addGroupBy('b.contract_kind'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + { label: 'Amount', value: Number(row?.amount ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts new file mode 100644 index 000000000..e23ce5469 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -0,0 +1,68 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net +// count unchanged) is excluded — it's neither an allocation nor a cancellation. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(ScheduleWagonAdjustmentLog, 'l') + .leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id') + .where('l.deleted_at IS NULL') + .andWhere("l.action IN ('ADD', 'REMOVE')"); + + if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + return qb; +} + +export const globalLogisticsWagonsReport: ReportDefinition = { + key: 'global-logistics-wagons', + title: 'Wagon Allocations by Day', + description: 'Wagons allocated vs. cancelled per day, by direction', + group: 'Operations', + filters: [ + { key: 'date', label: 'Date', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + ], + columns: [ + { key: 'date', label: 'Date', type: 'date', sortable: true, sortExpr: `date_trunc('day', l.occurred_at)` }, + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'allocated', label: 'Allocated', type: 'number', sortable: true }, + { key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true }, + ], + defaultSort: { key: 'date', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date') + .addSelect("COALESCE(ts.direction, 'Unknown')", 'direction') + .addSelect("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated') + .addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled') + .groupBy(`date_trunc('day', l.occurred_at)`) + .addGroupBy('ts.direction'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated') + .addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled') + .getRawOne(); + return [ + { label: 'Allocated', value: Number(row?.allocated ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts new file mode 100644 index 000000000..8827b77b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts @@ -0,0 +1,78 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// train_set_wagons.assigned_weight_tons is the planned load per slot, already +// maintained by the wagon-allocation flow — no need to re-derive it from +// bulk/container line items. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSetWagon, 'tsw') + .innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id') + .leftJoin(WagonType, 'wt', 'wt.id = tsw.wagon_type_id') + .where('tsw.deleted_at IS NULL AND ts.deleted_at IS NULL'); + + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` }); + } + if (params.dateFrom) { + qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); + } + if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + return qb; +} + +export const loadedCapacityReport: ReportDefinition = { + key: 'loaded-capacity', + title: 'Loaded Capacity', + description: 'Nameplate vs. loaded capacity per train, by wagon type', + group: 'Operations', + filters: [ + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'date', label: 'Departure', type: 'daterange' }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'departureDate', label: 'Departure', type: 'date' }, + { key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true }, + { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, + { key: 'capacityTons', label: 'Capacity', type: 'tons', sortable: true }, + { key: 'loadedTons', label: 'Loaded', type: 'tons', sortable: true }, + { key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true }, + ], + defaultSort: { key: 'loadedTons', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect('COUNT(*)::int', 'wagons') + .addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons') + .addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons') + .addSelect( + `CASE WHEN COALESCE(SUM(tsw.capacity_tons), 0) > 0 + THEN ROUND(SUM(tsw.assigned_weight_tons) / SUM(tsw.capacity_tons) * 100)::float8 END`, + 'utilizationPct', + ) + .groupBy('ts.train_number') + .addGroupBy('ts.scheduled_departure_date') + .addGroupBy('wt.name'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'wagons') + .addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons') + .addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons') + .getRawOne(); + return [ + { label: 'Wagons', value: Number(row?.wagons ?? 0) }, + { label: 'Capacity', value: Number(row?.capacityTons ?? 0), unit: 't' }, + { label: 'Loaded', value: Number(row?.loadedTons ?? 0), unit: 't' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts new file mode 100644 index 000000000..88a25d890 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts @@ -0,0 +1,100 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the +// train lifecycle. The platform tracks DRAFT/SCHEDULED/DISPATCHED/ARRIVED/ +// CANCELLED — no separate "in transit" status exists (a dispatched schedule +// with no actual_arrival_at yet *is* in transit; reported as DISPATCHED). +const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSchedule, 'ts') + .leftJoin(Yard, 'o', 'o.id = ts.origin_station_id') + .leftJoin(Yard, 'd', 'd.id = ts.destination_station_id') + .where('ts.deleted_at IS NULL'); + + if (params.dateFrom) { + qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); + } + if (params.dateTo) { + qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + } + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses }); + return qb; +} + +export const trainScheduleStatusReport: ReportDefinition = { + key: 'train-schedule-status', + title: 'Train Schedules', + description: 'Scheduled, dispatched, arrived and cancelled train departures', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'reference', label: 'Reference', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ts.status' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { + key: 'scheduledDeparture', + label: 'Scheduled dep.', + type: 'date', + sortable: true, + sortExpr: 'ts.scheduled_departure_date', + }, + { key: 'actualDeparture', label: 'Actual dep.', type: 'date' }, + { key: 'actualArrival', label: 'Actual arr.', type: 'date' }, + ], + defaultSort: { key: 'scheduledDeparture', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect('ts.reference', 'reference') + .addSelect('ts.status', 'status') + .addSelect('ts.direction', 'direction') + .addSelect("COALESCE(o.label, 'Unknown')", 'origin') + .addSelect("COALESCE(d.label, 'Unknown')", 'destination') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture') + .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture') + .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :scheduled)::int', 'scheduled') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :dispatched)::int', 'dispatched') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :arrived)::int', 'arrived') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :cancelled)::int', 'cancelled') + .setParameters({ scheduled: 'SCHEDULED', dispatched: 'DISPATCHED', arrived: 'ARRIVED', cancelled: 'CANCELLED' }) + .getRawOne(); + return [ + { label: 'Total', value: Number(row?.total ?? 0) }, + { label: 'Scheduled', value: Number(row?.scheduled ?? 0) }, + { label: 'Dispatched', value: Number(row?.dispatched ?? 0) }, + { label: 'Arrived', value: Number(row?.arrived ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts new file mode 100644 index 000000000..7dc7b8c3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts @@ -0,0 +1,86 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// "Turnaround" here is departure-to-arrival transit time on the actual (not +// scheduled) timestamps. Station dwell time (arrival -> the SAME train's next +// departure) would need pairing consecutive schedules by physical train, +// which isn't tracked directly — deferred, not modeled as a shortcut. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSchedule, 'ts') + .leftJoin(Yard, 'o', 'o.id = ts.origin_station_id') + .leftJoin(Yard, 'd', 'd.id = ts.destination_station_id') + .where('ts.deleted_at IS NULL') + .andWhere('ts.actual_departure_at IS NOT NULL') + .andWhere('ts.actual_arrival_at IS NOT NULL'); + + if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + return qb; +} + +export const trainTurnaroundReport: ReportDefinition = { + key: 'train-turnaround', + title: 'Train Turnaround', + description: 'Actual departure-to-arrival transit time per schedule', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departed', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { + key: 'actualDeparture', + label: 'Departed', + type: 'date', + sortable: true, + sortExpr: 'ts.actual_departure_at', + }, + { key: 'actualArrival', label: 'Arrived', type: 'date' }, + { key: 'transitHours', label: 'Transit (hrs)', type: 'number', sortable: true }, + ], + defaultSort: { key: 'actualDeparture', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect("COALESCE(o.label, 'Unknown')", 'origin') + .addSelect("COALESCE(d.label, 'Unknown')", 'destination') + .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture') + .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival') + .addSelect( + `ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric / 3600, 1)::float8`, + 'transitHours', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'trips') + .addSelect( + `ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at)))::numeric / 3600, 1)::float8`, + 'avgHours', + ) + .getRawOne(); + return [ + { label: 'Trips', value: Number(row?.trips ?? 0) }, + { label: 'Avg transit', value: Number(row?.avgHours ?? 0), unit: 'h' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts new file mode 100644 index 000000000..7dabec37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts @@ -0,0 +1,76 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to +// each wagon's CURRENT schedule pin — a live-state view, not a historical one. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Wagon, 'w') + .innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id') + .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') + .leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL') + .leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id') + .where('w.deleted_at IS NULL'); + + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` }); + } + if (params.dateFrom) { + qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); + } + if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + return qb; +} + +export const wagonTeuUtilizationReport: ReportDefinition = { + key: 'wagon-teu-utilization', + title: 'Wagon TEU Utilization', + description: 'TEU loaded per wagon on its currently assigned train', + group: 'Operations', + filters: [ + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'date', label: 'Departure', type: 'daterange' }, + ], + columns: [ + { key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'departureDate', label: 'Departure', type: 'date' }, + { key: 'containers', label: 'Containers', type: 'number', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + ], + defaultSort: { key: 'teu', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('w.wagon_number', 'wagonNumber') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect('ts.train_number', 'trainNumber') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') + .addSelect('COUNT(c.id)::int', 'containers') + .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') + .groupBy('w.wagon_number') + .addGroupBy('wt.name') + .addGroupBy('ts.train_number') + .addGroupBy('ts.scheduled_departure_date'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(DISTINCT w.id)::int', 'wagons') + .addSelect('COUNT(c.id)::int', 'containers') + .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') + .getRawOne(); + return [ + { label: 'Wagons', value: Number(row?.wagons ?? 0) }, + { label: 'Containers', value: Number(row?.containers ?? 0) }, + { label: 'Total TEU', value: Number(row?.teu ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index dfc6ff002..0d9b1d3a2 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -7,6 +7,12 @@ import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report' import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; import { wagonRequestsReport } from './definitions/wagon-requests.report'; import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; +import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; +import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; +import { trainTurnaroundReport } from './definitions/train-turnaround.report'; +import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; +import { loadedCapacityReport } from './definitions/loaded-capacity.report'; +import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; import { ReportDefinition } from './report.types'; /** @@ -23,6 +29,12 @@ export const REPORTS: ReportDefinition[] = [ wagonStatusDurationReport, wagonRequestsReport, locomotiveFleetStatusReport, + bookingStatusBreakdownReport, + trainScheduleStatusReport, + trainTurnaroundReport, + wagonTeuUtilizationReport, + loadedCapacityReport, + globalLogisticsWagonsReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index f9a0f959f..9cddfde87 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -63,6 +63,12 @@ export const REPORT_KEYS = [ "wagon-status-duration", "wagon-requests", "locomotive-fleet-status", + "booking-status-breakdown", + "train-schedule-status", + "train-turnaround", + "wagon-teu-utilization", + "loaded-capacity", + "global-logistics-wagons", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; From 29913259f60da2b3fe615303ea49655da65f7517 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:23:19 +0000 Subject: [PATCH 13/28] feat(freight-api): add 9 commercial/finance reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customer-status (company-profile roles, not Company — importer/ exporter/forwarder lives there), contract-lifecycle, customs-documents (clearance milestones), invoicing-pipeline, first-last-mile-bookings (one resolver, UNION ALL over first_mile/last_mile — verified the raw-string .from() subquery against the live query builder, not just hand-written SQL, after the join-alias bug earlier this branch), invoices-by-status, payments-by-status, revenue-summary, cargo-summary. payments carries no deleted_at column despite extending BaseEntity — caught by column-checking against the live DB before shipping, dropped the soft-delete filter for that one query. Completes the ITLMS dashboard spec's 20-resolver dedup list (19 built, freight-weight-variance dropped — no charged-vs-actual weight distinction in the schema). --- .../definitions/cargo-summary.report.ts | 58 ++++++++++++ .../definitions/contract-lifecycle.report.ts | 83 +++++++++++++++++ .../definitions/customer-status.report.ts | 67 ++++++++++++++ .../definitions/customs-documents.report.ts | 66 ++++++++++++++ .../first-last-mile-bookings.report.ts | 90 +++++++++++++++++++ .../definitions/invoices-by-status.report.ts | 72 +++++++++++++++ .../definitions/invoicing-pipeline.report.ts | 59 ++++++++++++ .../definitions/payments-by-status.report.ts | 73 +++++++++++++++ .../definitions/revenue-summary.report.ts | 61 +++++++++++++ .../src/modules/reports/report.registry.ts | 18 ++++ .../src/seed/freight-permissions.registry.ts | 9 ++ 11 files changed, 656 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts new file mode 100644 index 000000000..ee3063ee1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -0,0 +1,58 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const cargoSummaryReport: ReportDefinition = { + key: 'cargo-summary', + title: 'Cargo Summary', + description: 'Cargo tonnage by direction and cargo type', + group: 'Operations', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + ], + defaultSort: { key: 'tons', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts new file mode 100644 index 000000000..513abeda4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts @@ -0,0 +1,83 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Contract, 'ct') + .leftJoin(Company, 'c', 'c.id = ct.company_id') + .where('ct.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo }); + if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind }); + if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (directions !== null) { + qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const contractLifecycleReport: ReportDefinition = { + key: 'contract-lifecycle', + title: 'Contracts', + description: 'Signed, active and cancelled contracts', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Valid from', type: 'daterange' }, + { key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'kind', label: 'Kind', type: 'string' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'freightType', label: 'Freight type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' }, + { key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' }, + { key: 'validUntil', label: 'Valid until', type: 'date' }, + { key: 'signedAt', label: 'Signed', type: 'date' }, + ], + defaultSort: { key: 'validFrom', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ct.reference', 'reference') + .addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer') + .addSelect('ct.contract_kind', 'kind') + .addSelect('ct.trade_direction', 'direction') + .addSelect('ct.freight_type', 'freightType') + .addSelect('ct.status', 'status') + .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') + .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') + .addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed') + .addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled') + .getRawOne(); + return [ + { label: 'Contracts', value: Number(row?.total ?? 0) }, + { label: 'Signed', value: Number(row?.signed ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts new file mode 100644 index 000000000..60b6ff31a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts @@ -0,0 +1,67 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are +// CompanyProfile fields, not Company's — a company can hold several profiles +// (e.g. importer AND exporter), each independently approved/suspended. +const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(CompanyProfile, 'cp') + .innerJoin(Company, 'c', 'c.id = cp.company_id') + .where('cp.deleted_at IS NULL'); + + if (params.type) qb.andWhere('cp.type = :type', { type: params.type }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customerStatusReport: ReportDefinition = { + key: 'customer-status', + title: 'Customer Profiles', + description: 'Company profiles by role type and approval status', + group: 'Commercial', + filters: [ + { key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' }, + { key: 'reference', label: 'Reference', type: 'string' }, + { key: 'note', label: 'Note', type: 'string' }, + { key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' }, + ], + defaultSort: { key: 'reviewedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('c.name', 'company') + .addSelect('cp.type', 'type') + .addSelect('cp.status', 'status') + .addSelect("COALESCE(cp.reference, '')", 'reference') + .addSelect("COALESCE(cp.review_note, '')", 'note') + .addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended') + .setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended }) + .getRawOne(); + return [ + { label: 'Profiles', value: Number(row?.total ?? 0) }, + { label: 'Active', value: Number(row?.active ?? 0) }, + { label: 'Suspended', value: Number(row?.suspended ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts new file mode 100644 index 000000000..a0bcb32fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts @@ -0,0 +1,66 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { + ClearanceMilestone, + MILESTONE_OWNER_REGIONS, + MILESTONE_STATUSES, +} from '../../contracts/entities/clearance-milestone.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds.createQueryBuilder().from(ClearanceMilestone, 'cm').where('cm.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('cm.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('cm.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.ownerRegion) qb.andWhere('cm.owner_region = :ownerRegion', { ownerRegion: params.ownerRegion }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cm.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customsDocumentsReport: ReportDefinition = { + key: 'customs-documents', + title: 'Customs Clearance Milestones', + description: 'Clearance milestone volume by label, owner and status', + group: 'Operations', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'ownerRegion', + label: 'Owner', + type: 'select', + options: MILESTONE_OWNER_REGIONS.map((v) => ({ value: v, label: v })), + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: MILESTONE_STATUSES.map((v) => ({ value: v, label: v })) }, + ], + columns: [ + { key: 'milestone', label: 'Milestone', type: 'string', sortable: true }, + { key: 'ownerRegion', label: 'Owner', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('cm.milestone_label', 'milestone') + .addSelect("COALESCE(cm.owner_region, 'Unassigned')", 'ownerRegion') + .addSelect('cm.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('cm.milestone_label') + .addGroupBy('cm.owner_region') + .addGroupBy('cm.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect("COUNT(*) FILTER (WHERE cm.status = 'COMPLETED')::int", 'completed') + .addSelect("COUNT(*) FILTER (WHERE cm.status = 'PENDING')::int", 'pending') + .getRawOne(); + return [ + { label: 'Milestones', value: Number(row?.total ?? 0) }, + { label: 'Completed', value: Number(row?.completed ?? 0) }, + { label: 'Pending', value: Number(row?.pending ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts new file mode 100644 index 000000000..d3abe10dc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts @@ -0,0 +1,90 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// FirstMile and LastMile are separate tables with an identical shape (status, +// booking, optional vehicle). One resolver, unioned, with a `leg` column — +// beats shipping two near-duplicate reports for the two halves of the trip. +const LEG_UNION = `( + SELECT 'FIRST' AS leg, fm.id AS id, fm.booking_id AS booking_id, fm.status AS status, + fm.vehicle_id AS vehicle_id, fm.created_at AS created_at + FROM freight.first_mile fm WHERE fm.deleted_at IS NULL + UNION ALL + SELECT 'LAST' AS leg, lm.id AS id, lm.booking_id AS booking_id, lm.status AS status, + lm.vehicle_id AS vehicle_id, lm.created_at AS created_at + FROM freight.last_mile lm WHERE lm.deleted_at IS NULL +)`; + +const STATUS_OPTIONS = [ + { value: 'PAYMENT_PENDING', label: 'Payment pending' }, + { value: 'READY_TO_TRANSIT', label: 'Ready to transit' }, + { value: 'IN_TRANSIT', label: 'In transit' }, + { value: 'RECEIVED_TO_PORT', label: 'Received to port' }, + { value: 'DELIVERED', label: 'Delivered' }, +]; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(LEG_UNION, 'fl') + .innerJoin(Booking, 'b', 'b.id = fl.booking_id') + .leftJoin(Company, 'c', 'c.id = b.company_id') + .leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id') + .where('1 = 1'); + + if (params.leg) qb.andWhere('fl.leg = :leg', { leg: params.leg }); + if (params.dateFrom) qb.andWhere('fl.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses }); + return qb; +} + +export const firstLastMileBookingsReport: ReportDefinition = { + key: 'first-last-mile-bookings', + title: 'First/Last Mile Trucking', + description: 'First- and last-mile bookings by status and truck assignment', + group: 'Operations', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'leg', label: 'Leg', type: 'select', options: [{ value: 'FIRST', label: 'First mile' }, { value: 'LAST', label: 'Last mile' }] }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'leg', label: 'Leg', type: 'string', sortable: true }, + { key: 'booking', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'fl.status' }, + { key: 'truck', label: 'Truck', type: 'string' }, + { key: 'assigned', label: 'Assigned', type: 'string', sortable: true }, + { key: 'createdAt', label: 'Created', type: 'date', sortable: true, sortExpr: 'fl.created_at' }, + ], + defaultSort: { key: 'createdAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('fl.leg', 'leg') + .addSelect('b.reference', 'booking') + .addSelect("COALESCE(c.name, 'Unknown')", 'customer') + .addSelect('fl.status', 'status') + .addSelect("COALESCE(v.plate_number, '—')", 'truck') + .addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned') + .addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE fl.vehicle_id IS NOT NULL)::int', 'assigned') + .getRawOne(); + const total = Number(row?.total ?? 0); + const assigned = Number(row?.assigned ?? 0); + return [ + { label: 'Trips', value: total }, + { label: 'Assigned', value: assigned }, + { label: 'Unassigned', value: total - assigned }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts new file mode 100644 index 000000000..81b183f90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts @@ -0,0 +1,72 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Freight } from '@edr/types'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .innerJoin(Company, 'c', 'c.id = i.company_id') + .leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id') + .where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicesByStatusReport: ReportDefinition = { + key: 'invoices-by-status', + title: 'Invoices', + description: 'Every invoice with customer, profile type and settlement status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Issued', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'profileType', label: 'Profile', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'paidAmount', label: 'Paid', type: 'money' }, + { key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true }, + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' }, + { key: 'dueAt', label: 'Due', type: 'date' }, + ], + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.invoice_number', 'invoiceNumber') + .addSelect('c.name', 'customer') + .addSelect("COALESCE(cp.type, 'Unknown')", 'profileType') + .addSelect('i.status', 'status') + .addSelect('ROUND(i.total_amount)::float8', 'totalAmount') + .addSelect('ROUND(i.paid_amount)::float8', 'paidAmount') + .addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount') + .addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts new file mode 100644 index 000000000..8907f4f5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts @@ -0,0 +1,59 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Freight } from '@edr/types'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicingPipelineReport: ReportDefinition = { + key: 'invoicing-pipeline', + title: 'Invoicing Pipeline', + description: 'Invoice volume and value by type and status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'type', label: 'Type', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'balance', label: 'Outstanding', type: 'money', sortable: true }, + ], + defaultSort: { key: 'invoices', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.type', 'type') + .addSelect('i.status', 'status') + .addSelect('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .groupBy('i.type') + .addGroupBy('i.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts new file mode 100644 index 000000000..15e99a4b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts @@ -0,0 +1,73 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// No direct company link on payments (refId points at whatever the intent was +// for — booking, demurrage, ...); breakdown stops at status/method/currency. +const STATUS_OPTIONS = [ + { value: 'action-required', label: 'Action required' }, + { value: 'processing', label: 'Processing' }, + { value: 'success', label: 'Success' }, + { value: 'failed', label: 'Failed' }, + { value: 'canceled', label: 'Canceled' }, + { value: 'refunded', label: 'Refunded' }, +]; +const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map( + (v) => ({ value: v, label: v }), +); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + // payments carries no deleted_at column (unlike the rest of the schema) — + // confirmed against the live DB, not assumed from BaseEntity. + const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1'); + + if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.method) qb.andWhere('p.method = :method', { method: params.method }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses }); + return qb; +} + +export const paymentsByStatusReport: ReportDefinition = { + key: 'payments-by-status', + title: 'Payments by Status', + description: 'Payment volume and value by status, method and currency', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'method', label: 'Method', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'payments', label: 'Payments', type: 'number', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'amount', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('p.status', 'status') + .addSelect('p.method', 'method') + .addSelect('p.currency', 'currency') + .addSelect('COUNT(*)::int', 'payments') + .addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount') + .groupBy('p.status') + .addGroupBy('p.method') + .addGroupBy('p.currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'payments') + .addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid') + .getRawOne(); + return [ + { label: 'Payments', value: Number(row?.payments ?? 0) }, + { label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts new file mode 100644 index 000000000..684d4e2e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts @@ -0,0 +1,61 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const revenueSummaryReport: ReportDefinition = { + key: 'revenue-summary', + title: 'Revenue Summary', + description: 'Booking revenue by direction, cargo type and currency', + group: 'Finance', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('b.payment_currency', 'currency') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type') + .addGroupBy('b.payment_currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 0d9b1d3a2..004b61e5b 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -13,6 +13,15 @@ import { trainTurnaroundReport } from './definitions/train-turnaround.report'; import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; import { loadedCapacityReport } from './definitions/loaded-capacity.report'; import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; +import { customerStatusReport } from './definitions/customer-status.report'; +import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; +import { customsDocumentsReport } from './definitions/customs-documents.report'; +import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; +import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; +import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; +import { paymentsByStatusReport } from './definitions/payments-by-status.report'; +import { revenueSummaryReport } from './definitions/revenue-summary.report'; +import { cargoSummaryReport } from './definitions/cargo-summary.report'; import { ReportDefinition } from './report.types'; /** @@ -35,6 +44,15 @@ export const REPORTS: ReportDefinition[] = [ wagonTeuUtilizationReport, loadedCapacityReport, globalLogisticsWagonsReport, + customerStatusReport, + contractLifecycleReport, + customsDocumentsReport, + invoicingPipelineReport, + firstLastMileBookingsReport, + invoicesByStatusReport, + paymentsByStatusReport, + revenueSummaryReport, + cargoSummaryReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9cddfde87..e98127042 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -69,6 +69,15 @@ export const REPORT_KEYS = [ "wagon-teu-utilization", "loaded-capacity", "global-logistics-wagons", + "customer-status", + "contract-lifecycle", + "customs-documents", + "invoicing-pipeline", + "first-last-mile-bookings", + "invoices-by-status", + "payments-by-status", + "revenue-summary", + "cargo-summary", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; From 833e62990e429b2ac4a79e05b746fffe88d1ace7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 08:22:51 +0000 Subject: [PATCH 14/28] feat(train-scheduling): add wagon type, tare, equated length, station, seal no and note columns to import marshalling doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Load List / Marshalling Document only rendered Seq, Wagon, Booking, Company, Load, Container numbers, Weight T — missing fields present on the physical marshaling sheet (wagon type, tare, equated length, departure/arrival station, seal no) and a blank note column for yard staff. Export marshalling doc already had most of these; import doc now matches. Existing columns kept in place, unchanged. --- .../last-mile-requests.controller.ts | 13 +++ .../last-mile-requests.service.ts | 22 +++++ .../services/train-scheduling.service.ts | 27 +++++- .../detail/BookingMileServicesCard.tsx | 60 +++++++++++- .../portal/src/constants/URLS.ts | 1 + .../components/MileSummaryCard.tsx | 97 +++++++++++++++++-- .../services/last-mile-requests.service.ts | 7 ++ 7 files changed, 215 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 3d9fa4254..ff52ff9d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -48,6 +48,19 @@ export class LastMileRequestsController { return this.requestsService.freeTruckCount().then((count) => ({ count })); } + // Customer-facing like :id — booking detail (portal + backoffice) lists the + // booking's requests to link the stored LM contract. Ownership-checked in + // the service for portal callers. + @Get('by-booking/:bookingId') + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" }) + findForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.findForBooking(bookingId, user?.id ?? null); + } + @Get(':id/price-estimate') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index ebdc93746..37612d318 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -221,6 +221,28 @@ export class LastMileRequestsService { return record; } + /** + * Every request on a booking, newest first — the booking-detail pages + * (portal + backoffice) use this to surface the LM contract later. Portal + * callers pass their userId and are ownership-checked against the booking's + * company, mirroring findById. + */ + async findForBooking(bookingId: string, userId?: string | null): Promise { + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId) { + const booking = await this.bookingsRepository.findById(bookingId); + if (booking?.companyId && booking.companyId !== companyId) { + throw new BadRequestException('This booking does not belong to your company'); + } + } + } + return this.requestsRepository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + /** * Rule-based price estimate for the approval dialog: estimated km (yard GPS → * delivery point, straight-line) × the LIVE last-mile rate rules against the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index fc9395ffb..6c89c6980 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3002,6 +3002,9 @@ export class TrainSchedulingService { .map((wagon) => ({ sequenceNo: wagon.sequenceNo, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, + tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, + equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -3501,26 +3504,37 @@ export class TrainSchedulingService { const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)}`; + ${esc(wagon.wagonNumber)} + ${esc(wagon.wagonType)} + ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} + ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))} + ${esc(loadList.origin)} + ${esc(loadList.destination)}`; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return wagon.allocations.map( (allocation) => { const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + const sealNumbers = (allocation.containerItems ?? []) + .map((item) => item.sealNumber) + .filter(Boolean) + .join(', '); return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(sealNumbers || '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -3609,15 +3623,22 @@ export class TrainSchedulingService { Seq Wagon + Wagon Type + Tare + Equated + Departure Station + Arrival Station Booking Company Load Container numbers + Seal No + Note Weight T - ${allocationRows || 'No wagons on this train set.'} + ${allocationRows || 'No wagons on this train set.'} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 3236e5cae..0a079bb4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -1,6 +1,9 @@ -import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import { Download, Truck } from "lucide-react"; +import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./SectionCard"; @@ -10,12 +13,37 @@ export interface BookingMileServicesCardProps { booking: BookingDetail; } -/** First / last mile addresses. Renders nothing when neither is present. */ +/** + * First / last mile addresses, plus the stored last-mile contract reference + * (signed status + PDF download) for Truck & Machinery once a request on this + * booking is approved. Renders nothing when neither address is present. + */ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { + const { data: requestsResponse } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }), + queryFn: async () => + (await lastMileRequestsService.list({ bookingId: booking.id })).data, + enabled: Boolean(booking.lastMileDeliveryAddress), + }); + const approvedRequest = (requestsResponse?.data ?? []).find( + (r) => r.status === "APPROVED", + ); + if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { return null; } + const downloadContract = async () => { + if (!approvedRequest) return; + const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( @@ -26,6 +54,32 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp )} + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + + )} ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a0d33f5ba..c9576d192 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -224,6 +224,7 @@ export const URL_CONSTANTS = { }, LAST_MILE_REQUESTS: { + BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`, BY_ID: (id: string) => `/api/last-mile-requests/${id}`, SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx index bfb57529f..6619dbd06 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -1,5 +1,6 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -8,6 +9,7 @@ import type { MileLegSummary, MileVehicleSummary, } from "@/services/bookings.service"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import { CardTitle, SectionCard } from "./layout"; @@ -171,12 +173,85 @@ function LegBlock({ ); } +/** + * Reference row for the stored last-mile contract: signed status, open the + * contract page (view / sign), download the PDF. + */ +function LastMileContractRow({ + bookingId, + requestId, + signedAt, + signerDisplayName, +}: { + bookingId: string; + requestId: string; + signedAt?: string | null; + signerDisplayName?: string | null; +}) { + const navigate = useNavigate(); + const download = async () => { + const blob = await lastMileRequestsService.downloadContractDocument(requestId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "last-mile-contract.pdf"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + Last-mile contract + + + {signedAt + ? `Signed ${new Date(signedAt).toLocaleDateString()}${ + signerDisplayName ? ` by ${signerDisplayName}` : "" + }` + : "Awaiting your signature"} + + + + + + + + ); +} + export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { const { data } = useQuery({ queryKey: ["booking-mile-summary", booking.id], queryFn: () => bookingsService.mileSummary(booking.id), }); + // The stored LM contract lives on the booking's approved last-mile request. + const { data: lmRequests } = useQuery({ + queryKey: ["booking-last-mile-requests", booking.id], + queryFn: () => lastMileRequestsService.listForBooking(booking.id), + enabled: !!booking.lastMileDeliveryAddress, + }); + const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED"); + const firstLeg = data?.firstMile ?? null; const lastLeg = data?.lastMile ?? null; @@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { /> )} {showLast && ( - + + + {approvedRequest && ( + + )} + )} diff --git a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts index 5069eafe3..08133ba08 100644 --- a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts @@ -12,6 +12,7 @@ export interface LastMileRequest { requestedContainerNumbers?: string[] | null; requestedDeliveryDate?: string | null; customerSignedAt?: string | null; + signerDisplayName?: string | null; rejectionReason?: string | null; createdAt: string; updatedAt: string; @@ -46,6 +47,12 @@ export const lastMileRequestsService = { return data.data ?? data; }, + /** The booking's requests, newest first — links the stored LM contract. */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(L.BY_BOOKING(bookingId)); + return data.data ?? data; + }, + /** Confirm which containers go via EDR last-mile and the requested delivery date. */ submit: async ( id: string, From 6f14cf8bbf9141542ed71c9aa5baf8b92bf47860 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:33:59 +0000 Subject: [PATCH 15/28] feat(freight-backoffice): add DateRangePicker, replace ad-hoc from/to date filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shadcn Popover+Calendar range picker with presets (Today, Last 7/30 days, this/last month, YTD) and Apply/Cancel. Swaps it into every shadcn-family from-to date filter: activity log, sector reports, incoming records (EN branch), CombinedFilterBar (13 consumers), user records, audit log — also fixes an uncontrolled-input bug on the audit log date fields. Mantine-based ListControls/ReportFilters left untouched (different UI kit). --- .../backoffice/src/pages/AuditLog.tsx | 22 +- .../sectorReports/SectorReportFilters.tsx | 32 +-- .../externalIncomingRecordsV2.tsx | 25 +-- .../internalIncomingRecordsV2.tsx | 25 +-- .../components/userRecords/userRecords.tsx | 31 +-- .../shared/common/ui/date-range-picker.tsx | 197 ++++++++++++++++++ .../activity-log/activity-filters.tsx | 103 +-------- .../components/filters/CombinedFilterBar.tsx | 31 +-- 8 files changed, 252 insertions(+), 214 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/shared/common/ui/date-range-picker.tsx diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx index 5374bcee7..1f2c9461e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import { Input } from "@/shared/common/ui/input"; +import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker"; import { Select, SelectTrigger, @@ -134,21 +135,12 @@ const buildQuery = (): CollectionQueryDTO => { className="w-64" /> -
- - setDateRange({ ...dateRange, start: e.target.value }) - } - /> - to - - setDateRange({ ...dateRange, end: e.target.value }) - } - /> -
+ + setDateRange({ start: formatDay(range.from), end: formatDay(range.to) }) + } + /> -