fix: revenue by customer

This commit is contained in:
Nathnael
2026-08-21 10:57:42 +00:00
parent 25d13baa6f
commit 7a2383f02c

View File

@@ -1,6 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportColumn, ReportDefinition } from "../report.types"; import { ReportContext, ReportColumn, ReportDefinition } from '../report.types';
import { import {
PAID_SHARE, PAID_SHARE,
PAYER_EXPR, PAYER_EXPR,
@@ -10,7 +10,7 @@ import {
REVENUE_SUM, REVENUE_SUM,
currencyOf, currencyOf,
revenueLedgerQb, revenueLedgerQb,
} from "../revenue-classification"; } from '../revenue-classification';
/** /**
* One column per payment class, pivoted with FILTER. The class values are the * One column per payment class, pivoted with FILTER. The class values are the
@@ -19,16 +19,14 @@ import {
*/ */
const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({ const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({
value: c.value, value: c.value,
key: c.value key: c.value.toLowerCase().replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()),
.toLowerCase()
.replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()),
label: c.label, label: c.label,
})); }));
const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({ const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({
key: c.key, key: c.key,
label: c.label, label: c.label,
type: "money", type: 'money',
sortable: true, sortable: true,
})); }));
@@ -37,46 +35,43 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
} }
export const revenueByCustomerReport: ReportDefinition = { export const revenueByCustomerReport: ReportDefinition = {
key: "revenue-by-customer", key: 'revenue-by-customer',
title: "Revenue by Customer", title: 'Revenue by Customer',
description: description:
"Every paying customer on one row: total billed revenue, what they have settled, " + 'Every paying customer on one row: total billed revenue, what they have settled, ' +
"what is still open, and a column per charge type — rail transport, customs " + 'what is still open, and a column per charge type — rail transport, customs ' +
"clearance, first/last mile, overweight, cancellation, demurrage, storage, loading " + 'clearance, first/last mile, overweight, cancellation, demurrage, storage, loading ' +
"and unloading, and additional charges. Built on invoice lines, so the charge-type " + 'and unloading, and additional charges. Built on invoice lines, so the charge-type ' +
"split is the billed one; a booking total is a lump sum and cannot be split. The " + 'split is the billed one; a booking total is a lump sum and cannot be split. The ' +
"payer is the company or, for shipping-line credit invoices, the shipping line. " + 'payer is the company or, for shipping-line credit invoices, the shipping line. ' +
"There is no dedicated loading/unloading charge type in the system — handling, " + 'There is no dedicated loading/unloading charge type in the system — handling, ' +
"double-handling and lashing stand in for it.", 'double-handling and lashing stand in for it.',
group: "Finance", group: 'Finance',
filters: REVENUE_FILTERS, filters: REVENUE_FILTERS,
columns: [ columns: [
{ {
key: "customer", key: 'customer',
label: "Customer", label: 'Customer',
type: "string", type: 'string',
sortable: true, sortable: true,
sortExpr: PAYER_EXPR, sortExpr: PAYER_EXPR,
}, },
{ key: "revenue", label: "Total revenue", type: "money", sortable: true }, { key: 'revenue', label: 'Total revenue', type: 'money', sortable: true },
{ key: "paid", label: "Paid", type: "money", sortable: true }, { key: 'paid', label: 'Paid', type: 'money', sortable: true },
{ key: "outstanding", label: "Outstanding", type: "money", sortable: true }, { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
...classMoneyColumns, ...classMoneyColumns,
{ key: "invoices", label: "Invoices", type: "number", sortable: true }, { key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
], ],
defaultSort: { key: "revenue", dir: "DESC" }, defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: "bar", x: "customer", y: ["revenue"] }, chart: { type: 'bar', x: 'customer', y: ['revenue'] },
drill: { to: "revenue-transactions", carry: { customer: "customer" } }, drill: { to: 'revenue-transactions', carry: { customer: 'customer' } },
query(ctx) { query(ctx) {
const qb = baseQuery(ctx) const qb = baseQuery(ctx)
.select(PAYER_EXPR, "customer") .select(PAYER_EXPR, 'customer')
.addSelect(REVENUE_SUM, "revenue") .addSelect(REVENUE_SUM, 'revenue')
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid") .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid')
.addSelect( .addSelect(`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, 'outstanding')
`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, .addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
"outstanding",
)
.addSelect("COUNT(DISTINCT i.id)::int", "invoices")
.groupBy(PAYER_EXPR); .groupBy(PAYER_EXPR);
for (const c of CLASS_COLUMNS) { for (const c of CLASS_COLUMNS) {
@@ -89,18 +84,18 @@ export const revenueByCustomerReport: ReportDefinition = {
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, "customers") .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
.addSelect(REVENUE_SUM, "revenue") .addSelect(REVENUE_SUM, 'revenue')
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid") .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid')
.getRawOne<{ customers: number; revenue: number; paid: number }>(); .getRawOne<{ customers: number; revenue: number; paid: number }>();
const revenue = Number(row?.revenue ?? 0); const revenue = Number(row?.revenue ?? 0);
const paid = Number(row?.paid ?? 0); const paid = Number(row?.paid ?? 0);
const unit = currencyOf(ctx.params); const unit = currencyOf(ctx.params);
return [ return [
{ label: "Customers", value: Number(row?.customers ?? 0) }, { label: 'Customers', value: Number(row?.customers ?? 0) },
{ label: "Total revenue", value: revenue, unit }, { label: 'Total revenue', value: revenue, unit },
{ label: "Paid", value: paid, unit }, { label: 'Paid', value: paid, unit },
{ label: "Outstanding", value: Math.round(revenue - paid), unit }, { label: 'Outstanding', value: Math.round(revenue - paid), unit },
]; ];
}, },
}; };