From e964a9b8f47314c1443ec62c82c137f52fdbbc12 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 19 Aug 2026 10:14:46 +0000 Subject: [PATCH] chore: more reporting --- .../payment-classification.report.ts | 76 +++ .../receivables-payables.report.ts | 114 ++++ .../definitions/revenue-anomalies.report.ts | 102 ++++ .../definitions/revenue-by-category.report.ts | 121 ++++ .../definitions/revenue-by-period.report.ts | 112 ++++ .../definitions/revenue-by-route.report.ts | 79 +++ .../revenue-reconciliation.report.ts | 93 +++ .../revenue-top-customers.report.ts | 70 +++ .../revenue-transactions.report.ts | 131 +++++ .../src/modules/reports/report.registry.ts | 18 + .../src/modules/reports/report.types.ts | 20 + .../src/modules/reports/reports.controller.ts | 43 +- .../reports/revenue-classification.spec.ts | 102 ++++ .../modules/reports/revenue-classification.ts | 553 ++++++++++++++++++ .../src/seed/freight-permissions.registry.ts | 9 + apps/edr-freight-web/backoffice/src/App.tsx | 4 +- .../src/components/reports/ReportSection.tsx | 6 +- .../pages/reports/ReportsIndexRedirect.tsx | 17 - .../src/pages/reports/ReportsLandingPage.tsx | 51 ++ .../backoffice/src/types/reports.ts | 11 + 20 files changed, 1707 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts create mode 100644 apps/edr-freight-api/src/modules/reports/revenue-classification.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/ReportsIndexRedirect.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts new file mode 100644 index 000000000..4a77521b5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts @@ -0,0 +1,76 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + PAID_SHARE, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + PAYMENT_CLASS_LABEL_EXPR, + PERIOD_FILTER, + REVENUE_FILTERS, + REVENUE_SUM, + currencyOf, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = revenueLedgerQb(ctx); + const classes = ctx.params.classes as string[] | null; + if (classes?.length) { + qb.andWhere(`${PAYMENT_CLASS_EXPR} IN (:...classes)`, { classes }); + } + return qb; +} + +export const paymentClassificationReport: ReportDefinition = { + key: 'payment-classification', + title: 'Payment Classification', + description: + 'What customers actually paid for, per period: rail transport, customs clearance, ' + + 'first/last mile, overweight, cancellation, demurrage, storage, loading and unloading, ' + + 'and additional charges. Note there is no dedicated loading/unloading charge type in ' + + 'the system — handling and double-handling fees stand in for it.', + group: 'Finance', + filters: [ + PERIOD_FILTER, + ...REVENUE_FILTERS, + { key: 'classes', label: 'Payment class', type: 'multiselect', options: PAYMENT_CLASSES }, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'paymentClass', label: 'Payment class', type: 'string', sortable: true }, + { key: 'billed', label: 'Billed', type: 'money', sortable: true }, + { key: 'settled', label: 'Settled', type: 'money', sortable: true }, + { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'billed', dir: 'DESC' }, + chart: { type: 'bar', x: 'paymentClass', y: ['billed'] }, + query(ctx) { + const period = periodExpr(ctx.params); + return baseQuery(ctx) + .select(period, 'period') + .addSelect(PAYMENT_CLASS_LABEL_EXPR, 'paymentClass') + .addSelect('ROUND(SUM(il.amount))::float8', 'billed') + .addSelect(`ROUND(SUM(${PAID_SHARE}))::float8`, 'settled') + .addSelect(`ROUND(SUM(il.amount) - SUM(${PAID_SHARE}))::float8`, 'outstanding') + .addSelect('COUNT(*)::int', 'lines') + .groupBy(period) + .addGroupBy(PAYMENT_CLASS_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'billed') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'settled') + .getRawOne<{ billed: number; settled: number }>(); + const currency = currencyOf(ctx.params); + const billed = Number(row?.billed ?? 0); + const settled = Number(row?.settled ?? 0); + return [ + { label: 'Billed', value: billed, unit: currency }, + { label: 'Settled', value: settled, unit: currency }, + { label: 'Outstanding', value: Math.round(billed - settled), unit: currency }, + ]; + }, +}; 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 new file mode 100644 index 000000000..018a68faf --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -0,0 +1,114 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types'; +import { + PAYER_EXPR, + REVENUE_DATE, + REVENUE_FILTERS, + currencyOf, + invoiceLedgerQb, +} from '../revenue-classification'; + +export const LEDGER_SIDES: ReportFilterOption[] = [ + { value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, + { value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, + { value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, + { value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, + { value: 'SETTLED', label: 'Settled' }, +]; + +/** + * Which side of the ledger an invoice sits on. + * + * Receivable = EDR delivered and is owed money — the shipping-line credit + * arrangement, plus any invoice still carrying a balance. + * Payable = the customer paid for something EDR did not deliver, so the money + * is a refund liability rather than revenue: cancellation fees, and prepaid + * invoices whose booking died. + */ +const SIDE_EXPR = `CASE + WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT' + THEN 'RECEIVABLE_CREDIT' + WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION' + WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED') + THEN 'PAYABLE_UNDELIVERED' + WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN' + ELSE 'SETTLED' +END`; + +const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label])); +const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR} + ${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')} +END`; + +/** Money at stake on this row: what is owed, or what may have to be given back. */ +const EXPOSURE = `CASE + WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount + ELSE i.balance_amount +END`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = invoiceLedgerQb(ctx); + const sides = ctx.params.sides as string[] | null; + if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); + return qb; +} + +export const receivablesPayablesReport: ReportDefinition = { + key: 'receivables-payables', + title: 'Receivables and Payables', + description: + 'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + + 'including shipping-line credit services — and payable, where the customer paid but ' + + 'the service was not delivered, such as cancellation fees and prepayments against ' + + 'dead bookings. Payable amounts are a refund liability, not revenue.', + group: 'Finance', + filters: [ + ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), + { key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES }, + ], + columns: [ + { key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, + { 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' }, + { key: 'bookingStatus', label: 'Booking status', type: 'string' }, + { key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, + { key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, + { key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, + ], + defaultSort: { key: 'exposure', dir: 'DESC' }, + chart: { type: 'bar', x: 'side', y: ['exposure'] }, + query(ctx) { + return baseQuery(ctx) + .select(SIDE_LABEL_EXPR, 'side') + .addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect('i.invoice_number', 'invoiceNumber') + .addSelect("COALESCE(b.reference, '—')", 'bookingRef') + .addSelect("COALESCE(b.status, '—')", 'bookingStatus') + .addSelect(PAYER_EXPR, 'customer') + .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') + .addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') + .addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select( + `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`, + 'receivable', + ) + .addSelect( + `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`, + 'payable', + ) + .addSelect('COUNT(*)::int', 'invoices') + .getRawOne<{ receivable: number; payable: number; invoices: number }>(); + const currency = currencyOf(ctx.params); + return [ + { label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, + { label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts new file mode 100644 index 000000000..cf2d7735e --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts @@ -0,0 +1,102 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CATEGORY_LABEL_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + growthPctExpr, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +const REVENUE = 'SUM(il.amount)'; + +const THRESHOLDS = [ + { value: '10', label: '±10%' }, + { value: '25', label: '±25%' }, + { value: '50', label: '±50%' }, +]; + +const thresholdOf = (params: Record): number => { + const raw = Number(params.threshold); + return THRESHOLDS.some((t) => Number(t.value) === raw) ? raw : 25; +}; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueAnomaliesReport: ReportDefinition = { + key: 'revenue-anomalies', + title: 'Revenue Anomalies', + description: + 'Periods where a revenue category moved more than the chosen threshold against the ' + + 'previous period. Pull-based on purpose: this surfaces the spikes and drops for review ' + + 'rather than paging anyone, so the thresholds can be tuned against real numbers first.', + group: 'Finance', + filters: [ + PERIOD_FILTER, + { key: 'threshold', label: 'Threshold', type: 'select', options: THRESHOLDS }, + ...REVENUE_FILTERS, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'direction', label: 'Movement', type: 'string' }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'priorRevenue', label: 'Prior period', type: 'money' }, + { key: 'growthPct', label: 'Change', type: 'percent', sortable: true }, + ], + defaultSort: { key: 'period', dir: 'DESC' }, + query(ctx) { + const period = periodExpr(ctx.params); + // ORDER BY the same expression this query GROUPs BY — the to_char label, not + // the inner date_trunc. Ordering by the unwrapped timestamp raises + // "column i.issued_at must appear in the GROUP BY clause". The label formats + // are zero-padded, so lexicographic order is chronological order. + const prior = `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; + const change = growthPctExpr(REVENUE, prior); + + const inner = baseQuery(ctx) + .select(period, 'period') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') + .addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue') + .addSelect(change, 'growthPct') + .addSelect( + `CASE WHEN ${REVENUE} >= COALESCE(${prior}, 0) THEN 'Spike' ELSE 'Drop' END`, + 'direction', + ) + .groupBy(period) + .addGroupBy(REVENUE_CATEGORY_EXPR); + + // The threshold cannot live in WHERE or HAVING — both are evaluated before + // window functions, and `growthPct` is one. Wrapping is the only place the + // comparison is legal. A period with no predecessor yields NULL, and + // `ABS(NULL) >= n` is NULL, so those rows drop out without an extra guard. + return ctx.ds + .createQueryBuilder() + .select('a.*') + .from(`(${inner.getQuery()})`, 'a') + .setParameters(inner.getParameters()) + .where('ABS(a."growthPct") >= :threshold', { threshold: thresholdOf(ctx.params) }); + }, + + async summary(ctx) { + const [sql, params] = revenueAnomaliesReport.query(ctx).getQueryAndParameters(); + const rows: Array<{ spikes: number; drops: number }> = await ctx.ds.query( + `SELECT COUNT(*) FILTER (WHERE a.direction = 'Spike')::int AS spikes, + COUNT(*) FILTER (WHERE a.direction = 'Drop')::int AS drops + FROM (${sql}) a`, + params, + ); + return [ + { label: 'Spikes', value: Number(rows[0]?.spikes ?? 0) }, + { label: 'Drops', value: Number(rows[0]?.drops ?? 0) }, + { label: 'Threshold', value: thresholdOf(ctx.params), unit: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts new file mode 100644 index 000000000..0a46f15ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts @@ -0,0 +1,121 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + AVG_PER_UNIT_EXPR, + CATEGORY_LABEL_EXPR, + CONTAINERS_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + UNIT_LABEL_EXPR, + currencyOf, + growthPctExpr, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +const REVENUE = 'SUM(il.amount)'; + +/** + * Previous period's revenue for the same category. + * + * Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is + * legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the + * ORDER BY must repeat their grouping expressions verbatim: ordering by the + * inner `date_trunc` when the group key is the `to_char` wrapper fails, and + * ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window + * clause, silently producing an unordered partition. + */ +const priorRevenue = (period: string): string => + `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; + +const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period)); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueByCategoryReport: ReportDefinition = { + key: 'revenue-by-category', + title: 'Revenue by Category', + description: + 'Billed revenue in the twelve rail revenue categories, per period, with volume and ' + + 'period-over-period growth. Growth compares against the previous period inside the ' + + 'selected date range, so the earliest period always reads zero. ' + + 'Multimodal means a named sea carrier is on the booking.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'priorRevenue', label: 'Prior period', type: 'money' }, + { key: 'growthPct', label: 'Growth', type: 'percent' }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + { key: 'containers', label: 'Containers', type: 'number' }, + { key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' }, + { key: 'unit', label: 'Unit', type: 'string' }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'category', y: ['revenue'] }, + /** + * Row click opens the transaction list for exactly this bucket. + * + * `period` carries into `period_value` (the bucket, e.g. "2026-08"), NOT into + * `period` — that filter is the granularity, and handing it a date string + * would silently reset it to monthly. The granularity itself rides along + * from the filters already applied. + * + * `categoryKey` rather than `category`: the visible column holds the business + * label, and the target filters on the key. + */ + drill: { + to: 'revenue-transactions', + carry: { period: 'period_value', categoryKey: 'categoryKey' }, + }, + query(ctx) { + const period = periodExpr(ctx.params); + return baseQuery(ctx) + .select(period, 'period') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') + .addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue') + .addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct') + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers') + .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') + .addSelect(UNIT_LABEL_EXPR, 'unit') + .addSelect('COUNT(*)::int', 'lines') + .groupBy(period) + .addGroupBy(REVENUE_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect( + `ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${REVENUE_CATEGORY_EXPR} = 'UNCLASSIFIED'), 0))::float8`, + 'unclassified', + ) + .addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories') + // Invoice-level balance is deliberately NOT summed here — the ledger is + // at line grain, so a 3-line invoice would count its balance three times. + // Outstanding lives on `revenue-reconciliation`, at invoice grain. + .getRawOne<{ revenue: number; unclassified: number; categories: number }>(); + + const currency = currencyOf(ctx.params); + return [ + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency }, + { label: 'Categories', value: Number(row?.categories ?? 0) }, + // Always shown, even at zero: an audit report must never quietly drop money. + { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts new file mode 100644 index 000000000..541b9ef01 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts @@ -0,0 +1,112 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + currencyOf, + growthPctExpr, + nextPeriodOrdinalExpr, + periodExpr, + periodOrdinalExpr, + periodTruncExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +const REVENUE = 'SUM(il.amount)'; + +/** + * A six-period rolling linear trend, projected one period ahead. + * + * `regr_slope`/`regr_intercept` are Postgres built-ins, so this needs no + * dependency and no model store. It is a trend line, not a forecast model: no + * seasonality, no confidence interval, and meaningless on fewer than about + * four points — hence the row-count guard, which returns NULL rather than a + * confident-looking number drawn through two dots. + * + * The x variable is the period's epoch seconds, not `row_number()`: Postgres + * rejects a window function nested inside another window function's arguments + * ("window function calls cannot be nested"), and the timestamp is already a + * monotonic ordinal. + * + * ponytail: linear trend only. A seasonal model (Holt-Winters/ARIMA) means a + * stats dependency, a training story and an owner for model quality — do that + * only if the business names a seasonality requirement. + */ +const forecastNext = (params: Record): string => { + const window = `OVER (ORDER BY ${periodTruncExpr(params)} ROWS BETWEEN 5 PRECEDING AND CURRENT ROW)`; + const x = periodOrdinalExpr(params); + return `CASE WHEN count(*) ${window} >= 4 THEN GREATEST(0, ROUND( + (regr_intercept(${REVENUE}, ${x}) ${window}) + + (regr_slope(${REVENUE}, ${x}) ${window}) * ${nextPeriodOrdinalExpr(params)} + ))::float8 END`; +}; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueByPeriodReport: ReportDefinition = { + key: 'revenue-by-period', + title: 'Revenue Trend', + description: + 'Total billed revenue per period with period-over-period growth and a rolling ' + + 'six-period linear projection. The projection is a trend line, not a seasonal ' + + 'forecast, and is blank until six periods of history exist.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'priorRevenue', label: 'Prior period', type: 'money' }, + { key: 'growthPct', label: 'Growth', type: 'percent' }, + { key: 'forecastNext', label: 'Projected next', type: 'money' }, + { key: 'categories', label: 'Categories', type: 'number' }, + { key: 'tons', label: 'Tonnage', type: 'tons' }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'period', dir: 'ASC' }, + chart: { type: 'line', x: 'period', y: ['revenue', 'forecastNext'] }, + drill: { to: 'revenue-transactions', carry: { period: 'period_value' } }, + query(ctx) { + const period = periodExpr(ctx.params); + const trunc = periodTruncExpr(ctx.params); + const prior = `lag(${REVENUE}) OVER (ORDER BY ${trunc})`; + return baseQuery(ctx) + .select(period, 'period') + .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') + .addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue') + .addSelect(`COALESCE(${growthPctExpr(REVENUE, prior)}, 0)`, 'growthPct') + .addSelect(forecastNext(ctx.params), 'forecastNext') + .addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories') + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect('COUNT(*)::int', 'lines') + // Grouped by the period's start timestamp, so the ordering the windows + // above use is a grouping key rather than a bare column reference. + .groupBy(trunc); + }, + + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect(`COUNT(DISTINCT ${periodTruncExpr(ctx.params)})::int`, 'periods') + .getRawOne<{ revenue: number; periods: number }>(); + const periods = Number(row?.periods ?? 0); + const revenue = Number(row?.revenue ?? 0); + return [ + { label: 'Total revenue', value: revenue, unit: currencyOf(ctx.params) }, + { label: 'Periods', value: periods }, + { + label: 'Average per period', + value: periods ? Math.round(revenue / periods) : 0, + unit: currencyOf(ctx.params), + }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts new file mode 100644 index 000000000..3d8a9370a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts @@ -0,0 +1,79 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + AVG_PER_UNIT_EXPR, + CATEGORY_LABEL_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + UNIT_LABEL_EXPR, + currencyOf, + revenueLedgerQb, +} from '../revenue-classification'; + +/** + * There is no corridor entity in the schema — a corridor IS an + * (origin_yard, destination_yard) pair, which is exactly how rates are scoped. + */ +const CORRIDOR = `COALESCE(oy.label, 'Unknown') || ' → ' || COALESCE(dy.label, 'Unknown')`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueByRouteReport: ReportDefinition = { + key: 'revenue-by-route', + title: 'Revenue by Route', + description: + 'Billed revenue per corridor and revenue category. A corridor is an ' + + 'origin/destination station pair — the schema has no separate corridor entity.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'corridor', label: 'Corridor', type: 'string', sortable: true, sortExpr: CORRIDOR }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + { key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' }, + { key: 'unit', label: 'Unit', type: 'string' }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'corridor', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { categoryKey: 'categoryKey' } }, + query(ctx) { + return baseQuery(ctx) + .select(CORRIDOR, 'corridor') + .addSelect("COALESCE(oy.label, 'Unknown')", 'origin') + .addSelect("COALESCE(dy.label, 'Unknown')", 'destination') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect('ROUND(SUM(il.amount))::float8', 'revenue') + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') + .addSelect(UNIT_LABEL_EXPR, 'unit') + .addSelect('COUNT(*)::int', 'lines') + .groupBy(CORRIDOR) + .addGroupBy('oy.label') + .addGroupBy('dy.label') + .addGroupBy(REVENUE_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect(`COUNT(DISTINCT ${CORRIDOR})::int`, 'corridors') + .getRawOne<{ revenue: number; corridors: number }>(); + return [ + { label: 'Corridors', value: Number(row?.corridors ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, + ]; + }, +}; 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 new file mode 100644 index 000000000..73de3f505 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts @@ -0,0 +1,93 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + GATEWAY_PAID, + PAYER_EXPR, + REVENUE_DATE, + REVENUE_FILTERS, + currencyOf, + invoiceLedgerQb, +} from '../revenue-classification'; + +/** + * The gap between what the invoice says was paid and what the payment gateway + * recorded. Non-zero is not automatically wrong — manual settlements are real + * — but every one of them should be explainable, which is the point. + */ +const VARIANCE = `i.paid_amount - ${GATEWAY_PAID}`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = invoiceLedgerQb(ctx); + const matched = ctx.params.matched as string | null; + if (matched === 'matched') qb.andWhere(`ROUND(${VARIANCE}, 2) = 0`); + if (matched === 'unmatched') qb.andWhere(`ROUND(${VARIANCE}, 2) <> 0`); + return qb; +} + +export const revenueReconciliationReport: ReportDefinition = { + key: 'revenue-reconciliation', + title: 'Revenue vs Payment Reconciliation', + description: + 'Every invoice with its recorded settlement set against what the payment gateway ' + + 'actually confirmed. Invoices are matched on the booking id both sides carry — ' + + 'invoices.payment_id points at a payment-service intent, not a freight payment row. ' + + 'Invoices with no booking (warehouse, shipping-line credit) have no gateway record to ' + + 'match against and will show their full paid amount as variance.', + group: 'Finance', + filters: [ + ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), + { + key: 'matched', + label: 'Reconciliation', + type: 'select', + options: [ + { value: 'unmatched', label: 'Variance only' }, + { value: 'matched', label: 'Reconciled only' }, + ], + }, + ], + 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: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'source', label: 'Source', type: 'string', sortable: true, sortExpr: 'i.source' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, + { key: 'recordedPaid', label: 'Recorded paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, + { key: 'gatewayPaid', label: 'Gateway paid', type: 'money' }, + { key: 'variance', label: 'Variance', type: 'money', sortable: true, sortExpr: `ABS(${VARIANCE})` }, + { key: 'balance', label: 'Outstanding', type: 'money', sortable: true, sortExpr: 'i.balance_amount' }, + ], + defaultSort: { key: 'variance', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect('i.invoice_number', 'invoiceNumber') + .addSelect("COALESCE(b.reference, '—')", 'bookingRef') + .addSelect(PAYER_EXPR, 'customer') + .addSelect('i.source', 'source') + .addSelect('i.status', 'status') + .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') + .addSelect('ROUND(i.paid_amount, 2)::float8', 'recordedPaid') + .addSelect(`ROUND(${GATEWAY_PAID}, 2)::float8`, 'gatewayPaid') + .addSelect(`ROUND(${VARIANCE}, 2)::float8`, 'variance') + .addSelect('ROUND(i.balance_amount, 2)::float8', 'balance'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect(`COUNT(*) FILTER (WHERE ROUND(${VARIANCE}, 2) <> 0)::int`, 'withVariance') + .addSelect(`ROUND(COALESCE(SUM(ABS(${VARIANCE})), 0))::float8`, 'variance') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding') + .getRawOne<{ invoices: number; withVariance: number; variance: number; outstanding: number }>(); + const currency = currencyOf(ctx.params); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'With variance', value: Number(row?.withVariance ?? 0) }, + { label: 'Total variance', value: Number(row?.variance ?? 0), unit: currency }, + { label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: currency }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts new file mode 100644 index 000000000..ae27d7487 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts @@ -0,0 +1,70 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CATEGORY_LABEL_EXPR, + PAYER_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + currencyOf, + revenueLedgerQb, +} from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueTopCustomersReport: ReportDefinition = { + key: 'revenue-top-customers', + title: 'Top Customers by Revenue', + description: + 'Customers ranked by billed revenue, with their category mix and outstanding share. ' + + 'The payer is the company or, for shipping-line credit invoices, the shipping line — ' + + 'an invoice carries exactly one of the two.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'sharePct', label: 'Share of total', type: 'percent' }, + { key: 'tons', label: 'Tonnage', type: 'tons' }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'customer', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { customer: 'customer', categoryKey: 'categoryKey' } }, + query(ctx) { + return baseQuery(ctx) + .select(PAYER_EXPR, 'customer') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect('ROUND(SUM(il.amount))::float8', 'revenue') + // Share of the whole filtered set, not of the page — a window over no + // partition sees every group the query produced. + .addSelect( + 'ROUND(100 * SUM(il.amount) / NULLIF(SUM(SUM(il.amount)) OVER (), 0), 1)::float8', + 'sharePct', + ) + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect('COUNT(DISTINCT i.id)::int', 'invoices') + .groupBy(PAYER_EXPR) + .addGroupBy(REVENUE_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers') + .getRawOne<{ revenue: number; customers: number }>(); + return [ + { label: 'Customers', value: Number(row?.customers ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, + ]; + }, +}; 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 new file mode 100644 index 000000000..ad96b5b9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -0,0 +1,131 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + PAYMENT_CLASS_EXPR, + PAYER_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORIES, + REVENUE_CATEGORY_EXPR, + REVENUE_DATE, + REVENUE_FILTERS, + REVENUE_SUM, + currencyOf, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +/** + * The gateway payment behind an invoice, for traceability. `invoices.payment_id` + * points at a payment-api intent id rather than a `freight.payments` row, so the + * reliable link is `payments.ref_id = invoices.source_id` (the booking id). + * + * Correlated scalar subselects rather than a LATERAL join: TypeORM's query + * builder cannot emit LATERAL, and the correlation on `i.source_id` is what + * makes this work at all. Successful payments win, then most recent. + * + * `::text` is not cosmetic — `payments.method` and `payments.status` are real + * Postgres enums, so `COALESCE(, '')` fails with + * `invalid input value for enum freight.payments_method_enum: ""`. + */ +const latestPayment = (column: string): string => `( + SELECT p.${column}::text FROM freight.payments p + WHERE p.ref_id = i.source_id + ORDER BY (p.status = 'success') DESC, p.created_at DESC + LIMIT 1 +)`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = revenueLedgerQb(ctx); + + // Drill-down target: the summary reports hand over the exact period bucket + // and category key they were showing. + if (params.period_value) { + qb.andWhere(`${periodExpr(params)} = :periodValue`, { periodValue: params.period_value }); + } + if (params.categoryKey) { + qb.andWhere(`${REVENUE_CATEGORY_EXPR} = :categoryKey`, { categoryKey: params.categoryKey }); + } + return qb; +} + +export const revenueTransactionsReport: ReportDefinition = { + 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', + filters: [ + PERIOD_FILTER, + ...REVENUE_FILTERS, + { key: 'period_value', label: 'Period bucket', type: 'text' }, + { + 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: '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' }, + { 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' }, + query(ctx) { + return baseQuery(ctx) + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, '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') + .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', + ) + .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') + .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) }, + ]; + }, +}; 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 004b61e5b..da280a271 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -22,6 +22,15 @@ import { invoicesByStatusReport } from './definitions/invoices-by-status.report' import { paymentsByStatusReport } from './definitions/payments-by-status.report'; import { revenueSummaryReport } from './definitions/revenue-summary.report'; import { cargoSummaryReport } from './definitions/cargo-summary.report'; +import { revenueByCategoryReport } from './definitions/revenue-by-category.report'; +import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; +import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; +import { revenueByRouteReport } from './definitions/revenue-by-route.report'; +import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; +import { paymentClassificationReport } from './definitions/payment-classification.report'; +import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; +import { receivablesPayablesReport } from './definitions/receivables-payables.report'; +import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; import { ReportDefinition } from './report.types'; /** @@ -53,6 +62,15 @@ export const REPORTS: ReportDefinition[] = [ paymentsByStatusReport, revenueSummaryReport, cargoSummaryReport, + revenueByCategoryReport, + revenueTransactionsReport, + revenueByPeriodReport, + revenueByRouteReport, + revenueTopCustomersReport, + paymentClassificationReport, + revenueReconciliationReport, + receivablesPayablesReport, + revenueAnomaliesReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts index a709ac654..ca7578461 100644 --- a/apps/edr-freight-api/src/modules/reports/report.types.ts +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -34,6 +34,24 @@ export interface ReportFilterDef { type: ReportFilterType; /** Static option list for select/multiselect. */ options?: ReportFilterOption[]; + /** + * Resolves the option list from the database instead of declaring it inline — + * for filters whose choices are reference data (stations, cargo types). + * Called once per catalog request and cached; the result is serialised into + * `options`, so the frontend never sees the difference. + */ + optionsQuery?: (ds: DataSource) => Promise; +} + +/** + * Makes a summary row clickable: the row's values are carried into another + * report as filter params, which is how "drill down from summary to + * transaction level" works. Keys are this report's column keys; values are the + * target report's filter keys. + */ +export interface ReportDrillDef { + to: ReportKey; + carry: Record; } export interface ReportKpi { @@ -90,6 +108,8 @@ export interface ReportDefinition { summary?(ctx: ReportContext): Promise; /** Optional chart view of the same rows. Table remains the default view. */ chart?: ReportChartDef; + /** Makes rows clickable, navigating to a transaction-level report. */ + drill?: ReportDrillDef; } /** Catalog shape served by GET /reports — metadata only, no rows. */ diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index 4d213bc2b..107e11097 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { CurrentUser } from '@edr/api-common'; import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -12,13 +14,44 @@ import { ReportExportService } from './report-export.service'; import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util'; import { RawReportQuery, ReportRunnerService } from './report-runner.service'; import { REPORTS, getReport } from './report.registry'; -import { ReportCatalogEntry, ReportDefinition } from './report.types'; +import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types'; const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => { const { query: _query, summary, ...meta } = def; return { ...meta, hasSummary: Boolean(summary) }; }; +/** + * Filters whose choices are reference data resolve their options here rather + * than declaring them inline, so the catalog the frontend receives looks the + * same either way. Cached for the process lifetime — these are small, rarely + * changing lists (23 stations, 18 cargo types), and the catalog is hit on + * every page load. + */ +const optionsCache = new Map(); + +async function resolveFilterOptions( + def: ReportCatalogEntry, + ds: DataSource, +): Promise { + if (!def.filters.some((f) => f.optionsQuery)) return def; + + const filters = await Promise.all( + def.filters.map(async (filter) => { + if (!filter.optionsQuery) return filter; + let options = optionsCache.get(filter.key); + if (!options) { + options = await filter.optionsQuery(ds); + optionsCache.set(filter.key, options); + } + // Drop the resolver itself — it is a function and would not serialise. + const { optionsQuery: _resolver, ...rest } = filter; + return { ...rest, options }; + }), + ); + return { ...def, filters }; +} + @ApiTags('Reports') @ApiBearerAuth() @Controller('reports') @@ -28,14 +61,16 @@ export class ReportsController { private readonly runner: ReportRunnerService, private readonly exportService: ReportExportService, private readonly userTradeAccessService: UserTradeAccessService, + @InjectDataSource() private readonly dataSource: DataSource, ) {} @Get() @ApiOperation({ summary: 'List reports the caller has permission to run' }) async catalog(@CurrentUser() user: TCurrentUser): Promise { - return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map( - toCatalogEntry, - ); + const allowed = REPORTS.filter((def) => + hasFreightPermission(user, reportPermissionKey(def.key)), + ).map(toCatalogEntry); + return Promise.all(allowed.map((def) => resolveFilterOptions(def, this.dataSource))); } @Get(':key') diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts new file mode 100644 index 000000000..42475d591 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -0,0 +1,102 @@ +import { + BULK_FREIGHT_CHARGES, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORIES, + REVENUE_CATEGORY_EXPR, + periodExpr, +} from './revenue-classification'; + +/** + * `invoice_lines.charge_type` is an unconstrained varchar written by eight + * unrelated code paths. Nothing at the type level stops someone adding a ninth + * spelling, whose revenue would then land silently in the ELSE arm. + * + * This list is every value the codebase writes today. When it grows, these + * tests are what fail — which is the whole trade the const-map design makes. + */ +const KNOWN_CHARGE_TYPES = [ + // booking base freight (rate_type codes) + 'CONTAINER_IMPORT', 'CONTAINER_EXPORT', 'CONTAINER_20FT', 'CONTAINER_40FT', + 'BULK_IMPORT', 'BULK_EXPORT', 'INTERCITY_BULK', 'INTERCITY_CONTAINER', 'FREIGHT', + // surcharges + 'FUEL_SURCHARGE', 'LASHING', 'OVERWEIGHT_PER_TON', 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', 'RETURN_SURCHARGE', 'RETURN_SURCHARGE_20FT', + 'RETURN_SURCHARGE_40FT', 'CONTAINER_WITH_RETURN', 'ADJUSTMENT', 'RATE_ADJUSTMENT', + // customs + 'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE_20FT', 'CUSTOMS_CLEARANCE_40FT', + // mile legs + 'FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE', + // warehouse fees + 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'DEMURRAGE', 'STORAGE_FEE', + 'HANDLING_FEE', 'DOUBLE_HANDLING', 'TRUCK_DETENTION', + // other producers + 'CANCELLATION_FEE', 'SHIPPING_LINE_SERVICE', +]; + +/** + * Does the expression name this charge type — either as a literal or through + * one of its `LIKE 'PREFIX%'` arms? + * + * Deliberately a substring check, not a SQL parser: a parser would be more + * fragile than the expression it is guarding. This catches the failure that + * actually happens (a new charge type nobody added to the map) and nothing + * pretends it verifies the branch order. + */ +function isNamed(expr: string, chargeType: string): boolean { + if (expr.includes(`'${chargeType}'`)) return true; + return [...expr.matchAll(/LIKE '([^']*)%'/g)].some(([, prefix]) => + chargeType.startsWith(prefix), + ); +} + +describe('revenue classification', () => { + it('names every charge type the codebase writes in the payment-class map', () => { + const unmapped = KNOWN_CHARGE_TYPES.filter((c) => !isNamed(PAYMENT_CLASS_EXPR, c)); + expect(unmapped).toEqual([]); + }); + + it('names every ancillary charge type in the revenue-category map', () => { + // Bulk freight lines carry no category of their own — the CASE falls + // through to the booking's cargo type and trade direction for those. + const cargoDerived = new Set(BULK_FREIGHT_CHARGES); + const unmapped = KNOWN_CHARGE_TYPES.filter( + (c) => !cargoDerived.has(c) && !isNamed(REVENUE_CATEGORY_EXPR, c), + ); + expect(unmapped).toEqual([]); + }); + + it('emits only categories that are offered as filter options', () => { + const declared = new Set(REVENUE_CATEGORIES.map((c) => c.value)); + const emitted = [...REVENUE_CATEGORY_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]); + expect(emitted.length).toBeGreaterThan(0); + expect(emitted.filter((c) => !declared.has(c))).toEqual([]); + expect(declared.has('UNCLASSIFIED')).toBe(true); + }); + + it('emits only payment classes that are offered as filter options', () => { + const declared = new Set(PAYMENT_CLASSES.map((c) => c.value)); + const emitted = [...PAYMENT_CLASS_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]); + expect(emitted.filter((c) => !declared.has(c))).toEqual([]); + expect(declared.has('ADDITIONAL')).toBe(true); + }); + + it('falls back to a whitelisted period unit instead of interpolating input', () => { + expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); + expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); + // Anything unrecognised — including an injection attempt — becomes 'month'. + expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( + "date_trunc('month'", + ); + expect(periodExpr({})).toContain("date_trunc('month'"); + }); + + it('offers exactly the period units the expression understands', () => { + const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); + expect(offered.length).toBe(5); + for (const unit of offered) { + expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts new file mode 100644 index 000000000..ebe52ef6b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -0,0 +1,553 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Company } from '../companies/entities/company.entity'; +import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; + +/** + * The shared vocabulary and SQL behind every revenue report. + * + * The fact table is `invoice_lines`, not `bookings`: `charge_type` is the only + * column in the system that separates customs, first/last mile, demurrage, + * storage and incidental revenue from base freight. A booking total is one + * lump sum and cannot answer the revenue-classification requirement. + * + * Every consumer builds its FROM through {@link revenueLedgerQb}, so the table + * aliases below (`il i b ct oy dy co slc`) are a fixed contract and the SQL + * fragments here can reference them directly. + */ + +// --------------------------------------------------------------------------- +// Revenue categories +// --------------------------------------------------------------------------- + +export const REVENUE_CATEGORIES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' }, + { value: 'CONTAINER_EXPORT', label: 'Full Container Export' }, + { value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' }, + { value: 'FERTILIZER', label: 'Fertilizer Transportation' }, + { value: 'BREAK_BULK', label: 'Break Bulk (Steel, Machineries)' }, + { value: 'RORO', label: 'RoRo Transportation' }, + { value: 'OTHER_IMPORT_BULK', label: 'Other Import Bulk Cargo' }, + { value: 'OTHER_EXPORT_CARGO', label: 'Other Export Cargo' }, + { value: 'DOMESTIC', label: 'Domestic Cargo Transportation' }, + { value: 'INCIDENTAL', label: 'Incidental Charges' }, + { value: 'FIRST_LAST_MILE', label: 'First & Last Mile' }, + { value: 'CUSTOMS_CLEARANCE', label: 'Customs Clearance' }, + { value: 'UNCLASSIFIED', label: 'Unclassified' }, +]; + +/** + * `charge_type` is an unconstrained varchar written by eight different code + * paths, so the same concept arrives under several spellings — three for + * demurrage, four for first/last mile. Every set below absorbs all of them. + */ +export const MILE_CHARGES = ['FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE']; + +export const INCIDENTAL_CHARGES = [ + 'FUEL_SURCHARGE', + 'LASHING', + 'OVERWEIGHT_PER_TON', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', + 'CANCELLATION_FEE', + 'ADJUSTMENT', + 'RATE_ADJUSTMENT', + 'DEMURRAGE', + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'TRUCK_DETENTION', + 'STORAGE_FEE', + 'HANDLING_FEE', + 'DOUBLE_HANDLING', + 'SHIPPING_LINE_SERVICE', +]; + +export const DOMESTIC_CHARGES = ['INTERCITY_BULK', 'INTERCITY_CONTAINER']; + +export const CONTAINER_FREIGHT_CHARGES = [ + 'CONTAINER_IMPORT', + 'CONTAINER_EXPORT', + 'CONTAINER_20FT', + 'CONTAINER_40FT', +]; + +export const BULK_FREIGHT_CHARGES = ['BULK_IMPORT', 'BULK_EXPORT', 'FREIGHT']; + +/** Cargo codes the business bills as break bulk, wherever the cargo tree puts them. */ +export const BREAK_BULK_CODES = ['STEEL_BILLET', 'STEEL', 'MACHINERY', 'PIPES', 'TIMBER']; + +export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO']; + +export const FERTILIZER_CODES = ['FERTILIZER']; + +/** + * Multimodal means EDR carried the sea leg as well as the rail leg. Nothing in + * the schema says so directly; a named sea carrier on the booking is the + * agreed proxy. One constant, deliberately — flip it here if the business + * defines multimodality differently. + */ +const MULTIMODAL_PREDICATE = 'b.shipping_line_id IS NOT NULL'; + +const list = (values: string[]): string => values.map((v) => `'${v}'`).join(', '); + +/** + * Assigns each invoice line exactly one revenue category. First match wins. + * + * Charge-derived rules run BEFORE cargo-derived ones on purpose: a customs or + * demurrage line billed on a container-import booking is customs/incidental + * revenue, not container-import revenue. Reversing the order would fold every + * ancillary charge back into the freight categories. + * + * Nothing falls through silently — an unmatched line lands in UNCLASSIFIED and + * every report surfaces that total as a KPI, because an audit report must + * never quietly drop money. + */ +export const REVENUE_CATEGORY_EXPR = `CASE + WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE' + WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE' + WHEN il.charge_type LIKE 'RETURN_SURCHARGE%' + OR il.charge_type = 'CONTAINER_WITH_RETURN' THEN 'EMPTY_CONTAINER_REEXPORT' + WHEN il.charge_type IN (${list(INCIDENTAL_CHARGES)}) THEN 'INCIDENTAL' + WHEN il.charge_type IN (${list(DOMESTIC_CHARGES)}) + OR (oy.country IS NOT NULL AND oy.country = dy.country) THEN 'DOMESTIC' + WHEN il.charge_type IN (${list(CONTAINER_FREIGHT_CHARGES)}) + OR b.freight_type = 'CONTAINER' THEN + CASE + WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${MULTIMODAL_PREDICATE} THEN 'CONTAINER_IMPORT_MULTIMODAL' + ELSE 'CONTAINER_IMPORT_UNIMODAL' + END + WHEN ct.code IN (${list(FERTILIZER_CODES)}) THEN 'FERTILIZER' + WHEN ct.code IN (${list(BREAK_BULK_CODES)}) THEN 'BREAK_BULK' + WHEN ct.code IN (${list(RORO_CODES)}) THEN 'RORO' + WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO' + WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK' + ELSE 'UNCLASSIFIED' +END`; + +const labelCase = (expr: string, options: ReportFilterOption[]): string => + `CASE ${expr}\n ${options + .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) + .join('\n ')}\nEND`; + +/** The category as a business label rather than its key, for display columns. */ +export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES); + +/** + * Period-over-period change, as a percentage. + * + * The denominator is `ABS(prior)`, not `prior`. A category can post negative + * revenue in a period — a credit note or rate adjustment outweighing its + * charges — and dividing by a negative prior flips the sign, reporting a + * recovery as a decline. Taking the magnitude keeps the sign of the change + * itself. + */ +export const growthPctExpr = (revenue: string, prior: string): string => + `ROUND(100 * (${revenue} - ${prior}) / NULLIF(ABS(${prior}), 0), 1)::float8`; + +// --------------------------------------------------------------------------- +// Payment classification +// --------------------------------------------------------------------------- + +export const PAYMENT_CLASSES: ReportFilterOption[] = [ + { value: 'RAIL_TRANSPORT', label: 'Rail transport' }, + { value: 'CUSTOMS_CLEARANCE', label: 'Custom clearance' }, + { value: 'FIRST_LAST_MILE', label: 'First and last mile' }, + { value: 'OVERWEIGHT', label: 'Overweight' }, + { value: 'CANCELLATION', label: 'Cancellation' }, + { value: 'DEMURRAGE', label: 'Demurrage' }, + { value: 'STORAGE', label: 'Storage' }, + { value: 'LOADING_UNLOADING', label: 'Loading and unloading' }, + { value: 'ADDITIONAL', label: 'Additional payment' }, +]; + +const RAIL_CHARGES = [...CONTAINER_FREIGHT_CHARGES, ...BULK_FREIGHT_CHARGES, ...DOMESTIC_CHARGES]; + +const DEMURRAGE_CHARGES = ['DEMURRAGE', 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'TRUCK_DETENTION']; + +/** + * Charges that legitimately belong in the spec's "additional payment" bucket. + * + * Listed explicitly rather than left to the ELSE arm: ELSE also catches charge + * types nobody has mapped yet, and those two cases must not be + * indistinguishable. Naming these is what lets the spec fail when a genuinely + * new charge type appears. + */ +export const ADDITIONAL_CHARGES = [ + 'FUEL_SURCHARGE', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', + 'RETURN_SURCHARGE', + 'RETURN_SURCHARGE_20FT', + 'RETURN_SURCHARGE_40FT', + 'CONTAINER_WITH_RETURN', + 'ADJUSTMENT', + 'RATE_ADJUSTMENT', + 'SHIPPING_LINE_SERVICE', +]; + +/** + * The nine buckets the revenue spec asks payments to be classified into. + * + * Caveat worth repeating wherever this is shown: there is no dedicated + * loading/unloading charge type in the system. HANDLING_FEE, DOUBLE_HANDLING + * and LASHING are the nearest equivalent, so that bucket is an approximation, + * not an exact match. + */ +export const PAYMENT_CLASS_EXPR = `CASE + WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE' + WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE' + WHEN il.charge_type IN (${list(RAIL_CHARGES)}) THEN 'RAIL_TRANSPORT' + WHEN il.charge_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT' + WHEN il.charge_type = 'CANCELLATION_FEE' THEN 'CANCELLATION' + WHEN il.charge_type IN (${list(DEMURRAGE_CHARGES)}) THEN 'DEMURRAGE' + WHEN il.charge_type = 'STORAGE_FEE' THEN 'STORAGE' + WHEN il.charge_type IN ('HANDLING_FEE', 'DOUBLE_HANDLING', 'LASHING') + THEN 'LOADING_UNLOADING' + WHEN il.charge_type IN (${list(ADDITIONAL_CHARGES)}) THEN 'ADDITIONAL' + ELSE 'ADDITIONAL' +END`; + +// --------------------------------------------------------------------------- +// Period granularity +// --------------------------------------------------------------------------- + +/** + * Frozen whitelist. The runner coerces a `select` filter to a trimmed string + * or null; that string is used only as an object key here, so the user's value + * never reaches SQL — one of five compile-time constants does. + * + * Every format is zero-padded, so lexicographic order equals chronological + * order. The growth window depends on that. + */ +const PERIOD_UNITS = { + day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, + week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, + month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, + // `quarter` is a valid date_trunc unit but NOT a valid interval unit — + // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. + quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, + year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, +} as const; + +export const PERIOD_FILTER: ReportFilterDef = { + key: 'period', + label: 'Granularity', + type: 'select', + options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })), +}; + +/** The timestamp every revenue report buckets and filters on. */ +export const REVENUE_DATE = 'COALESCE(i.issued_at, i.created_at)'; + +/** + * The period label expression, as a string. + * + * Callers must reuse the returned string VERBATIM in the select, the GROUP BY + * and any window `ORDER BY`. Two traps make this non-negotiable: + * + * 1. A window `ORDER BY date_trunc(...)` when the group key is `to_char(date_trunc(...))` + * fails with "column i.issued_at must appear in the GROUP BY clause". + * 2. Ordinal shorthand — `OVER (PARTITION BY 2 ORDER BY 1)` — is NOT a + * positional reference inside a window clause. Postgres reads the integers + * as constants, so it partitions by a constant and applies no ordering. It + * type-checks, it EXPLAINs clean, and it returns plausible garbage. + */ +export function periodExpr(params: Record): string { + const unit = resolvePeriod(params); + return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`; +} + +function resolvePeriod(params: Record): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { + const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; + return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; +} + +/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ +export const periodTruncExpr = (params: Record): string => + `date_trunc('${resolvePeriod(params).trunc}', ${REVENUE_DATE})`; + +/** + * The period as a number, for regression: seconds since epoch at the period's + * start. Using the timestamp itself rather than `row_number()` keeps a trend + * calculation to a single window level — Postgres rejects a window function + * nested inside another window function's arguments. + */ +export const periodOrdinalExpr = (params: Record): string => + `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; + +/** Same scale, one period later — where a one-step-ahead projection lands. */ +export const nextPeriodOrdinalExpr = (params: Record): string => + `EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; + +// --------------------------------------------------------------------------- +// Volume — measured at line grain, never joined from the booking +// --------------------------------------------------------------------------- + +/** + * `invoice_lines.quantity` already carries the billed quantity per line, and + * `metadata->>'unit'` says what it counts (PER_TON / PER_CONTAINER / PER_WAGON). + * Joining booking-level tonnage instead would multiply it by the number of + * lines on the booking. + */ +export const TONS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_TON')`; + +export const CONTAINERS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`; + +/** + * TEU is never stored. It is derived from the charge code's size suffix; lines + * whose code carries no size (CONTAINER_IMPORT / CONTAINER_EXPORT) count as one + * TEU each, which under-counts any 40ft box billed under an unsized code. + */ +export const TEU_EXPR = `SUM(il.quantity * CASE WHEN il.charge_type LIKE '%40FT%' THEN 2 ELSE 1 END) + FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`; + +/** + * Revenue per unit, against whichever unit the category is actually billed in. + * Exactly one of tons/TEU is non-null per category, so this is per-ton for bulk + * and per-TEU for containers; the `unit` column says which. + */ +export const AVG_PER_UNIT_EXPR = `ROUND( + SUM(il.amount) / NULLIF(COALESCE(${TONS_EXPR}, 0) + COALESCE(${TEU_EXPR}, 0), 0), 2 +)::float8`; + +export const UNIT_LABEL_EXPR = `CASE + WHEN COALESCE(${TONS_EXPR}, 0) > 0 THEN 'per ton' + WHEN COALESCE(${TEU_EXPR}, 0) > 0 THEN 'per TEU' + ELSE '' +END`; + +// --------------------------------------------------------------------------- +// The shared ledger query +// --------------------------------------------------------------------------- + +/** Invoice states that never represent recognised revenue. */ +const DEAD_INVOICE_STATUSES = ['DRAFT', 'CANCELLED']; + +export const PAYMENT_METHOD_OPTIONS: ReportFilterOption[] = [ + 'telebirr', + 'cbe-birr', + 'cbe-bill', + 'ebirr', + 'waafi', + 'dmoney', + 'cac-bank', + 'card', +].map((v) => ({ value: v, label: v })); + +export const CURRENCY_FILTER: ReportFilterDef = { + key: 'currency', + label: 'Currency', + type: 'select', + options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ], +}; + +/** + * The filter set shared by every revenue report, so they drill into each other + * without losing context. + * + * `currency` is not optional decoration: the ledger holds both ETB and USD + * lines, and summing across them produces a number that means nothing. It + * defaults to ETB in {@link revenueLedgerQb} rather than being left blank. + */ +export const REVENUE_FILTERS: ReportFilterDef[] = [ + { key: 'date', label: 'Issued', type: 'daterange' }, + CURRENCY_FILTER, + { + key: 'categories', + label: 'Revenue category', + type: 'multiselect', + options: REVENUE_CATEGORIES, + }, + { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, + { key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, + { key: 'customer', label: 'Customer / booking ref', type: 'text' }, + { + key: 'methods', + label: 'Payment method', + type: 'multiselect', + options: PAYMENT_METHOD_OPTIONS, + }, +]; + +/** Stations are reference data — 23 rows that change about yearly. */ +export async function yardOptions(ds: ReportContext['ds']): Promise { + return ds + .createQueryBuilder() + .from(Yard, 'y') + .select('y.code', 'value') + .addSelect('y.label', 'label') + .where('y.deleted_at IS NULL AND y.is_active') + .orderBy('y.display_order', 'ASC') + .getRawMany(); +} + +/** Currency the ledger reports in when the caller does not choose one. */ +export const DEFAULT_CURRENCY = 'ETB'; + +export const currencyOf = (params: Record): string => + (params.currency as string) || DEFAULT_CURRENCY; + +/** + * Every revenue report starts here: one invoice line joined out to the booking + * that explains it. Bookings are LEFT joined on purpose — warehouse, demurrage + * and shipping-line-credit invoices carry no booking and must still be counted. + * + * The booking join is `i.source_id = b.id::text`, never `i.source_id::uuid`: + * `source_id` is a varchar with no FK that holds non-UUID values for other + * sources (`eims-self-test-…`), so casting it would throw at runtime. + */ +export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(InvoiceLine, 'il') + .innerJoin(Invoice, 'i', 'i.id = il.invoice_id AND i.deleted_at IS NULL') + .leftJoin( + Booking, + 'b', + "i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL", + ) + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = i.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id') + .where('il.deleted_at IS NULL') + .andWhere('i.status NOT IN (:...deadInvoiceStatuses)', { + deadInvoiceStatuses: DEAD_INVOICE_STATUSES, + }) + .andWhere("i.source <> 'eims_self_test'") + // An umbrella general contract is paid once and drawn down by many orders; + // counting both double-counts its value. + .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") + // Mixing ETB and USD into one SUM produces a meaningless number. + .andWhere('il.currency = :currency', { currency: currencyOf(params) }); + + if (params.dateFrom) { + qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + } + if (params.dateTo) { + qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); + } + + const categories = params.categories as string[] | null; + if (categories?.length) { + qb.andWhere(`${REVENUE_CATEGORY_EXPR} IN (:...categories)`, { categories }); + } + + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) { + qb.andWhere('dy.code = :destination', { destination: params.destination }); + } + + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + const methods = params.methods as string[] | null; + if (methods?.length) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.payments p + WHERE p.ref_id = i.source_id AND p.status = 'success' + AND p.method IN (:...methods))`, + { methods }, + ); + } + + // Hides lines whose booking sits outside the caller's trade scope. Lines with + // no booking carry no direction and stay visible. + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + + return qb; +} + +/** + * Invoice-grain sibling of {@link revenueLedgerQb}, for the reports that must + * not multiply an invoice by its line count — outstanding balance, + * reconciliation, receivable/payable. Same joins, same filters, minus the + * line-only ones (charge category, currency lives on the invoice here). + */ +export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .leftJoin( + Booking, + 'b', + "i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL", + ) + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = i.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id') + .where('i.deleted_at IS NULL') + .andWhere('i.status NOT IN (:...deadInvoiceStatuses)', { + deadInvoiceStatuses: DEAD_INVOICE_STATUSES, + }) + .andWhere("i.source <> 'eims_self_test'") + .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") + .andWhere('i.currency = :currency', { currency: currencyOf(params) }); + + if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + return qb; +} + +/** + * What the payment gateway actually recorded against this invoice, summed. + * `invoices.payment_id` points at a payment-api intent id rather than a + * `freight.payments` row, so the reliable link is the booking id both sides + * carry. + */ +export const GATEWAY_PAID = `( + SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p + WHERE p.ref_id = i.source_id AND p.status = 'success' +)`; + +/** The payer, whichever of the two mutually exclusive payer columns is set. */ +export const PAYER_EXPR = "COALESCE(co.name, slc.name, 'Unknown')"; + +/** `SUM(amount)`, rounded to whole currency and typed as a JS number. */ +export const REVENUE_SUM = 'ROUND(COALESCE(SUM(il.amount), 0))::float8'; + +/** + * Settled share of a line, apportioned by how much of its invoice was paid. + * Invoice-level `paid_amount` cannot be attributed to a single line any other + * way. + */ +export const PAID_SHARE = + 'il.amount * CASE WHEN i.total_amount > 0 THEN i.paid_amount / i.total_amount ELSE 0 END'; + +/** The payment class as a business label rather than its key. */ +export const PAYMENT_CLASS_LABEL_EXPR = labelCase(PAYMENT_CLASS_EXPR, PAYMENT_CLASSES); 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 380a62f02..41728e10c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -78,6 +78,15 @@ export const REPORT_KEYS = [ "payments-by-status", "revenue-summary", "cargo-summary", + "revenue-by-category", + "revenue-transactions", + "revenue-by-period", + "revenue-by-route", + "revenue-top-customers", + "payment-classification", + "revenue-reconciliation", + "receivables-payables", + "revenue-anomalies", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c131af4bd..31133c575 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -44,7 +44,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage"; import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config"; -import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect"; +import ReportsLandingPage from "./pages/reports/ReportsLandingPage"; import ReportPage from "./pages/reports/ReportPage"; import AuditLogsPage from "./pages/AuditLogsPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; @@ -245,7 +245,7 @@ const App = () => { path="reports" element={ - + } /> 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 99c6d30c3..e686bc9d0 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx @@ -9,6 +9,8 @@ interface ReportSectionProps { reportKey: string; /** Scopes the report to one entity, e.g. the contract this page is showing. */ idKeyValue?: string; + /** Opens on the chart instead of the table — for dashboard tiles. */ + defaultView?: "table" | "chart"; } /** @@ -17,7 +19,7 @@ interface ReportSectionProps { * loading or if the caller lacks the report's permission, so pages can embed * it unconditionally without their own permission check. */ -export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) { +export function ReportSection({ reportKey, idKeyValue, defaultView }: ReportSectionProps) { const { data: catalog } = useQuery(api.reports.catalog.queryOptions()); const def = catalog?.find((r) => r.key === reportKey); @@ -31,7 +33,7 @@ export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) { {def.description} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsIndexRedirect.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsIndexRedirect.tsx deleted file mode 100644 index a341b388e..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsIndexRedirect.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Navigate } from "react-router-dom"; - -import { api } from "@/services/api"; - -/** - * `/dashboard/reports` has no page of its own — it forwards to the first - * report the caller has access to (catalog order = registration order, - * already permission-filtered server-side), or home if they have none. - */ -export default function ReportsIndexRedirect() { - const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions()); - - if (isLoading) return null; - const first = catalog?.[0]; - return ; -} diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx new file mode 100644 index 000000000..98ea19bfc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsLandingPage.tsx @@ -0,0 +1,51 @@ +import { SimpleGrid, Stack } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Navigate } from "react-router-dom"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { ReportSection } from "@/components/reports/ReportSection"; +import { api } from "@/services/api"; + +/** + * The revenue dashboard the reporting spec asks for, assembled from reports + * that already exist rather than a second aggregation API: each tile is a + * `ReportSection` opened on its chart, and each one permission-gates itself by + * rendering nothing when the caller's catalog lacks that report. + */ +const TILES = [ + "revenue-by-period", + "revenue-by-category", + "revenue-by-route", + "revenue-top-customers", +]; + +export default function ReportsLandingPage() { + const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions()); + + if (isLoading) return null; + + const visible = TILES.filter((key) => catalog?.some((r) => r.key === key)); + + // No revenue reports for this user — fall back to the old behaviour and send + // them to the first report they can actually open. + if (!visible.length) { + const first = catalog?.[0]; + return ; + } + + return ( + + + + + {visible.map((key) => ( + + ))} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts index 8bdd6bd23..e3b6749da 100644 --- a/apps/edr-freight-web/backoffice/src/types/reports.ts +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -46,6 +46,16 @@ export interface ReportChartDef { y: string[]; } +/** + * Makes a summary row clickable: the row's values are carried into another + * report as filter params. Keys are this report's column keys; values are the + * target report's filter keys. + */ +export interface ReportDrillDef { + to: string; + carry: Record; +} + /** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */ export interface ReportCatalogEntry { key: string; @@ -58,6 +68,7 @@ export interface ReportCatalogEntry { defaultSort?: { key: string; dir: "ASC" | "DESC" }; hasSummary: boolean; chart?: ReportChartDef; + drill?: ReportDrillDef; } export interface ReportPageMeta {