From 018505d6fa6b27e319f7299bf9e973770519b51a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:02:27 +0000 Subject: [PATCH 01/13] feat(billing): filter, show and export invoice payment method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settled method is split across two stores: a gateway settlement records the real provider on the linked freight.payments row (cbe-bill, telebirr) while the invoice's own payments ledger only writes a flat "GATEWAY"; a manual settlement has no payments row at all and the ledger is the only source (BANK_TRANSFER, OFFLINE, or whatever PayInvoiceDto.method carried). invoicePaymentMethodExpr folds both into one UPPER_SNAKE vocabulary — provider first, newest ledger entry as the fallback — and the list filter, the export field and the export filter all use that same expression, so the screen and the file can never disagree. The paymentMethods param is deliberately not validated against a fixed list: the manual pay endpoint takes a free-form method, so an IsIn would silently drop real values. --- .../src/modules/billing/billing.service.ts | 28 ++++++- .../modules/billing/dto/filter-invoice.dto.ts | 14 ++++ .../billing/invoice-settlement.util.ts | 40 ++++++++++ .../exports/datasets/invoices.dataset.ts | 18 ++++- .../src/pages/invoices/InvoiceDetailPage.tsx | 55 +++++++++++++- .../src/pages/invoices/InvoicesPage.tsx | 32 +++++++- .../backoffice/src/types/invoice.ts | 76 +++++++++++++++++++ 7 files changed, 256 insertions(+), 7 deletions(-) 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 fbadc77c0..eab221505 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -35,7 +35,11 @@ import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLineRepository } from "./invoice-line.repository"; import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; -import { applySettlement, round2 } from "./invoice-settlement.util"; +import { + applySettlement, + invoicePaymentMethodExpr, + round2, +} from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ @@ -109,6 +113,8 @@ export interface InvoiceListFilters { statuses?: Freight.InvoiceStatus[]; sources?: string[]; eimsStatuses?: string[]; + /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ + paymentMethods?: string[]; currency?: string; search?: string; issuedFrom?: string; @@ -123,6 +129,13 @@ export interface InvoiceListFilters { tradeDirections?: string[]; } +/** + * The list/summary query builders both alias the invoice as `invoice` and the + * joined gateway payment as `payment`; TypeORM rewrites those alias.property + * references into real quoted columns. + */ +const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment"); + const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ @@ -292,6 +305,13 @@ export class BillingService { eimsStatuses: filter.eimsStatuses, }); } + if (filter.paymentMethods?.length) { + // Requires the `payment` alias to be joined by the caller — both call + // sites do, unconditionally, so this can never reference a missing alias. + qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, { + paymentMethods: filter.paymentMethods, + }); + } if (filter.currency) { // Stored casing has drifted ("usd" rows exist) — compare normalised. qb.andWhere("UPPER(invoice.currency) = :currency", { @@ -386,6 +406,9 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") + // The gateway payment behind the invoice: the settled method and the + // provider's transaction reference both live on it, and nowhere else. + .leftJoinAndSelect("invoice.payment", "payment") // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated // raw. The id tiebreaker keeps paging stable when the sort column ties // (issuedAt is null on every DRAFT row). @@ -542,6 +565,7 @@ export class BillingService { // Joined, not selected: `applyInvoiceFilters` searches the customer name, // so the alias has to exist even though the summary only sums money. .leftJoin("invoice.company", "company") + .leftJoin("invoice.payment", "payment") .select("invoice.currency", "currency") .addSelect("SUM(invoice.paidAmount)", "collected") .groupBy("invoice.currency"); @@ -748,7 +772,7 @@ export class BillingService { /** Invoice header plus its line items. */ async findById(id: string): Promise { const invoice = await this.invoices.findById(id, { - relations: { company: true, companyProfile: true }, + relations: { company: true, companyProfile: true, payment: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const [hydrated] = await this.attachShippingLineCompanies([invoice]); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index aec6e4ac0..a98ad06c1 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -15,6 +15,7 @@ import { } from "class-validator"; import { EimsInvoiceStatus } from "../../eims/eims-registration.types"; +import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util"; /** Columns the invoice list may be ordered by -> their query-builder expression. */ export const INVOICE_SORT_COLUMNS: Record = { @@ -96,6 +97,19 @@ export class FilterInvoiceDto { @IsIn(Object.values(EimsInvoiceStatus), { each: true }) eimsStatuses?: EimsInvoiceStatus[]; + /** + * Settled payment method (`?paymentMethods=CBE_BILL,BANK_TRANSFER`). Values are + * the normalised UPPER_SNAKE vocabulary of `invoicePaymentMethodExpr`. Not + * validated against a fixed list — the manual pay endpoint takes a free-form + * method, so an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, enum: INVOICE_PAYMENT_METHODS }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + paymentMethods?: string[]; + /** Manual-payments worklist and the invoice list: restrict to one currency. */ @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index ab1e27b1a..172e74c17 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -34,3 +34,43 @@ export function applySettlement( const balanceAmount = Math.max(0, round2(total - paidAmount)); return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; } + +/** + * SQL for an invoice's settled payment method, normalised to one vocabulary. + * + * Two sources have to be merged: gateway settlements carry the real provider on + * the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the + * invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual + * settlements have no payments row at all and the ledger is the ONLY source + * (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried). + * So: provider first, newest ledger entry as the fallback. + * + * `-> -1` is the last ledger element — the ledger is appended newest-last. + * `::text` is not cosmetic: `payments.method` is a real Postgres enum, and + * COALESCE against a text fallback fails without the cast. + * + * Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one + * value on screen, in the filter and in the export. + */ +export const invoicePaymentMethodExpr = (invoice: string, payment: string): string => + `UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`; + +/** + * The methods the filter offers. Not exhaustive by construction — the manual + * pay endpoint takes a free-form `method` string — so nothing validates against + * this list; it is the pick-list, not a constraint. + */ +export const INVOICE_PAYMENT_METHODS = [ + "TELEBIRR", + "CBE_BIRR", + "CBE_BILL", + "EBIRR", + "WAAFI", + "CARD", + "DMONEY", + "CAC_BANK", + "BANK_TRANSFER", + "OFFLINE", + /** Settled at a gateway whose provider row is no longer linked. */ + "GATEWAY", +] as const; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 67f2207e9..3b897ef2d 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -1,11 +1,16 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Invoice } from '../../billing/entities/invoice.entity'; +import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util'; +import { PaymentEntity } from '../../payment/entities/payment.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ExportDataset } from '../export.types'; +/** Same expression the list endpoint filters by, in this dataset's aliases. */ +const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p'); + /** * Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a * signature blob) and `eims_last_error` (a raw error dump). The @@ -26,8 +31,11 @@ export const invoicesDataset: ExportDataset = { // with a second query. In a dataset it is just a join by column. { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' }, { alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' }, + // The gateway payment behind the invoice — provider method and its + // transaction reference. Always joined: `scope()` filters on it. + { alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' }, ], - alwaysJoin: ['c'], + alwaysJoin: ['c', 'p'], groups: [ { id: 'invoice', label: 'Invoice' }, @@ -66,6 +74,9 @@ export const invoicesDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' }, { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD }, + { key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' }, + { key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' }, { key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now() @@ -104,6 +115,7 @@ export const invoicesDataset: ExportDataset = { { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, + { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, @@ -132,6 +144,10 @@ export const invoicesDataset: ExportDataset = { if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); + const paymentMethods = params.paymentMethods as string[] | null; + if (paymentMethods?.length) { + qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods }); + } // Casing has drifted in the data ("usd" rows exist) — normalise both sides, // same as the list endpoint does. if (params.currency) { diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 23a974856..0fa911485 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -15,7 +15,14 @@ import { Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react"; +import { + ArrowLeft, + Building2, + CreditCard, + Download, + FileText, + Printer, +} from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; @@ -34,7 +41,11 @@ import { LinkedEntityCard, type FieldRowProps } from "@/components/detail"; import { useBookingDetail } from "@/hooks/bookings/useBookings"; import { api } from "@/services/api"; import { invoicesService } from "@/services/invoices.service"; -import type { Invoice } from "@/types/invoice"; +import { + invoicePaymentMethod, + paymentMethodLabel, + type Invoice, +} from "@/types/invoice"; function openPdfBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); @@ -124,6 +135,45 @@ function RecipientCard({ invoice }: { invoice: Invoice }) { ); } +/** + * How the invoice was settled. The method and the provider's transaction + * reference live on the linked gateway payment for an online settlement, and in + * the invoice's own `payments` ledger for a manual one — this reads whichever + * exists, same precedence the API filters by. Not rendered at all until + * something has been settled. + */ +function PaymentCard({ invoice }: { invoice: Invoice }) { + const method = invoicePaymentMethod(invoice); + const lastEntry = invoice.payments?.at(-1); + if (!method && !lastEntry) return null; + + // The gateway's own reference first; `merchantOrderId` is our order id, which + // is still the number a provider support desk can trace. The ledger reference + // is what a manual settlement carries (bank slip / transfer reference). + const transactionRef = + invoice.payment?.transactionId ?? + invoice.payment?.merchantOrderId ?? + lastEntry?.reference ?? + undefined; + + return ( + + ); +} + /** What the invoice was raised for — a booking's route/wagons when the * source is a booking; otherwise just the source type and its raw id * (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */ @@ -393,6 +443,7 @@ export default function InvoiceDetailPage() { + diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 8026f692b..5361558fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -18,7 +18,13 @@ import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActio import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; -import type { Invoice, InvoiceListFilter } from "@/types/invoice"; +import { + PAYMENT_METHOD_OPTIONS, + invoicePaymentMethod, + paymentMethodLabel, + type Invoice, + type InvoiceListFilter, +} from "@/types/invoice"; import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({ @@ -74,6 +80,12 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [ toParams: (v) => v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" }, }, + { + key: "paymentMethods", + label: "Payment method", + type: "enum", + options: PAYMENT_METHOD_OPTIONS, + }, { key: "issued", label: "Issued", @@ -274,6 +286,22 @@ export default function InvoicesPanel() { ), }, + { + id: "paymentMethod", + header: "Method", + cell: ({ row }) => { + const method = invoicePaymentMethod(row.original); + return method ? ( + + {paymentMethodLabel(method)} + + ) : ( + + — + + ); + }, + }, { id: "dueAt", header: "Due", @@ -361,7 +389,7 @@ export default function InvoicesPanel() { - + ; + +/** + * The payment methods the invoice list filters by. Normalised UPPER_SNAKE, the + * one vocabulary the API's `invoicePaymentMethodExpr` folds both sources into: + * the gateway's own provider slugs (`cbe-bill` → CBE_BILL) and the flat labels + * a manual settlement writes to the ledger. + * + * Not exhaustive — the manual pay endpoint takes a free-form method — so treat + * an unknown value as displayable, never as invalid. + */ +export const PAYMENT_METHOD_OPTIONS: { value: string; label: string }[] = [ + { value: "TELEBIRR", label: "Telebirr" }, + { value: "CBE_BIRR", label: "CBE Birr" }, + { value: "CBE_BILL", label: "CBE Bill" }, + { value: "EBIRR", label: "E-Birr" }, + { value: "WAAFI", label: "Waafi" }, + { value: "CARD", label: "Card" }, + { value: "DMONEY", label: "D-Money" }, + { value: "CAC_BANK", label: "CAC Bank" }, + { value: "BANK_TRANSFER", label: "Bank transfer" }, + { value: "OFFLINE", label: "Offline" }, + { value: "GATEWAY", label: "Gateway" }, +]; + +const PAYMENT_METHOD_LABELS = new Map( + PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]), +); + +/** Normalise a raw method from either source to the vocabulary above. */ +export const normalizePaymentMethod = (raw?: string | null): string | null => + raw ? raw.toUpperCase().replace(/-/g, "_") : null; + +/** + * An invoice's settled method, resolved the same way the API does: the gateway + * provider first, the newest ledger entry as the fallback. Null while unpaid. + */ +export function invoicePaymentMethod(invoice: Invoice): string | null { + const ledger = invoice.payments?.at(-1)?.method; + return normalizePaymentMethod(invoice.payment?.method ?? ledger); +} + +/** Human label for a method value; unknown values are shown as-is. */ +export const paymentMethodLabel = (method: string): string => + PAYMENT_METHOD_LABELS.get(method) ?? method; From 3da00e1f069e44f9d8086978a19daf48b27f4a63 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:02:46 +0000 Subject: [PATCH 02/13] feat(billing): show the booking PNR on an invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PNR is the CBE_BILL reference the customer actually pays against, but it is stamped onto the booking at payment-initiation time — it is a column on neither the invoice nor the payment. Both surfaces read it back by source id, the same lookup the sealed invoice PDF already did, so screen, export and document now agree. The export's join casts bk.id::text rather than i.source_id::uuid: source_id is a bare varchar pointer that is not always a UUID (EIMS self-test rows carry a slug), and casting that direction throws on those rows. --- .../src/modules/exports/datasets/invoices.dataset.ts | 9 +++++++++ .../backoffice/src/pages/invoices/InvoiceDetailPage.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 3b897ef2d..59d6db4ca 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -2,6 +2,7 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Invoice } from '../../billing/entities/invoice.entity'; import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util'; import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; @@ -34,6 +35,11 @@ export const invoicesDataset: ExportDataset = { // The gateway payment behind the invoice — provider method and its // transaction reference. Always joined: `scope()` filters on it. { alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' }, + // Booking behind the invoice, for the PNR alone. `i.source_id` is a bare + // varchar pointer that is not always a UUID (EIMS self-test rows carry a + // slug), so the cast goes on `bk.id`, never on `source_id` — casting the + // other way throws on those rows. + { alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" }, ], alwaysJoin: ['c', 'p'], @@ -76,6 +82,9 @@ export const invoicesDataset: ExportDataset = { { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, { key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD }, { key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' }, + // The CBE_BILL reference the customer pays against — stamped onto the + // booking at payment-initiation time, not held on the invoice or payment. + { key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' }, { key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' }, { key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 0fa911485..def389687 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -145,6 +145,14 @@ function RecipientCard({ invoice }: { invoice: Invoice }) { function PaymentCard({ invoice }: { invoice: Invoice }) { const method = invoicePaymentMethod(invoice); const lastEntry = invoice.payments?.at(-1); + // The PNR is the CBE_BILL reference the customer pays against. It is stamped + // onto the BOOKING at payment-initiation time, not onto the invoice or the + // payment, so it has to be read back from there — same lookup the sealed PDF + // does. Shares react-query's cache with `SourceCard`, so this costs no + // second request. + const { data: booking } = useBookingDetail( + invoice.source === "booking" ? invoice.sourceId : undefined, + ); if (!method && !lastEntry) return null; // The gateway's own reference first; `merchantOrderId` is our order id, which @@ -163,6 +171,7 @@ function PaymentCard({ invoice }: { invoice: Invoice }) { name={method ? paymentMethodLabel(method) : "Settled"} rows={[ { label: "Transaction ref", value: transactionRef }, + { label: "PNR", value: booking?.pnrCode ?? undefined }, { label: "Provider status", value: invoice.payment?.status }, { label: "Paid", From c3894462e7c1d8727300f27e0ef38a520a3cba0e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:03:08 +0000 Subject: [PATCH 03/13] feat(billing): search invoices by PNR and transaction ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are numbers a customer or a provider support desk quotes back, so they belong in the free-text box rather than behind a filter pill. The transaction id and merchant order id are plain ORs — the payment alias is already joined by every caller of applyInvoiceFilters. The PNR folds into the existing booking EXISTS block instead of adding a second subquery, so it inherits that block's correlation and also matches warehouse-, first-mile- and last-mile-sourced invoices, not just booking-sourced ones. bk is promoted to alwaysJoin now that the export's scope() references it. --- .../src/modules/billing/billing.service.ts | 12 ++++++++---- .../exports/datasets/invoices.dataset.ts | 17 ++++++++++++++--- .../src/pages/invoices/InvoicesPage.tsx | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) 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 eab221505..10a20ea03 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -351,20 +351,24 @@ export class BillingService { qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()"); } if (filter.search) { - // Searches what the row actually shows: its number, who it bills, and - // the source record behind it (booking reference, GRN, shipping line). + // Searches what the row actually shows: its number, who it bills, the + // source record behind it (booking reference, PNR, GRN, shipping line) + // and the payment references a customer or a provider support desk would + // quote back — the gateway transaction id and our merchant order id. // The raw `sourceId` stays matchable so a pasted UUID still resolves. - // Requires the `company` alias — every caller of this joins it. + // Requires the `company` and `payment` aliases — every caller joins both. qb.andWhere( `(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search OR company.name ILIKE :search + OR payment.transactionId ILIKE :search + OR payment.merchantOrderId ILIKE :search OR EXISTS ( SELECT 1 FROM freight.bookings b LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id - WHERE b.reference ILIKE :search + WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search) AND (b.id::text = invoice.source_id OR wi.id::text = invoice.source_id OR fm.id::text = invoice.source_id diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 59d6db4ca..56ab3b74e 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -41,7 +41,7 @@ export const invoicesDataset: ExportDataset = { // other way throws on those rows. { alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" }, ], - alwaysJoin: ['c', 'p'], + alwaysJoin: ['c', 'p', 'bk'], groups: [ { id: 'invoice', label: 'Invoice' }, @@ -134,7 +134,7 @@ export const invoicesDataset: ExportDataset = { { key: 'hasBalance', label: 'Outstanding only', type: 'text' }, { key: 'overdue', label: 'Overdue only', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, - { key: 'search', label: 'Search invoice no. or customer', type: 'text' }, + { key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' }, ], defaultSort: { key: 'issuedAt', dir: 'DESC' }, @@ -171,7 +171,18 @@ export const invoicesDataset: ExportDataset = { if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()'); if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); if (params.search) { - qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); + // Same reach as the list page's search box, minus the source-record + // lookups it does with correlated subqueries: number, customer, the PNR + // the customer pays against, and the payment references support desks + // quote back. + qb.andWhere( + `(i.invoice_number ILIKE :search + OR c.name ILIKE :search + OR bk.pnr_code ILIKE :search + OR p.transaction_id ILIKE :search + OR p.merchant_order_id ILIKE :search)`, + { search: `%${params.search as string}%` }, + ); } // ACL: invoices.source_id is a varchar pointer at the originating booking. applyBookingRefDirectionScope(qb, 'i.source_id', directions); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 5361558fb..8b28fd71a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -370,7 +370,7 @@ export default function InvoicesPanel() { From 2be18764602206222a0fc79d2914c92007c4a244 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:12:08 +0000 Subject: [PATCH 04/13] feat(reports): carry time of day on report date columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven report columns bucketed their timestamp to a bare day with to_char, which is wrong for anything a user reads as an event rather than a period: two departures on the same date, or a wagon request fulfilled hours after it was raised, were indistinguishable in the output. The renderer only shows the time when the value actually has one, keyed off the string rather than a per-column flag — a genuine day bucket would otherwise render as 12:00 AM, which reads as data rather than as absence. --- .../charged-vs-actual-volume.report.ts | 2 +- .../definitions/contract-utilization.report.ts | 4 ++-- .../first-last-mile-bookings.report.ts | 2 +- .../reports/definitions/loaded-capacity.report.ts | 2 +- .../definitions/receivables-payables.report.ts | 2 +- .../definitions/revenue-reconciliation.report.ts | 2 +- .../definitions/revenue-transactions.report.ts | 2 +- .../definitions/train-schedule-status.report.ts | 2 +- .../reports/definitions/wagon-requests.report.ts | 4 ++-- .../definitions/wagon-status-duration.report.ts | 2 +- .../definitions/wagon-teu-utilization.report.ts | 2 +- .../src/components/reports/report-format.ts | 15 +++++++++++---- 12 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index 6da342e17..e495f409b 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -68,7 +68,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = { query(ctx) { return baseQuery(ctx) .select("COALESCE(ts.train_number, '—')", 'trainNumber') - .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt') + .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt') .addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station') .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 03f98d306..747a14891 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -91,8 +91,8 @@ export const contractUtilizationReport: ReportDefinition = { .addSelect('c.name', 'customer') .addSelect('ct.status', 'status') .addSelect('ct.contract_kind', 'kind') - .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.contract_valid_from, 'YYYY-MM-DD HH24:MI')`, 'validFrom') + .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD HH24:MI')`, 'validUntil') .addSelect('COALESCE(cap.committed, 0)::float8', 'committed') .addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons') .addSelect('COALESCE(booked.cnt, 0)', 'bookings') 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 index d3abe10dc..06e26d246 100644 --- 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 @@ -72,7 +72,7 @@ export const firstLastMileBookingsReport: ReportDefinition = { .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'); + .addSelect(`to_char(fl.created_at, 'YYYY-MM-DD HH24:MI')`, 'createdAt'); }, async summary(ctx) { const row = await baseQuery(ctx) 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 index 8827b77b8..65dfada57 100644 --- 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 @@ -49,7 +49,7 @@ export const loadedCapacityReport: ReportDefinition = { query(ctx) { return baseQuery(ctx) .select('ts.train_number', 'trainNumber') - .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate') .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') .addSelect('COUNT(*)::int', 'wagons') .addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 0e07710bb..190e83c02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -378,7 +378,7 @@ export const receivablesPayablesReport: ReportDefinition = { query(ctx) { return baseQuery(ctx) .select(SIDE_LABEL_OF('r.side_key'), 'side') - .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt') + .addSelect("to_char(r.txn_date, 'YYYY-MM-DD HH24:MI')", 'issuedAt') .addSelect('r.doc_ref', 'invoiceNumber') .addSelect('r.booking_ref', 'bookingRef') .addSelect('r.booking_status', 'bookingStatus') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts index 73de3f505..c7bec9497 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts @@ -63,7 +63,7 @@ export const revenueReconciliationReport: ReportDefinition = { defaultSort: { key: 'variance', dir: 'DESC' }, query(ctx) { return baseQuery(ctx) - .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt') .addSelect('i.invoice_number', 'invoiceNumber') .addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect(PAYER_EXPR, 'customer') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts index ad96b5b9e..044f0141e 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -93,7 +93,7 @@ export const revenueTransactionsReport: ReportDefinition = { defaultSort: { key: 'issuedAt', dir: 'DESC' }, query(ctx) { return baseQuery(ctx) - .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt') .addSelect('i.invoice_number', 'invoiceNumber') .addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect("COALESCE(b.id::text, '')", 'bookingId') 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 index 88a25d890..b81ea7896 100644 --- 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 @@ -76,7 +76,7 @@ export const trainScheduleStatusReport: ReportDefinition = { .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.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, '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'); }, 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 index df5ac364c..67a916d45 100644 --- 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 @@ -57,8 +57,8 @@ export const wagonRequestsReport: ReportDefinition = { .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(`to_char(r.created_at, 'YYYY-MM-DD HH24:MI')`, 'requestedAt') + .addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD HH24:MI')`, 'fulfilledAt') .addSelect( `ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`, 'delayDays', 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 index 348f681d1..771eec71d 100644 --- 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 @@ -69,7 +69,7 @@ export const wagonStatusDurationReport: ReportDefinition = { .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(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD HH24:MI')`, 'since') .addSelect( `FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`, 'daysInStatus', 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 index 7dabec37c..a10df9976 100644 --- 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 @@ -53,7 +53,7 @@ export const wagonTeuUtilizationReport: ReportDefinition = { .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(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate') .addSelect('COUNT(c.id)::int', 'containers') .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') .groupBy('w.wagon_number') diff --git a/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts index d9b174ef1..15933ee78 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts +++ b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts @@ -17,10 +17,17 @@ export function formatReportCell(value: unknown, type: ReportColumnType): string case "number": return Number(value).toLocaleString(); case "date": { - const d = new Date(String(value)); - return Number.isNaN(d.getTime()) - ? String(value) - : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + const raw = String(value); + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return raw; + // Day-bucket columns carry no time part — don't invent a 12:00 AM for them. + const hasTime = /\d:\d/.test(raw); + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + ...(hasTime ? { hour: "2-digit", minute: "2-digit" } : {}), + }); } default: return String(value); From cdb2f234c39577c2de3d735295aab5d5bbba1be3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:12:17 +0000 Subject: [PATCH 05/13] feat(reports): drop the raw booking ID from revenue transactions The bare UUID column sat next to the booking reference it duplicates, and a reference is what anyone reading or exporting this report actually quotes. The report is the audit trail for an export, so a column nobody can act on is weight in every downloaded file. --- .../modules/reports/definitions/revenue-transactions.report.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts index 044f0141e..4f70216e8 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -73,7 +73,6 @@ export const revenueTransactionsReport: ReportDefinition = { { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, { key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, - { key: 'bookingId', label: 'Booking ID', type: 'string' }, { key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, { key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR }, { key: 'paymentClass', label: 'Payment class', type: 'string' }, @@ -96,7 +95,6 @@ export const revenueTransactionsReport: ReportDefinition = { .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt') .addSelect('i.invoice_number', 'invoiceNumber') .addSelect("COALESCE(b.reference, '—')", 'bookingRef') - .addSelect("COALESCE(b.id::text, '')", 'bookingId') .addSelect(PAYER_EXPR, 'payer') .addSelect(REVENUE_CATEGORY_EXPR, 'category') .addSelect(PAYMENT_CLASS_EXPR, 'paymentClass') From a3f06597ccb7af67515c8f143d143227f65e10c3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:12:32 +0000 Subject: [PATCH 06/13] feat(reports): break charged vs actual volume down by leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report priced every departure against its planned origin-to-destination corridor, so a train that worked several station-to-station moves showed one row and one distance. Ton/Km and Vehicle-Km were then computed against that single corridor and understated the work actually done. The row grain is now the leg: consecutive checkpoint events at different yards, read with lead() over each schedule. DISTINCT because a train that works the same pair twice in one departure is still one leg — without it the join fans the cargo out and doubles every SUM in the group. Both ends fall back to the schedule's own corridor, so a departure with no checkpoints logged keeps exactly the single row it had before. Only query() joins the legs. The KPIs stay corridor-level and would count the same cargo once per leg if they had that join. --- .../charged-vs-actual-volume.report.ts | 87 ++++++++++++++----- 1 file changed, 67 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index e495f409b..9784589e0 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -1,5 +1,6 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { Yard } from '../../rule-engine/entities/yard.entity'; import { ReportContext, ReportDefinition } from '../report.types'; import { ACTUAL_TONS_EXPR, @@ -14,14 +15,43 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + distanceKmBetween, } from '../operations-classification'; /** - * Distance and empty-wagon count belong to the departure, so they are constant - * within a group that includes `ts.id` — MAX() satisfies Postgres without - * dragging a scalar subselect through the GROUP BY. + * A leg is one station-to-station move the train actually made: two consecutive + * checkpoints at different yards. DISTINCT because a train that works the same + * pair twice in one departure is still one leg — without it the join would fan + * the cargo out again and double every SUM in the group. */ -const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`; +const LEGS = `( + SELECT DISTINCT e.train_schedule_id, e.from_yard_id, e.to_yard_id + FROM ( + SELECT ev.train_schedule_id, + ev.yard_id AS from_yard_id, + lead(ev.yard_id) OVER ( + PARTITION BY ev.train_schedule_id ORDER BY ev.occurred_at + ) AS to_yard_id + FROM freight.train_checkpoint_events ev + WHERE ev.deleted_at IS NULL + ) e + WHERE e.to_yard_id IS NOT NULL AND e.to_yard_id <> e.from_yard_id +)`; + +/** + * LEFT joined, and both ends fall back to the schedule's own corridor: a + * departure with no checkpoints logged has no legs, and must keep the single + * origin-to-destination row it had before this report knew about legs. + */ +const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)'; +const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)'; + +/** + * Distance and empty-wagon count are constant within a group that includes + * `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar + * subselect through the GROUP BY. + */ +const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`; const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; /** @@ -29,8 +59,8 @@ const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; * configured distance. A missing distance is not a zero distance, and zeroing * it would understate the corridor's work without anyone noticing. */ -const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`; -const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`; +const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`; +const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`; function baseQuery(ctx: ReportContext): SelectQueryBuilder { const qb = allocationLedgerQb(ctx); @@ -38,22 +68,37 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { return qb; } +/** The row grain: one leg of one departure. Only `query()` needs it — the KPIs + * are corridor-level and would count the same cargo once per leg if they had + * this join. */ +function legQuery(ctx: ReportContext): SelectQueryBuilder { + return baseQuery(ctx) + .leftJoin(LEGS, 'leg', 'leg.train_schedule_id = ts.id') + .leftJoin(Yard, 'lfy', `lfy.id = ${LEG_FROM}`) + .leftJoin(Yard, 'lty', `lty.id = ${LEG_TO}`); +} + export const chargedVsActualVolumeReport: ReportDefinition = { key: 'charged-vs-actual-volume', title: 'Charged and Actual Volumes', description: - 'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' + - 'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' + - '2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' + - 'all editable in Operating standards. Actual volume is what the marshalling recorded. ' + - 'Vehicle-Km counts the empty wagons on that train, so it repeats across the train’s ' + - 'cargo types rather than being split between them.', + 'Charged versus actual volume per leg and cargo type, with Ton/Km and Vehicle-Km. ' + + 'A leg is one station-to-station move the train actually made, read from its logged ' + + 'checkpoints; a departure with no checkpoints logged shows as its single planned ' + + 'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' + + 'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' + + 'perishables — all editable in Operating standards. Actual volume is what the ' + + 'marshalling recorded. Volumes and wagon counts belong to the train, not to the leg, ' + + 'so they repeat on every leg it ran and across its cargo types rather than being split ' + + 'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' + + 'exception and are the leg’s own, so they add up across legs into the real corridor ' + + 'figure.', group: 'Operations', filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], columns: [ { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, { key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' }, - { key: 'station', label: 'Station', type: 'string' }, + { key: 'leg', label: 'Leg', type: 'string' }, { key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true }, @@ -66,27 +111,29 @@ export const chargedVsActualVolumeReport: ReportDefinition = { ], defaultSort: { key: 'departedAt', dir: 'DESC' }, query(ctx) { - return baseQuery(ctx) + return legQuery(ctx) .select("COALESCE(ts.train_number, '—')", 'trainNumber') .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt') - .addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station') + .addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg') .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons') .addSelect(TEU_EXPR, 'teu') .addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons') - .addSelect(`${ROUTE_KM}::float8`, 'distanceKm') + .addSelect(`${LEG_KM}::float8`, 'distanceKm') .addSelect(TON_KM, 'tonKm') .addSelect(VEHICLE_KM, 'vehicleKm') .groupBy('ts.id') .addGroupBy('ts.train_number') .addGroupBy('ts.actual_departure_at') .addGroupBy('ts.scheduled_departure_date') - .addGroupBy('oy.label') - .addGroupBy('oy.code') - .addGroupBy('dy.label') - .addGroupBy('dy.code') + .addGroupBy('leg.from_yard_id') + .addGroupBy('leg.to_yard_id') + .addGroupBy('lfy.label') + .addGroupBy('lfy.code') + .addGroupBy('lty.label') + .addGroupBy('lty.code') .addGroupBy(CARGO_CATEGORY_EXPR); }, async summary(ctx) { From e8f46675dcf568f976c4769044fee8043249e124 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:12:40 +0000 Subject: [PATCH 07/13] feat(reports): add a total wagons column to charged vs actual volume Loaded and empty wagons were both shown but never summed, so the train's actual consist had to be added up by hand on every row. MAX() on the empty count for the same reason the distance uses it: the value is constant within a group that includes ts.id and the leg. --- .../reports/definitions/charged-vs-actual-volume.report.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index 9784589e0..5fe6ba81b 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -53,6 +53,7 @@ const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)'; */ const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`; const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; +const TOTAL_WAGONS = `(COUNT(DISTINCT tsw.id) + ${EMPTY_WAGONS})::int`; /** * Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no @@ -105,6 +106,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = { { key: 'teu', label: 'TEU', type: 'number' }, { key: 'wagons', label: 'Loaded wagons', type: 'number' }, { key: 'emptyWagons', label: 'Empty wagons', type: 'number' }, + { key: 'totalWagons', label: 'Total wagons', type: 'number', sortable: true }, { key: 'distanceKm', label: 'Distance (km)', type: 'number' }, { key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true }, { key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' }, @@ -121,6 +123,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = { .addSelect(TEU_EXPR, 'teu') .addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons') + .addSelect(TOTAL_WAGONS, 'totalWagons') .addSelect(`${LEG_KM}::float8`, 'distanceKm') .addSelect(TON_KM, 'tonKm') .addSelect(VEHICLE_KM, 'vehicleKm') From baef14c847e8de01e49c321d63f772907182f8f2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:12:51 +0000 Subject: [PATCH 08/13] style(reports): reformat revenue transactions with prettier Whole-file requote to double quotes plus wrapped column literals. No behaviour change. Kept as its own commit because it is out of step with the rest of reports/definitions, which is single-quoted: bare prettier ignores @edr/prettier-config, so running it on one file requotes that file alone. Drop this commit if the directory should stay consistent. --- .../revenue-transactions.report.ts | 144 +++++++++++------- 1 file changed, 87 insertions(+), 57 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts index 4f70216e8..9b060659c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -1,6 +1,6 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportContext, ReportDefinition } from "../report.types"; import { PAYMENT_CLASS_EXPR, PAYER_EXPR, @@ -13,7 +13,7 @@ import { currencyOf, periodExpr, revenueLedgerQb, -} from '../revenue-classification'; +} from "../revenue-classification"; /** * The gateway payment behind an invoice, for traceability. `invoices.payment_id` @@ -51,79 +51,109 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { } export const revenueTransactionsReport: ReportDefinition = { - key: 'revenue-transactions', - title: 'Revenue Transactions', + key: "revenue-transactions", + title: "Revenue Transactions", description: - 'Every billed revenue line, at transaction level — booking reference, invoice number, ' + - 'charge type, cargo, quantity and the payment reference behind it. This is the ' + - 'drill-down target for the revenue summaries and the audit trail for an export.', - group: 'Finance', + "Every billed revenue line, at transaction level — booking reference, invoice number, " + + "charge type, cargo, quantity and the payment reference behind it. This is the " + + "drill-down target for the revenue summaries and the audit trail for an export.", + group: "Finance", filters: [ PERIOD_FILTER, ...REVENUE_FILTERS, - { key: 'period_value', label: 'Period bucket', type: 'text' }, + { key: "period_value", label: "Period bucket", type: "text" }, { - key: 'categoryKey', - label: 'Category (exact)', - type: 'select', + key: "categoryKey", + label: "Category (exact)", + type: "select", options: REVENUE_CATEGORIES, }, ], columns: [ - { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, - { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, - { key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, - { key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, - { key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR }, - { key: 'paymentClass', label: 'Payment class', type: 'string' }, - { key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' }, - { key: 'cargo', label: 'Cargo', type: 'string' }, - { key: 'route', label: 'Route', type: 'string' }, - { key: 'quantity', label: 'Qty', type: 'number' }, - { key: 'unit', label: 'Unit', type: 'string' }, - { key: 'unitRate', label: 'Unit rate', type: 'money' }, - { key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' }, - { key: 'currency', label: 'Currency', type: 'string' }, - { key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' }, - { key: 'paymentRef', label: 'Payment ref', type: 'string' }, - { key: 'paymentMethod', label: 'Method', type: 'string' }, - { key: 'paymentStatus', label: 'Payment status', type: 'string' }, + { key: "issuedAt", label: "Issued", type: "date", sortable: true, sortExpr: REVENUE_DATE }, + { + key: "invoiceNumber", + label: "Invoice No.", + type: "string", + sortable: true, + sortExpr: "i.invoice_number", + }, + { + key: "bookingRef", + label: "Booking", + type: "string", + sortable: true, + sortExpr: "b.reference", + }, + { key: "payer", label: "Customer", type: "string", sortable: true, sortExpr: PAYER_EXPR }, + { + key: "category", + label: "Revenue category", + type: "string", + sortable: true, + sortExpr: REVENUE_CATEGORY_EXPR, + }, + { key: "paymentClass", label: "Payment class", type: "string" }, + { + key: "chargeType", + label: "Charge type", + type: "string", + sortable: true, + sortExpr: "il.charge_type", + }, + { key: "cargo", label: "Cargo", type: "string" }, + { key: "route", label: "Route", type: "string" }, + { key: "quantity", label: "Qty", type: "number" }, + { key: "unit", label: "Unit", type: "string" }, + { key: "unitRate", label: "Unit rate", type: "money" }, + { key: "amount", label: "Amount", type: "money", sortable: true, sortExpr: "il.amount" }, + { key: "currency", label: "Currency", type: "string" }, + { + key: "invoiceStatus", + label: "Invoice status", + type: "string", + sortable: true, + sortExpr: "i.status", + }, + { key: "paymentRef", label: "Payment ref", type: "string" }, + { key: "paymentMethod", label: "Method", type: "string" }, + { key: "paymentStatus", label: "Payment status", type: "string" }, ], - defaultSort: { key: 'issuedAt', dir: 'DESC' }, + defaultSort: { key: "issuedAt", dir: "DESC" }, query(ctx) { return baseQuery(ctx) - .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt') - .addSelect('i.invoice_number', 'invoiceNumber') - .addSelect("COALESCE(b.reference, '—')", 'bookingRef') - .addSelect(PAYER_EXPR, 'payer') - .addSelect(REVENUE_CATEGORY_EXPR, 'category') - .addSelect(PAYMENT_CLASS_EXPR, 'paymentClass') - .addSelect('il.charge_type', 'chargeType') - .addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", 'cargo') - .addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route') - .addSelect('il.quantity::float8', 'quantity') - .addSelect("COALESCE(il.metadata->>'unit', '')", 'unit') - .addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate') - .addSelect('ROUND(il.amount, 2)::float8', 'amount') - .addSelect('il.currency', 'currency') - .addSelect('i.status', 'invoiceStatus') + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, "issuedAt") + .addSelect("i.invoice_number", "invoiceNumber") + .addSelect("COALESCE(b.reference, '—')", "bookingRef") + .addSelect(PAYER_EXPR, "payer") + .addSelect(REVENUE_CATEGORY_EXPR, "category") + .addSelect(PAYMENT_CLASS_EXPR, "paymentClass") + .addSelect("il.charge_type", "chargeType") + .addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", "cargo") + .addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", "route") + .addSelect("il.quantity::float8", "quantity") + .addSelect("COALESCE(il.metadata->>'unit', '')", "unit") + .addSelect("ROUND(il.unit_rate, 2)::float8", "unitRate") + .addSelect("ROUND(il.amount, 2)::float8", "amount") + .addSelect("il.currency", "currency") + .addSelect("i.status", "invoiceStatus") .addSelect( - `COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`, - 'paymentRef', + `COALESCE(${latestPayment("transaction_id")}, ${latestPayment("merchant_order_id")}, '')`, + "paymentRef", ) - .addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod') - .addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus'); + .addSelect(`COALESCE(${latestPayment("method")}, '')`, "paymentMethod") + .addSelect(`COALESCE(${latestPayment("status")}, '')`, "paymentStatus"); }, async summary(ctx) { const row = await baseQuery(ctx) - .select(REVENUE_SUM, 'revenue') - .addSelect('COUNT(*)::int', 'lines') - .addSelect('COUNT(DISTINCT i.id)::int', 'invoices') + .select(REVENUE_SUM, "revenue") + .addSelect("COUNT(*)::int", "lines") + .addSelect("COUNT(DISTINCT i.id)::int", "invoices") .getRawOne<{ revenue: number; lines: number; invoices: number }>(); return [ - { label: 'Lines', value: Number(row?.lines ?? 0) }, - { label: 'Invoices', value: Number(row?.invoices ?? 0) }, - { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, + { label: "Lines", value: Number(row?.lines ?? 0) }, + { label: "Invoices", value: Number(row?.invoices ?? 0) }, + { label: "Revenue", value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, ]; }, }; From de407c0014a7103ec75a9e2dbde7762f6bce1463 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:54:36 +0000 Subject: [PATCH 09/13] feat(reports): clamp report descriptions with a show more toggle Report descriptions run to a paragraph. ReportDescription wraps Mantine's Spoiler to clamp them to two lines, and the toggle only renders when the text actually overflows. PageHeader kept its subtitle on a hard truncate class, which would have pinned the spoiler to one line, so a ReactNode subtitle now renders as-is and owns its own layout. A string subtitle still truncates as before. --- .../src/components/page/PageHeader.tsx | 12 ++++++--- .../components/reports/ReportDescription.tsx | 27 +++++++++++++++++++ .../src/components/reports/ReportSection.tsx | 7 +++-- .../src/components/reports/ReportView.tsx | 3 ++- 4 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx index 86beb22fb..2194930ec 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -58,9 +58,15 @@ export function PageHeader({ {meta} {subtitle ? ( - - {subtitle} - + typeof subtitle === "string" ? ( + + {subtitle} + + ) : ( + // A component subtitle handles its own layout — truncating it + // to one line would defeat e.g. an expandable description. +
{subtitle}
+ ) ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx new file mode 100644 index 000000000..69707dac8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx @@ -0,0 +1,27 @@ +import { Spoiler, Text } from "@mantine/core"; + +interface ReportDescriptionProps { + text: string; +} + +/** + * Report descriptions run to a paragraph. Clamps to roughly two lines and adds + * a Show more toggle — Spoiler measures the content, so the toggle only appears + * when the text actually overflows. + */ +export function ReportDescription({ text }: ReportDescriptionProps) { + return ( + + + {text} + + + ); +} + +export default ReportDescription; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx index e686bc9d0..09da81439 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx @@ -1,8 +1,9 @@ -import { Stack, Text, Title } from "@mantine/core"; +import { Stack, Title } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { ReportDescription } from "./ReportDescription"; import { ReportView } from "./ReportView"; interface ReportSectionProps { @@ -29,9 +30,7 @@ export function ReportSection({ reportKey, idKeyValue, defaultView }: ReportSect
{def.title} - - {def.description} - +
diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx index 2634e882c..2c78a9b6c 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx @@ -13,6 +13,7 @@ import type { ReportFilterDef, ReportRunParams } from "@/types/reports"; import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common"; import { ReportChart } from "./ReportChart"; +import { ReportDescription } from "./ReportDescription"; import { ReportExportButton } from "./ReportExportButton"; import { formatKpiValue, formatReportCell } from "./report-format"; @@ -229,7 +230,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R {pageHeader ? ( } action={ {exportButton} From b7f8a436fba108b9bb58e75c0d52bd5851cb5e53 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 08:01:12 +0000 Subject: [PATCH 10/13] fix(reports): filter the plan side of plan-versus-actual reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cargo filter was applied only to the operated and attainment subqueries. The plan side selected targets by metric and dimension alone, and the FULL OUTER JOIN put every filtered-out key back as a row of zeros — ?categories=FERTILIZER returned all ten planned categories. planKeyFilter restricts targets to the selected categories (or container classes), reading ot.cargo_category for a station plan and ot.dimension_key otherwise. Values are whitelisted against the vocabulary and inlined, because the fragment is assembled into raw CTE text and the runner does not validate multiselect values. Two related grain leaks close with it: - planCountryFilter narrows a station plan to the chosen country. All seven station targets are Ethiopian, so the Djibouti view was listing 33 Ethiopian targets as stations that moved nothing. - planGrainFilter drops the plan entirely when origin, destination, train number or direction is set. No target carries a route, so the plan there was the whole corridor's target sitting beside one slice of its work, and the implement rate read as a miss that never happened. Fixes cargo-volume-performance, cargo-volume-by-station, trainset-performance and teu-performance together. Co-Authored-By: Claude Opus 5 --- .../reports/operations-classification.spec.ts | 71 +++++++++++++++++++ .../reports/operations-classification.ts | 61 +++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts index 63a7db20b..a37bc3f0c 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -7,6 +7,7 @@ import { TARGET_DIMENSION_KEYS, cycleRateExpr, implementRateExpr, + plannedRowsSql, } from './operations-classification'; import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity'; @@ -91,4 +92,74 @@ describe('operations classification', () => { expect(rate(65, 78)).toBeLessThan(100); expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)'); }); + + /** + * The plan side is FULL OUTER JOINed to the operated side, so a plan row for + * a category the user filtered out comes back as a row of zeros — the bug + * where `?categories=FERTILIZER` still returned all ten planned categories. + */ + describe('plannedRowsSql cargo filter', () => { + const sqlFor = (dimension: string, params: Record): string => + plannedRowsSql('VOLUME_TONS', dimension, params, 'SELECT 1'); + + it('restricts targets to the selected categories', () => { + expect(sqlFor('cargo_category', { categories: ['FERTILIZER'] })).toContain( + "AND ot.dimension_key IN ('FERTILIZER')", + ); + }); + + it('restricts a station target on its cargo type, not its key', () => { + const sql = sqlFor('station', { categories: ['SAND'] }); + expect(sql).toContain("AND ot.cargo_category IN ('SAND')"); + expect(sql).not.toContain('ot.dimension_key IN'); + }); + + it('filters a container class report on its own vocabulary', () => { + expect(sqlFor('container_class', { classes: ['CONTAINER_EXPORT'] })).toContain( + "AND ot.dimension_key IN ('CONTAINER_EXPORT')", + ); + }); + + it('leaves every target when nothing is selected', () => { + expect(sqlFor('cargo_category', {})).not.toContain('ot.dimension_key IN'); + }); + + it('matches nothing on a value no category expression can emit', () => { + expect(sqlFor('cargo_category', { categories: ["x'; DROP TABLE"] })).toContain('AND FALSE'); + }); + + it('narrows a station plan to the chosen country rather than suppressing it', () => { + const sql = sqlFor('station', { country: 'Djibouti' }); + expect(sql).toContain("y.country = 'Djibouti'"); + expect(sql).not.toContain('AND FALSE'); + }); + + it('ignores a country that is not one of the two sides', () => { + expect(sqlFor('station', { country: "' OR true --" })).not.toContain('y.country'); + }); + + it('leaves the country alone on a plan not keyed by station', () => { + expect(sqlFor('cargo_category', { country: 'Djibouti' })).not.toContain('y.country'); + }); + + /** + * No target carries a route, a train or a direction, so beside a + * route-filtered actual the plan would be the whole corridor's target. + */ + it.each(['origin', 'destination', 'trainNumber', 'direction'])( + 'reports no plan at all when %s narrows below the target grain', + (key) => { + expect(sqlFor('cargo_category', { [key]: 'X' })).toContain('AND FALSE'); + }, + ); + + it('keeps the plan when only period, date and category are set', () => { + const sql = sqlFor('cargo_category', { + period: 'month', + dateFrom: '2026-01-01', + categories: ['SAND'], + }); + expect(sql).not.toContain('AND FALSE'); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index 1c8f8504c..cffb787ee 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -513,7 +513,9 @@ export const PLAN_GRANULARITY_NOTE = 'Plan is the committed figure and never moves. Required is the same target treated as a ' + 'quota: whatever is still outstanding, spread across the time still left, so a period ' + 'that fell behind raises what the periods after it must carry. A target already met in ' + - 'full requires nothing further.'; + 'full requires nothing further. No target carries a route, a train or a direction, so ' + + 'filtering by one leaves the plan columns empty rather than comparing a corridor’s whole ' + + 'target against one slice of its work.'; /** * The user's date filter as open-ended bounds, so the clipping arithmetic below @@ -575,6 +577,60 @@ END`; * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind * with {@link plannedRowsParams} — they come from the user's date filter. */ +/** + * The plan side of a plan-versus-actual report has to obey the same cargo + * filter the operated side does. Without it the FULL OUTER JOIN re-introduces + * every planned key the user filtered out, as a row of zeros. + * + * Values are whitelisted against the vocabulary and inlined rather than bound: + * this fragment is assembled into raw CTE text, and the filter params are not + * validated upstream. An unknown value matches nothing — same as it does on the + * operated side, where the CASE can never emit it. + */ +const planKeyFilter = (dimension: string, params: Record): string => { + const isClass = dimension === 'container_class'; + const selected = (isClass ? params.classes : params.categories) as string[] | null; + if (!selected?.length) return ''; + const vocab = isClass ? CONTAINER_CLASSES : CARGO_CATEGORIES; + const valid = selected.filter((v) => vocab.some((o) => o.value === v)); + // A station target is keyed on the yard and carries its cargo type alongside. + const column = dimension === 'station' ? 'ot.cargo_category' : 'ot.dimension_key'; + return valid.length ? `AND ${column} IN (${quote(valid)})` : 'AND FALSE'; +}; + +/** + * Filters that narrow the operated population below the grain any target is + * kept at. No target carries a route, a train or a direction, so a plan read + * beside a route-filtered actual is the whole corridor's plan sitting next to + * one slice of its work — the implement rate then reads as a miss that never + * happened. + * + * There is no honest number to show, so the plan side reports nothing at all + * and Implement Rate goes NULL, the same way it does for a period with no + * target. `country` is absent on purpose: on a station plan it is a property of + * the planned key itself, and {@link planCountryFilter} narrows rather than + * suppresses. + */ +const PLAN_GRAIN_BREAKERS = ['origin', 'destination', 'trainNumber', 'direction']; + +const planGrainFilter = (params: Record): string => + PLAN_GRAIN_BREAKERS.some((key) => params[key]) ? 'AND FALSE' : ''; + +/** + * A station target is keyed on a yard code, so the country filter — which + * decides which end of the corridor the report calls "the station" — is a real + * predicate on the plan, not a grain break. Without it the Djibouti view lists + * every Ethiopian station's target as a row that moved nothing. + */ +const planCountryFilter = (dimension: string, params: Record): string => { + if (dimension !== 'station') return ''; + const country = COUNTRY_FILTER.options?.find((o) => o.value === params.country)?.value; + if (!country) return ''; + return `AND EXISTS (SELECT 1 FROM freight.yards y + WHERE y.code = ot.dimension_key AND y.deleted_at IS NULL + AND y.country = '${country}')`; +}; + export const plannedRowsSql = ( metric: string, dimension: string, @@ -597,6 +653,9 @@ export const plannedRowsSql = ( AND ot.metric = '${metric}' AND ot.dimension = '${dimension}' AND ot.planned_value > 0 + ${planKeyFilter(dimension, params)} + ${planCountryFilter(dimension, params)} + ${planGrainFilter(params)} ), -- One row per target per bucket. Generated a day at a time rather than a -- bucket at a time: the ragged units restart their blocks each January, so From 2e5dadeee989212ff0fb88446cf2feb1cdfa31dd Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 08:01:20 +0000 Subject: [PATCH 11/13] fix(reports): apply the trade scope to six unscoped reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These reports resolved ctx.directions and never read it, so a user restricted to one trade direction saw every row, and a user with no trade access — where directions is [] and the rule is show nothing — saw all of them. first-last-mile-bookings scopes on the booking's own direction; the other five scope on ts.direction. global-logistics-wagons uses the fragment form so a log row whose schedule is gone stays visible, which is the rule the other ledgers apply to rows carrying no direction. Also adds the missing soft-delete guards: b.deleted_at on the first/last mile booking join, ts.deleted_at on the wagon-teu-utilization and global-logistics-wagons schedule joins. Co-Authored-By: Claude Opus 5 --- .../definitions/first-last-mile-bookings.report.ts | 9 +++++++-- .../definitions/global-logistics-wagons.report.ts | 10 ++++++++-- .../reports/definitions/loaded-capacity.report.ts | 5 ++++- .../definitions/train-schedule-status.report.ts | 5 ++++- .../reports/definitions/train-turnaround.report.ts | 5 ++++- .../definitions/wagon-teu-utilization.report.ts | 11 +++++++++-- 6 files changed, 36 insertions(+), 9 deletions(-) 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 index 06e26d246..5832e75f3 100644 --- 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 @@ -3,6 +3,7 @@ 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 { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; // FirstMile and LastMile are separate tables with an identical shape (status, @@ -27,11 +28,11 @@ const STATUS_OPTIONS = [ ]; function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(LEG_UNION, 'fl') - .innerJoin(Booking, 'b', 'b.id = fl.booking_id') + .innerJoin(Booking, 'b', 'b.id = fl.booking_id AND b.deleted_at IS NULL') .leftJoin(Company, 'c', 'c.id = b.company_id') .leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id') .where('1 = 1'); @@ -41,6 +42,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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 }); + + // Every leg hangs off a booking, so the trade scope is the booking's own + // direction — the same rule the booking-grain reports apply. + applyDirectionScope(qb, 'b.trade_direction', directions); return qb; } 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 index d3fd759f7..c6a8edc7a 100644 --- 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 @@ -2,22 +2,28 @@ 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 { directionScopeSql } from '../../user-trade-access/trade-scope.util'; 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 { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(ScheduleWagonAdjustmentLog, 'l') - .leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id') + .leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id AND ts.deleted_at IS NULL') .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 }); + + // A log row whose schedule is gone carries no direction to scope by and + // stays visible — the rule the other ledgers apply to booking-less rows. + const scope = directionScopeSql('ts.direction', directions); + qb.andWhere(`(ts.id IS NULL OR ${scope.sql})`, scope.params); return qb; } 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 index 65dfada57..0c8f08c7f 100644 --- 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 @@ -3,13 +3,14 @@ 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 { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; 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 { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSetWagon, 'tsw') @@ -24,6 +25,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); } if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + + applyDirectionScope(qb, 'ts.direction', directions); return qb; } 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 index b81ea7896..1104d5895 100644 --- 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 @@ -2,6 +2,7 @@ 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 { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; // ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the @@ -11,7 +12,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v })); function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSchedule, 'ts') @@ -28,6 +29,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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 }); + + applyDirectionScope(qb, 'ts.direction', directions); return qb; } 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 index 7dc7b8c3c..48304f054 100644 --- 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 @@ -2,6 +2,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; // "Turnaround" here is departure-to-arrival transit time on the actual (not @@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; // 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 { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSchedule, 'ts') @@ -22,6 +23,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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 }); + + applyDirectionScope(qb, 'ts.direction', directions); return qb; } 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 index a10df9976..3d81c06ed 100644 --- 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 @@ -5,16 +5,21 @@ 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 { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; 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 { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(Wagon, 'w') - .innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id') + .innerJoin( + TrainSchedule, + 'ts', + 'ts.id = w.current_train_schedule_id AND ts.deleted_at IS NULL', + ) .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') @@ -27,6 +32,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); } if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + + applyDirectionScope(qb, 'ts.direction', directions); return qb; } From eba09c73ae4f6408679d967e943213bca9d72b09 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 08:21:29 +0000 Subject: [PATCH 12/13] fix: locomotive filtering --- .../reports/definitions/locomotive-fleet-status.report.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index cf971cf2c..a9e74039d 100644 --- 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 @@ -12,7 +12,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { .createQueryBuilder() .from(Locomotive, 'l') .leftJoin(Yard, 'y', 'y.id = l.current_yard_id') - .where('l.deleted_at IS NULL'); + .where('l.deleted_at IS NULL') + // Names ending '#' are excluded from the fleet report by request; the + // marker is a roster convention, not a column the schema tracks. + .andWhere("COALESCE(l.name, '') NOT LIKE '%#'"); const statuses = params.statuses as string[] | null; if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses }); From 2286135228117214a0ea0b138b0ffa27f3be9b9c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 08:58:58 +0000 Subject: [PATCH 13/13] feat(reports): add port warehouse operations summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces the monthly count sheet a port warehouse publishes: trains, containers by size and laden state, wagons, TEU and bulk wagons per cargo type, each split into export and import beside an overall total. Two departures from the spreadsheet it replaces: - Wagons are counted distinctly from the marshalling record rather than derived as 20ft/2 + 40ft + bulk wagons, which overstates whenever a wagon ran part-loaded. - Total is counted over everything rather than summed across the direction columns — a train carrying both an import and an export booking belongs to both and would otherwise count twice. Demurrage is billed on invoice lines and is left to Revenue by Category. Adds a station filter matching either end of the corridor, so one warehouse can report the trains it worked in both directions. --- .../port-warehouse-summary.report.spec.ts | 45 +++ .../port-warehouse-summary.report.ts | 322 ++++++++++++++++++ .../src/modules/reports/report.registry.ts | 2 + .../src/seed/freight-permissions.registry.ts | 1 + 4 files changed, 370 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts new file mode 100644 index 000000000..2254cb53d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts @@ -0,0 +1,45 @@ +import { + LINES, + countAliases, + lineRefs, + portWarehouseSummaryReport, +} from "./port-warehouse-summary.report"; + +/** + * The sheet's lines are SQL fragments over the aggregate's select aliases, so a + * renamed or dropped count is invisible to the type-checker and surfaces as a + * 42703 the first time someone opens the report. + */ +describe("port-warehouse-summary", () => { + it("every line reads a column the aggregate selects", () => { + expect(lineRefs().filter((ref) => !countAliases().includes(ref))).toEqual( + [], + ); + }); + + it("every selected count is used by a line", () => { + expect( + countAliases().filter((alias) => !lineRefs().includes(alias)), + ).toEqual([]); + }); + + it("labels are unique — the sheet groups on them", () => { + const labels = LINES.map((l) => l.label); + expect(new Set(labels).size).toBe(labels.length); + }); + + it("declares a column for every direction the pivot emits", () => { + const keys = portWarehouseSummaryReport.columns.map((c) => c.key); + expect(keys).toEqual( + expect.arrayContaining([ + "sn", + "section", + "metric", + "export", + "import", + "domestic", + "total", + ]), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts new file mode 100644 index 000000000..9cd3a11c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts @@ -0,0 +1,322 @@ +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; + +import { ReportContext, ReportDefinition } from "../report.types"; +import { + ALLOC_CONTAINERS_20, + ALLOC_CONTAINERS_40, + OPERATIONS_FILTERS, + SCHEDULE_IS_CONTAINER, + allocationLedgerQb, +} from "../operations-classification"; +import { yardOptions } from "../revenue-classification"; + +/** + * The monthly operations summary a port warehouse publishes — the shape of the + * GMP workbook: one line per operation, counted separately for export and + * import, and again over everything. + * + * Every other operations report is a normal grouped table; this one is a + * transposed count sheet, because that is the artefact being reproduced. It is + * built by aggregating the allocation ledger once per direction and once + * overall, unpivoting each of those rows into one row per named operation, then + * pivoting direction back out into columns. + * + * Two deliberate departures from the workbook: + * + * - **Wagons are counted, not derived.** The workbook computes wagons as + * `20ft/2 + 40ft + bulk wagons` because it has no marshalling record. We do — + * `COUNT(DISTINCT train_set_wagons.id)` is what actually carried the cargo. + * The two disagree whenever a wagon ran part-loaded, and the counted figure + * is the true one. + * - **Demurrage is not here.** It is billed on invoice lines, a different fact + * table entirely; Revenue by Category filtered to Demurrage already answers + * it and joining it in at allocation grain would double-count. + */ + +/** Booking-level empty marker, NULL-safe so an allocation with no booking is "laden". */ +const IS_EMPTY = "COALESCE(b.equipment_return = 'RETURN', false)"; +const IS_CONTAINER_LOAD = "wba.load_type = 'CONTAINER'"; + +/** The bulk twin of {@link SCHEDULE_IS_CONTAINER} — same train-set grain. */ +const SCHEDULE_IS_BULK = `EXISTS ( + SELECT 1 FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL + WHERE w.train_set_id = ts.train_set_id + AND a.deleted_at IS NULL AND a.load_type <> 'CONTAINER' +)`; + +const DIRECTION = "COALESCE(b.trade_direction, ts.direction)"; + +const boxes = (perAllocation: string, cond: string): string => + `(COALESCE(SUM(${perAllocation}) FILTER (WHERE ${cond}), 0))::int`; + +const trains = (cond?: string): string => + `(COUNT(DISTINCT ts.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`; + +const wagons = (cond?: string): string => + `(COUNT(DISTINCT tsw.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`; + +/** + * The raw counts the sheet is built from, keyed by the alias each is selected + * as. {@link LINES} may only reference these; the spec beside this file is what + * keeps the two in step, since a stale `p.` is a runtime 42703 that + * neither tsc nor a type-check can see. + */ +const COUNTS: Record = { + trains: trains(), + container_trains: trains( + `${SCHEDULE_IS_CONTAINER} AND NOT ${SCHEDULE_IS_BULK}`, + ), + bulk_trains: trains(`${SCHEDULE_IS_BULK} AND NOT ${SCHEDULE_IS_CONTAINER}`), + mixed_trains: trains(`${SCHEDULE_IS_CONTAINER} AND ${SCHEDULE_IS_BULK}`), + full_20: boxes( + ALLOC_CONTAINERS_20, + `${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`, + ), + full_40: boxes( + ALLOC_CONTAINERS_40, + `${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`, + ), + empty_20: boxes(ALLOC_CONTAINERS_20, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`), + empty_40: boxes(ALLOC_CONTAINERS_40, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`), + full_wagons: wagons(`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`), + empty_wagons: wagons(`${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`), + bulk_wagons: wagons(`NOT ${IS_CONTAINER_LOAD}`), + wagons: wagons(), +}; + +/** + * The operation lines, in the workbook's order. `value` is an expression over + * the per-direction aggregate `p`, so a derived line (totals, TEU) is plain + * arithmetic rather than a second pass over the ledger. + */ +export const LINES: { section: string; label: string; value: string }[] = [ + { section: "Trains", label: "Total trains", value: "p.trains" }, + { section: "Trains", label: "Container trains", value: "p.container_trains" }, + { section: "Trains", label: "Bulk cargo trains", value: "p.bulk_trains" }, + { + section: "Trains", + label: "Mixed bulk and container trains", + value: "p.mixed_trains", + }, + { section: "Containers", label: "Full containers 20ft", value: "p.full_20" }, + { section: "Containers", label: "Full containers 40ft", value: "p.full_40" }, + { + section: "Containers", + label: "Empty containers 20ft", + value: "p.empty_20", + }, + { + section: "Containers", + label: "Empty containers 40ft", + value: "p.empty_40", + }, + { + section: "Containers", + label: "Total 20ft containers", + value: "p.full_20 + p.empty_20", + }, + { + section: "Containers", + label: "Total 40ft containers", + value: "p.full_40 + p.empty_40", + }, + { + section: "Containers", + label: "Total containers", + value: "p.full_20 + p.empty_20 + p.full_40 + p.empty_40", + }, + { + section: "Containers", + label: "Total TEU", + value: "p.full_20 + p.empty_20 + (p.full_40 + p.empty_40) * 2", + }, + { + section: "Wagons", + label: "Wagons loaded with full containers", + value: "p.full_wagons", + }, + { + section: "Wagons", + label: "Wagons loaded with empty containers", + value: "p.empty_wagons", + }, + { + section: "Wagons", + label: "Wagons loaded with bulk cargo", + value: "p.bulk_wagons", + }, + { section: "Wagons", label: "Total wagons", value: "p.wagons" }, +]; + +/** Every `p.` a line reads, or the aliases the aggregate offers. */ +export const lineRefs = (): string[] => [ + ...new Set( + LINES.flatMap((l) => [...l.value.matchAll(/\bp\.(\w+)/g)].map((m) => m[1])), + ), +]; + +export const countAliases = (): string[] => Object.keys(COUNTS); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + if (ctx.params.station) { + // Either end of the corridor — a warehouse reports the trains it worked, + // whichever direction they ran. + qb.andWhere("(oy.code = :station OR dy.code = :station)", { + station: ctx.params.station, + }); + } + return qb; +} + +/** + * The raw counts, one row per direction — or, with `grouped` false, one row for + * everything under the pseudo-direction `ALL`. + * + * The second form is not a convenience: trains and wagons are counted + * distinctly, and a train carrying both an import and an export booking belongs + * to both directions. Adding the direction columns up would count it twice, so + * the Total column reads this row instead of summing the others. + */ +function aggregate( + ctx: ReportContext, + grouped: boolean, +): SelectQueryBuilder { + const qb = baseQuery(ctx).select(grouped ? DIRECTION : "'ALL'", "dir"); + for (const [alias, expr] of Object.entries(COUNTS)) qb.addSelect(expr, alias); + if (grouped) qb.groupBy(DIRECTION); + return qb; +} + +/** + * The bulk sheet: wagons per cargo type. Grouped on the cargo type itself, not + * the coarse cargo category the other operations reports use — the workbook + * lists wheat, sugar and lentils separately and the category vocabulary folds + * all three into BULK. + */ +function bulkByCargoType( + ctx: ReportContext, + grouped: boolean, +): SelectQueryBuilder { + const qb = baseQuery(ctx) + .andWhere(`NOT ${IS_CONTAINER_LOAD}`) + .select(grouped ? DIRECTION : "'ALL'", "dir") + .addSelect("900", "sn") + .addSelect("'Bulk cargo'", "section") + .addSelect("COALESCE(ct.cargo_type_name, 'Unclassified')", "metric") + .addSelect(wagons(), "value") + .groupBy("ct.cargo_type_name"); + if (grouped) qb.addGroupBy(DIRECTION); + return qb; +} + +export const portWarehouseSummaryReport: ReportDefinition = { + key: "port-warehouse-summary", + title: "Port Warehouse Operations Summary", + description: + "The monthly count sheet a port warehouse publishes: trains, containers by size and " + + "laden state, wagons and TEU, each split into export and import beside an overall total, " + + "followed " + + "by bulk cargo wagons per cargo type. Pick a station to report one warehouse and a date " + + "range to report one month. Counted from the marshalling record — what was actually put " + + "on the train. Wagons are counted distinctly rather than derived from container counts, " + + "so a part-loaded wagon counts once. Total is counted over everything rather than summed " + + "across the direction columns, because a train carrying both an import and an export " + + "booking belongs to both and would otherwise count twice. Demurrage is billed on invoice lines and is not " + + "part of this report; use Revenue by Category filtered to Demurrage.", + group: "Operations", + filters: [ + ...OPERATIONS_FILTERS, + { + key: "station", + label: "Station / warehouse", + type: "select", + optionsQuery: yardOptions, + }, + ], + columns: [ + { key: "sn", label: "S/N", type: "number", sortable: true }, + { key: "section", label: "Section", type: "string", sortable: true }, + { + key: "metric", + label: "Name of operation", + type: "string", + sortable: true, + }, + { key: "export", label: "Export", type: "number", sortable: true }, + { key: "import", label: "Import", type: "number", sortable: true }, + { key: "domestic", label: "Domestic", type: "number", sortable: true }, + { key: "total", label: "Total", type: "number", sortable: true }, + ], + defaultSort: { key: "sn", dir: "ASC" }, + query(ctx) { + const aggs = [aggregate(ctx, true), aggregate(ctx, false)]; + const bulks = [bulkByCargoType(ctx, true), bulkByCargoType(ctx, false)]; + + // The fixed lines, unpivoted. sn is the line's position in LINES, so the + // sheet keeps the workbook's order regardless of what the values are. + const values = LINES.map( + (l, i) => + `(${i + 1}, '${l.section}', '${l.label.replace(/'/g, "''")}', (${l.value})::int)`, + ).join(",\n "); + + const long = [ + ...aggs.map( + (agg) => ` + SELECT p.dir, v.sn, v.section, v.metric, v.value + FROM (${agg.getQuery()}) p + CROSS JOIN LATERAL (VALUES + ${values} + ) AS v(sn, section, metric, value)`, + ), + ...bulks.map( + (bulk) => + `SELECT bq.dir, bq.sn, bq.section, bq.metric, bq.value FROM (${bulk.getQuery()}) bq`, + ), + ].join("\n UNION ALL\n"); + + const dirSum = (dir: string): string => + `(COALESCE(SUM(l.value) FILTER (WHERE l.dir = '${dir}'), 0))::int`; + + return ( + ctx.ds + .createQueryBuilder() + .from(`(${long})`, "l") + .setParameters( + Object.assign( + {}, + ...[...aggs, ...bulks].map((qb) => qb.getParameters()), + ), + ) + // Renumbered after grouping so the bulk lines continue the sheet's + // numbering instead of all sharing the 900 that ordered them. + .select("(ROW_NUMBER() OVER (ORDER BY l.sn, l.metric))::int", "sn") + .addSelect("l.section", "section") + .addSelect("l.metric", "metric") + .addSelect(dirSum("EXPORT"), "export") + .addSelect(dirSum("IMPORT"), "import") + .addSelect(dirSum("DOMESTIC"), "domestic") + .addSelect(dirSum("ALL"), "total") + .groupBy("l.sn") + .addGroupBy("l.section") + .addGroupBy("l.metric") + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(trains(), "trains") + .addSelect(wagons(), "wagons") + .addSelect( + `${boxes(ALLOC_CONTAINERS_20, IS_CONTAINER_LOAD)} + ${boxes(ALLOC_CONTAINERS_40, IS_CONTAINER_LOAD)} * 2`, + "teu", + ) + .getRawOne<{ trains: number; wagons: number; teu: number }>(); + + return [ + { label: "Trains", value: Number(row?.trains ?? 0) }, + { label: "Wagons", value: Number(row?.wagons ?? 0) }, + { label: "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 fc404738d..53273fa69 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -33,6 +33,7 @@ import { teuPerformanceReport } from "./definitions/teu-performance.report"; import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report"; import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report"; import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report"; +import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report"; import { ReportDefinition } from "./report.types"; /** @@ -75,6 +76,7 @@ export const REPORTS: ReportDefinition[] = [ cargoVolumePerformanceReport, chargedVsActualVolumeReport, cargoVolumeByStationReport, + portWarehouseSummaryReport, ]; const BY_KEY = new Map( 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 8204ee2d3..b7e0ecaf2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -97,6 +97,7 @@ const SEEDED_REPORT_KEYS = [ "cargo-volume-performance", "charged-vs-actual-volume", "cargo-volume-by-station", + "port-warehouse-summary", ] as const; /**