mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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<ObjectLiteral> {
|
||||
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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>): number => {
|
||||
const raw = Number(params.threshold);
|
||||
return THRESHOLDS.some((t) => Number(t.value) === raw) ? raw : 25;
|
||||
};
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
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: '%' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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, unknown>): 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<ObjectLiteral> {
|
||||
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),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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(<enum>, '')` 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<ObjectLiteral> {
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
|
||||
|
||||
@@ -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<ReportFilterOption[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>;
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
@@ -90,6 +108,8 @@ export interface ReportDefinition {
|
||||
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
|
||||
/** 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. */
|
||||
|
||||
@@ -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<string, ReportFilterOption[]>();
|
||||
|
||||
async function resolveFilterOptions(
|
||||
def: ReportCatalogEntry,
|
||||
ds: DataSource,
|
||||
): Promise<ReportCatalogEntry> {
|
||||
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<ReportCatalogEntry[]> {
|
||||
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')
|
||||
|
||||
@@ -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}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, unknown>): string {
|
||||
const unit = resolvePeriod(params);
|
||||
return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`;
|
||||
}
|
||||
|
||||
function resolvePeriod(params: Record<string, unknown>): (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, unknown>): 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, unknown>): string =>
|
||||
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`;
|
||||
|
||||
/** Same scale, one period later — where a one-step-ahead projection lands. */
|
||||
export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): 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<ReportFilterOption[]> {
|
||||
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<ReportFilterOption>();
|
||||
}
|
||||
|
||||
/** Currency the ledger reports in when the caller does not choose one. */
|
||||
export const DEFAULT_CURRENCY = 'ETB';
|
||||
|
||||
export const currencyOf = (params: Record<string, unknown>): 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<ObjectLiteral> {
|
||||
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<ObjectLiteral> {
|
||||
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);
|
||||
@@ -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];
|
||||
|
||||
@@ -45,7 +45,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";
|
||||
@@ -248,7 +248,7 @@ const App = () => {
|
||||
path="reports"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
|
||||
<ReportsIndexRedirect />
|
||||
<ReportsLandingPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,11 @@ import type { FilterBodyProps } from "./TextBody";
|
||||
// i18n.language !== "en" branch here when this body is first wired into a
|
||||
// record-management page (Phase 4 of the filter-bar rollout).
|
||||
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
|
||||
// DEFAULT_OP.date is always "between" — a def restricted to a single
|
||||
// non-default operator (e.g. `operators: ["before"]` for an exact-date
|
||||
// filter) would otherwise open on the range UI with no way to switch off
|
||||
// it, since OperatorSelect hides itself when there's only one choice.
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date);
|
||||
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
|
||||
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
|
||||
|
||||
@@ -50,15 +50,15 @@ export function PageHeader({
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, maxWidth: 640 }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<Title order={2} className="truncate">
|
||||
<Title order={2} className="truncate" style={{ minWidth: 0 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{meta}
|
||||
</Group>
|
||||
{subtitle ? (
|
||||
<Text c="dimmed" size="sm" mt={4}>
|
||||
<Text c="dimmed" size="sm" mt={4} className="truncate">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
|
||||
import { DateInput, DatePickerInput } from "@mantine/dates";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import type { ReportFilterDef } from "@/types/reports";
|
||||
|
||||
export interface ReportFilterValues {
|
||||
[param: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface ReportFiltersProps {
|
||||
filters: ReportFilterDef[];
|
||||
values: ReportFilterValues;
|
||||
onChange: (values: ReportFilterValues) => void;
|
||||
}
|
||||
|
||||
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
|
||||
const fromDate = (value: string | null): string | undefined => value ?? undefined;
|
||||
|
||||
/** Renders one widget per report-declared filter and reports raw param values back up. */
|
||||
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
|
||||
if (!filters.length) return null;
|
||||
|
||||
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{filters.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case "daterange":
|
||||
return (
|
||||
<DatePickerInput
|
||||
key={filter.key}
|
||||
type="range"
|
||||
placeholder={filter.label}
|
||||
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
|
||||
onChange={([from, to]) =>
|
||||
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
|
||||
}
|
||||
presets={getDateRangePresets()}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={230}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateInput
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
value={toDate(values[filter.key])}
|
||||
onChange={(d) => set({ [filter.key]: fromDate(d) })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
data={filter.options ?? []}
|
||||
value={values[filter.key] ?? null}
|
||||
onChange={(v) => set({ [filter.key]: v ?? undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={170}
|
||||
/>
|
||||
);
|
||||
case "multiselect":
|
||||
return (
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
data={filter.options ?? []}
|
||||
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
|
||||
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<TextInput
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
leftSection={<Search size={16} />}
|
||||
value={values[filter.key] ?? ""}
|
||||
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={220}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportFilters;
|
||||
@@ -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}
|
||||
</Text>
|
||||
</div>
|
||||
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
|
||||
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} defaultView={defaultView} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Column, SortingState } from "@tanstack/react-table";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PageHeader } from "@/components/page";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||
import { api } from "@/services/api";
|
||||
import type { ReportRunParams } from "@/types/reports";
|
||||
import type { ReportFilterDef, ReportRunParams } from "@/types/reports";
|
||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ReportChart } from "./ReportChart";
|
||||
import { ReportExportButton } from "./ReportExportButton";
|
||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||
|
||||
/**
|
||||
* Maps the report catalog's own filter vocabulary onto the shared FilterBar's
|
||||
* `FilterDef`. "search" is skipped — FilterBar already renders its own search
|
||||
* box wired to the same `search` param, so keeping the catalog's declared
|
||||
* "search" filter too would just double it up as a redundant pill.
|
||||
*/
|
||||
function toFilterDefs(filters: ReportFilterDef[]): FilterDef[] {
|
||||
return filters
|
||||
.filter((f) => f.key !== "search")
|
||||
.map((f): FilterDef => {
|
||||
switch (f.type) {
|
||||
case "daterange":
|
||||
return {
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams(`${f.key}From`, `${f.key}To`),
|
||||
};
|
||||
case "date":
|
||||
// Every report's single-date filter (e.g. "as of") is an exact
|
||||
// cutoff, not a range — one fixed operator keeps DateBody on its
|
||||
// single-date UI instead of the range picker.
|
||||
return { key: f.key, label: f.label, type: "date", operators: ["before"] };
|
||||
case "select":
|
||||
return { key: f.key, label: f.label, type: "enum", multiple: false, options: f.options ?? [] };
|
||||
case "multiselect":
|
||||
return { key: f.key, label: f.label, type: "enum", multiple: true, options: f.options ?? [] };
|
||||
default:
|
||||
return { key: f.key, label: f.label, type: "text" };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
|
||||
const sorted = column.getIsSorted();
|
||||
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
||||
@@ -40,6 +74,8 @@ interface ReportViewProps {
|
||||
* arrow) with export/refresh as its actions, instead of inline above the
|
||||
* table. Off by default for embedded sections. */
|
||||
pageHeader?: boolean;
|
||||
/** Opens on the chart instead of the table — for dashboard tiles. */
|
||||
defaultView?: "table" | "chart";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,15 +83,29 @@ interface ReportViewProps {
|
||||
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
|
||||
* Adding a report never touches this file.
|
||||
*/
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: ReportViewProps) {
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
const [view, setView] = useState<"table" | "chart">(defaultView ?? "table");
|
||||
|
||||
// FilterBar's own state — reads/writes the URL directly, same as
|
||||
// BookingRequestsPage, so a drilled-into or shared report URL opens
|
||||
// already filtered.
|
||||
const reportFilterDefs = useMemo(() => toFilterDefs(def?.filters ?? []), [def?.filters]);
|
||||
const controls = useFilters(reportFilterDefs);
|
||||
// useFilters also tracks its own page/pageSize — unused here, this report
|
||||
// view paginates itself (and overrides pageSize for the chart view below).
|
||||
const filterParams = useMemo(() => {
|
||||
const rest = { ...controls.params };
|
||||
delete rest.page;
|
||||
delete rest.pageSize;
|
||||
return rest;
|
||||
}, [controls.params]);
|
||||
|
||||
// Filters + sort as the user currently has them — independent of the view
|
||||
// toggle's paging, so export always matches what's on screen either way.
|
||||
@@ -64,10 +114,18 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
return {
|
||||
sortBy: sort?.id,
|
||||
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
|
||||
...debouncedFilters,
|
||||
...filterParams,
|
||||
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
||||
};
|
||||
}, [def, sorting, debouncedFilters, idKeyValue]);
|
||||
}, [def, sorting, filterParams, idKeyValue]);
|
||||
|
||||
// A filter change should land back on page 1, same as every other
|
||||
// FilterBar page — but pagination here is local (not URL-driven via
|
||||
// controls.tableProps), so it needs an explicit reset.
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterParams]);
|
||||
|
||||
const runParams: ReportRunParams | undefined = useMemo(() => {
|
||||
if (!def) return undefined;
|
||||
@@ -87,6 +145,30 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
enabled: Boolean(runParams),
|
||||
});
|
||||
|
||||
/**
|
||||
* Row click carries this row's values into the target report as filter
|
||||
* params — the "summary to transaction level" drill-down. Undefined unless
|
||||
* the report declares `drill`, which is what leaves the row unclickable.
|
||||
*/
|
||||
const handleRowClick = useMemo(() => {
|
||||
const drill = def?.drill;
|
||||
if (!drill) return undefined;
|
||||
return (row: Record<string, unknown>) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [column, filterKey] of Object.entries(drill.carry)) {
|
||||
const value = row[column];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
params.set(filterKey, String(value));
|
||||
}
|
||||
}
|
||||
// Carry the filters already applied, so the drill narrows rather than resets.
|
||||
for (const [key, value] of Object.entries(appliedParams)) {
|
||||
if (typeof value === "string" && value && !params.has(key)) params.set(key, value);
|
||||
}
|
||||
navigate(`/dashboard/reports/${drill.to}?${params.toString()}`);
|
||||
};
|
||||
}, [def?.drill, appliedParams, navigate]);
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
@@ -167,25 +249,23 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<ReportFilters
|
||||
filters={def.filters}
|
||||
values={filterValues}
|
||||
onChange={(v) => {
|
||||
setFilterValues(v);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<FilterBar
|
||||
defs={reportFilterDefs}
|
||||
controls={controls}
|
||||
// Only a handful of reports actually implement the `search`
|
||||
// param server-side (see toFilterDefs) — showing the box on
|
||||
// every report would be a dead control on the rest.
|
||||
showSearch={def.filters.some((f) => f.key === "search")}
|
||||
searchPlaceholder="Search…"
|
||||
>
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{view === "chart" && def.chart ? (
|
||||
@@ -195,6 +275,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
onRowClick={handleRowClick}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No data for the selected filters."
|
||||
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
|
||||
|
||||
@@ -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 <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
|
||||
}
|
||||
@@ -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 <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Revenue dashboard"
|
||||
subtitle="Billed rail revenue by period, category, corridor and customer. Pick any report in the sidebar for the full table, filters and export."
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
|
||||
{visible.map((key) => (
|
||||
<ReportSection key={key} reportKey={key} defaultView="chart" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
|
||||
Reference in New Issue
Block a user