mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(reports): classify receivables and payables by real money flow
The receivable/payable split contradicted how money actually moves, in three ways that each changed a headline number: - The wagon-cancellation FEE was booked as a payable. It is money the customer owes EDR (raised ISSUED and unpaid at request time), so it belongs on the receivable side while open. The sign was inverted. - A whole-booking wagon cancellation was booked at the source invoice's full paid_amount, and never cleared: the booking stays CANCELLED and the invoice stays PAID even after the credit is rebooked. The real liability is the ledger row's credit_amount, and only while it sits in CREDIT_AVAILABLE — cancellation refunds no cash, it hands back bookable credit redeemed by creating another booking. - Shipping-line debt in UNBILLED has no invoice row at all, so an invoice-only fact table could not see it. That is the un-batched half of the debt, in the report whose stated purpose is shipping-line credit. The report is now a UNION of the three tables that hold the answer: invoices with a balance (plus prepayments against dead bookings), UNBILLED shipping_line_credits, and CREDIT_AVAILABLE booking_wagon_cancellations. A booking already carried by the cancellation ledger is excluded from the invoice branch so its money is counted once. Fully settled invoices are dropped — zero exposure is neither a receivable nor a payable. Branches are re-projected through an explicit column list before being unioned: UNION matches by position and TypeORM does not preserve addSelect order, which silently reordered one branch into "gross, exposure, side_key, ..." and failed with "UNION types text and numeric cannot be matched". Verified against Postgres with a rollback-only fixture covering every side, plus EXPLAIN over each filter combination and every sortable column.
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { BookingWagonCancellation } from '../../bookings/entities/booking-wagon-cancellation.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||||
|
import { ShippingLineCredit } from '../../shipping-lines/entities/shipping-line-credit.entity';
|
||||||
|
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
|
||||||
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
|
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
|
||||||
import {
|
import {
|
||||||
PAYER_EXPR,
|
PAYER_EXPR,
|
||||||
@@ -10,47 +17,287 @@ import {
|
|||||||
} from '../revenue-classification';
|
} from '../revenue-classification';
|
||||||
|
|
||||||
export const LEDGER_SIDES: ReportFilterOption[] = [
|
export const LEDGER_SIDES: ReportFilterOption[] = [
|
||||||
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' },
|
{
|
||||||
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' },
|
value: 'RECEIVABLE_SL_UNBILLED',
|
||||||
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' },
|
label: 'Receivable — shipping-line service, not yet invoiced',
|
||||||
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' },
|
},
|
||||||
{ value: 'SETTLED', label: 'Settled' },
|
{
|
||||||
|
value: 'RECEIVABLE_SL_INVOICED',
|
||||||
|
label: 'Receivable — shipping-line invoice open',
|
||||||
|
},
|
||||||
|
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open invoice balance' },
|
||||||
|
{
|
||||||
|
value: 'PAYABLE_WAGON_CREDIT',
|
||||||
|
label: 'Payable — unapplied wagon-cancellation credit',
|
||||||
|
},
|
||||||
|
{ value: 'PAYABLE_PREPAID', label: 'Payable — paid but not delivered' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which side of the ledger an invoice sits on.
|
* Which side of the ledger a row sits on, and why the report is a union of
|
||||||
|
* three fact tables rather than a CASE over `invoices`.
|
||||||
*
|
*
|
||||||
* Receivable = EDR delivered and is owed money — the shipping-line credit
|
* RECEIVABLE — money EDR is owed. The shipping-line arrangement is service
|
||||||
* arrangement, plus any invoice still carrying a balance.
|
* first, pay later, and it produces debt in two shapes: a `shipping_line_credits`
|
||||||
* Payable = the customer paid for something EDR did not deliver, so the money
|
* row with NO invoice while it is UNBILLED (a shipping-line booking raises no
|
||||||
* is a refund liability rather than revenue: cancellation fees, and prepaid
|
* invoice at all), and an open batch invoice once finance bills it. Counting
|
||||||
* invoices whose booking died.
|
* only the second understates the debt by everything not yet batched. Ordinary
|
||||||
|
* open invoice balances are the third shape — including the wagon-cancellation
|
||||||
|
* FEE, which is money the customer owes EDR, never a liability.
|
||||||
|
*
|
||||||
|
* PAYABLE — the customer paid and did not get the service. Wagon cancellation
|
||||||
|
* never refunds cash: the cancelled freight becomes a rebooking credit that is
|
||||||
|
* redeemed by creating another booking (see BookingWagonCancellationService).
|
||||||
|
* So the liability is exactly the cancellations sitting in CREDIT_AVAILABLE —
|
||||||
|
* fee settled, wagons freed, credit not yet applied — valued at `credit_amount`,
|
||||||
|
* and it disappears the moment the row turns REBOOKED. The source invoice is
|
||||||
|
* useless for this: a whole-booking cut leaves it PAID at its full amount
|
||||||
|
* forever, which is neither the right number nor the right lifetime.
|
||||||
|
*
|
||||||
|
* Fully settled invoices are not rows here. A zero-exposure invoice is neither
|
||||||
|
* a receivable nor a payable; Invoicing Pipeline is the report that lists them.
|
||||||
*/
|
*/
|
||||||
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 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 ')}
|
/** Labels a side key that is already a column — the union is classified inside, labelled outside. */
|
||||||
|
const SIDE_LABEL_OF = (keyExpr: string): string =>
|
||||||
|
`CASE ${keyExpr}\n ${[...LABELS]
|
||||||
|
.map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`)
|
||||||
|
.join('\n ')}\nEND`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses that cannot become cash. EXPIRED closed its own pay window and
|
||||||
|
* REFUNDED already gave the money back, so neither is owed in either
|
||||||
|
* direction. Filtered here rather than in the shared DEAD_INVOICE_STATUSES —
|
||||||
|
* that constant feeds every revenue report and those invoices did earn revenue.
|
||||||
|
*/
|
||||||
|
const UNCOLLECTABLE_INVOICE_STATUSES = "('EXPIRED', 'REFUNDED')";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A booking whose money is accounted for by the cancellation ledger instead.
|
||||||
|
* Without this, a whole-booking wagon cancellation would be counted twice: once
|
||||||
|
* as its own CREDIT_AVAILABLE credit, and again as the source booking's paid
|
||||||
|
* invoice sitting against a CANCELLED booking — and the second copy would never
|
||||||
|
* clear, because rebooking updates the ledger row, not the old invoice.
|
||||||
|
*/
|
||||||
|
const HAS_CANCELLATION_LEDGER = `EXISTS (
|
||||||
|
SELECT 1 FROM freight.booking_wagon_cancellations bwc0
|
||||||
|
WHERE bwc0.booking_id = b.id
|
||||||
|
AND bwc0.deleted_at IS NULL
|
||||||
|
AND bwc0.status <> 'WITHDRAWN'
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/** Customer paid, booking died, and no cancellation credit represents it. */
|
||||||
|
const PREPAID_DEAD = `i.paid_amount > 0
|
||||||
|
AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
|
||||||
|
AND NOT ${HAS_CANCELLATION_LEDGER}`;
|
||||||
|
|
||||||
|
export const INVOICE_SIDE_EXPR = `CASE
|
||||||
|
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
|
||||||
|
THEN 'RECEIVABLE_SL_INVOICED'
|
||||||
|
WHEN ${PREPAID_DEAD} THEN 'PAYABLE_PREPAID'
|
||||||
|
ELSE 'RECEIVABLE_OPEN'
|
||||||
END`;
|
END`;
|
||||||
|
|
||||||
/** Money at stake on this row: what is owed, or what may have to be given back. */
|
/**
|
||||||
const EXPOSURE = `CASE
|
* The union's column contract, in positional order.
|
||||||
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount
|
*
|
||||||
ELSE i.balance_amount
|
* UNION matches by POSITION, and TypeORM does not preserve `addSelect` order —
|
||||||
END`;
|
* it hoists a branch's repeated expressions to the front, which silently
|
||||||
|
* rearranged one branch into `gross, exposure, side_key, …` and failed with
|
||||||
|
* "UNION types text and numeric cannot be matched". Every branch is therefore
|
||||||
|
* re-projected through this list by name before it is unioned.
|
||||||
|
*/
|
||||||
|
const UNION_COLUMNS = [
|
||||||
|
'side_key',
|
||||||
|
'txn_date',
|
||||||
|
'doc_ref',
|
||||||
|
'booking_ref',
|
||||||
|
'booking_status',
|
||||||
|
'payer',
|
||||||
|
'gross',
|
||||||
|
'settled',
|
||||||
|
'exposure',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one wagon-cancellation status that is a live liability: the fee is
|
||||||
|
* settled and the booking cut, but the credit has not been turned into a
|
||||||
|
* booking yet. FEE_PENDING has cut nothing, REBOOKED has been redeemed, and
|
||||||
|
* WITHDRAWN/EXPIRED owe nothing.
|
||||||
|
*/
|
||||||
|
export const CREDIT_LIABILITY_STATUS = 'CREDIT_AVAILABLE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line credit status that is debt with no invoice behind it. BILLED
|
||||||
|
* credits are counted through their invoice on branch A, which is what keeps
|
||||||
|
* the two shipping-line sides disjoint.
|
||||||
|
*/
|
||||||
|
export const UNINVOICED_CREDIT_STATUS = 'UNBILLED';
|
||||||
|
|
||||||
|
/** Applies the filters branches B and C share with {@link invoiceLedgerQb}. */
|
||||||
|
function applySharedFilters(
|
||||||
|
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||||
|
ctx: ReportContext,
|
||||||
|
dateExpr: string,
|
||||||
|
): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
|
||||||
|
if (params.dateFrom) qb.andWhere(`${dateExpr} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||||
|
if (params.dateTo) qb.andWhere(`${dateExpr} < :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}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An umbrella general contract is paid once and drawn down by many orders —
|
||||||
|
// same exclusion invoiceLedgerQb applies on branch A.
|
||||||
|
qb.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')");
|
||||||
|
|
||||||
|
// Both branches reach their booking directly, so the direction scope is the
|
||||||
|
// plain column form, not the source_id-pointer form invoices need. A row
|
||||||
|
// whose booking is gone carries no direction to scope by and stays visible —
|
||||||
|
// the same rule applyBookingRefDirectionScope applies on branch A.
|
||||||
|
const scope = directionScopeSql('b.trade_direction', directions);
|
||||||
|
qb.andWhere(`(b.id IS NULL OR ${scope.sql})`, scope.params);
|
||||||
|
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch A — invoices carrying a balance, plus prepayments against dead bookings. */
|
||||||
|
function invoiceBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
return invoiceLedgerQb(ctx)
|
||||||
|
.andWhere(`i.status NOT IN ${UNCOLLECTABLE_INVOICE_STATUSES}`)
|
||||||
|
.andWhere(`(i.balance_amount > 0 OR (${PREPAID_DEAD}))`)
|
||||||
|
.select(INVOICE_SIDE_EXPR, 'side_key')
|
||||||
|
.addSelect(REVENUE_DATE, 'txn_date')
|
||||||
|
.addSelect('i.invoice_number', 'doc_ref')
|
||||||
|
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
|
||||||
|
.addSelect("COALESCE(b.status, '—')", 'booking_status')
|
||||||
|
.addSelect(PAYER_EXPR, 'payer')
|
||||||
|
.addSelect('i.total_amount', 'gross')
|
||||||
|
.addSelect('i.paid_amount', 'settled')
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN ${PREPAID_DEAD} THEN i.paid_amount ELSE i.balance_amount END`,
|
||||||
|
'exposure',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch B — shipping-line services used but never invoiced.
|
||||||
|
*
|
||||||
|
* The credit row IS the debt while it is UNBILLED; BILLED rows are the ones
|
||||||
|
* behind an invoice and are already counted by branch A, so taking only
|
||||||
|
* UNBILLED here is what keeps the two shipping-line sides disjoint.
|
||||||
|
*/
|
||||||
|
function unbilledCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(ShippingLineCredit, 'slc_c')
|
||||||
|
.leftJoin(Booking, 'b', 'b.id = slc_c.booking_id AND b.deleted_at IS NULL')
|
||||||
|
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
|
||||||
|
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
|
||||||
|
.leftJoin(Company, 'co', 'co.id = b.company_id')
|
||||||
|
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = slc_c.shipping_line_company_id')
|
||||||
|
.where('slc_c.deleted_at IS NULL')
|
||||||
|
.andWhere('slc_c.status = :uninvoicedCreditStatus', {
|
||||||
|
uninvoicedCreditStatus: UNINVOICED_CREDIT_STATUS,
|
||||||
|
})
|
||||||
|
.andWhere('slc_c.currency = :currency', {
|
||||||
|
currency: currencyOf(ctx.params),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Priced when the service was used; that is the date the debt was incurred.
|
||||||
|
applySharedFilters(qb, ctx, 'slc_c.created_at');
|
||||||
|
|
||||||
|
return qb
|
||||||
|
.select("'RECEIVABLE_SL_UNBILLED'", 'side_key')
|
||||||
|
.addSelect('slc_c.created_at', 'txn_date')
|
||||||
|
.addSelect("'—'", 'doc_ref')
|
||||||
|
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
|
||||||
|
.addSelect("COALESCE(b.status, '—')", 'booking_status')
|
||||||
|
.addSelect("COALESCE(slc.name, 'Unknown')", 'payer')
|
||||||
|
.addSelect('slc_c.amount', 'gross')
|
||||||
|
.addSelect('0::numeric', 'settled')
|
||||||
|
.addSelect('slc_c.amount', 'exposure');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch C — cancelled wagons whose credit has not been rebooked.
|
||||||
|
*
|
||||||
|
* `credit_amount` is priced in the BOOKING's payment currency, not
|
||||||
|
* `fee_currency` — that one prices the cancellation fee, which is a separate
|
||||||
|
* (and opposite-signed) piece of money.
|
||||||
|
*/
|
||||||
|
function wagonCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(BookingWagonCancellation, 'bwc')
|
||||||
|
.innerJoin(Booking, 'b', 'b.id = bwc.booking_id AND b.deleted_at IS NULL')
|
||||||
|
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
|
||||||
|
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
|
||||||
|
.leftJoin(Company, 'co', 'co.id = b.company_id')
|
||||||
|
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = b.shipping_line_company_id')
|
||||||
|
.where('bwc.deleted_at IS NULL')
|
||||||
|
.andWhere('bwc.status = :creditLiabilityStatus', {
|
||||||
|
creditLiabilityStatus: CREDIT_LIABILITY_STATUS,
|
||||||
|
})
|
||||||
|
.andWhere("COALESCE(b.payment_currency, 'ETB') = :currency", {
|
||||||
|
currency: currencyOf(ctx.params),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The credit exists from the moment the fee settled and the booking was cut.
|
||||||
|
applySharedFilters(qb, ctx, 'COALESCE(bwc.fee_paid_at, bwc.created_at)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
qb
|
||||||
|
.select("'PAYABLE_WAGON_CREDIT'", 'side_key')
|
||||||
|
.addSelect('COALESCE(bwc.fee_paid_at, bwc.created_at)', 'txn_date')
|
||||||
|
// numeric(6,2) renders as "2.00"; a wagon count reads as "2" (and "2.5"
|
||||||
|
// survives, because a half wagon is a real bulk quantity here).
|
||||||
|
.addSelect(
|
||||||
|
`rtrim(rtrim(bwc.wagons_cancelled::text, '0'), '.') || ' wagon(s) cancelled'`,
|
||||||
|
'doc_ref',
|
||||||
|
)
|
||||||
|
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
|
||||||
|
.addSelect("COALESCE(b.status, '—')", 'booking_status')
|
||||||
|
.addSelect(PAYER_EXPR, 'payer')
|
||||||
|
// The freight was paid in full on the original booking, so the whole
|
||||||
|
// credit is money already in hand and owed back as bookable value.
|
||||||
|
.addSelect('bwc.credit_amount', 'gross')
|
||||||
|
.addSelect('bwc.credit_amount', 'settled')
|
||||||
|
.addSelect('bwc.credit_amount', 'exposure')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three branches as one relation, wrapped so the runner can sort, page and
|
||||||
|
* COUNT(*) it like any other report query.
|
||||||
|
*
|
||||||
|
* Parameters are merged from every branch: `getQuery()` leaves `:name`
|
||||||
|
* placeholders in place, and only the outer builder's parameter bag is read
|
||||||
|
* when the SQL is finally bound.
|
||||||
|
*/
|
||||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
const qb = invoiceLedgerQb(ctx);
|
const branches = [invoiceBranch(ctx), unbilledCreditBranch(ctx), wagonCreditBranch(ctx)];
|
||||||
|
const combined = branches
|
||||||
|
.map((b, idx) => `SELECT ${UNION_COLUMNS.join(', ')} FROM (${b.getQuery()}) branch_${idx}`)
|
||||||
|
.join('\n UNION ALL\n ');
|
||||||
|
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(`(${combined})`, 'r')
|
||||||
|
.setParameters(Object.assign({}, ...branches.map((b) => b.getParameters())));
|
||||||
|
|
||||||
const sides = ctx.params.sides as string[] | null;
|
const sides = ctx.params.sides as string[] | null;
|
||||||
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides });
|
if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides });
|
||||||
|
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = {
|
|||||||
key: 'receivables-payables',
|
key: 'receivables-payables',
|
||||||
title: 'Receivables and Payables',
|
title: 'Receivables and Payables',
|
||||||
description:
|
description:
|
||||||
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' +
|
'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' +
|
||||||
'including shipping-line credit services — and payable, where the customer paid but ' +
|
'shipping-line credit services whether invoiced yet or not, plus any invoice still ' +
|
||||||
'the service was not delivered, such as cancellation fees and prepayments against ' +
|
'carrying a balance — and payable, where the customer paid and the service was not ' +
|
||||||
'dead bookings. Payable amounts are a refund liability, not revenue.',
|
'delivered. The payable is dominated by wagon cancellations whose credit has not been ' +
|
||||||
|
'rebooked; that credit is redeemed by creating another booking, never refunded in cash.',
|
||||||
group: 'Finance',
|
group: 'Finance',
|
||||||
filters: [
|
filters: [
|
||||||
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
|
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
|
||||||
{ key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES },
|
{
|
||||||
|
key: 'sides',
|
||||||
|
label: 'Ledger side',
|
||||||
|
type: 'multiselect',
|
||||||
|
options: LEDGER_SIDES,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
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: 'side',
|
||||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
label: 'Ledger side',
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.side_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'issuedAt',
|
||||||
|
label: 'Date',
|
||||||
|
type: 'date',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.txn_date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'invoiceNumber',
|
||||||
|
label: 'Invoice / ref',
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.doc_ref',
|
||||||
|
},
|
||||||
{ key: 'bookingRef', label: 'Booking', type: 'string' },
|
{ key: 'bookingRef', label: 'Booking', type: 'string' },
|
||||||
{ key: 'bookingStatus', label: 'Booking status', 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: 'customer',
|
||||||
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
|
label: 'Payer',
|
||||||
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE },
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.payer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'invoiced',
|
||||||
|
label: 'Amount',
|
||||||
|
type: 'money',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.gross',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paid',
|
||||||
|
label: 'Paid',
|
||||||
|
type: 'money',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.settled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exposure',
|
||||||
|
label: 'Owed / refundable',
|
||||||
|
type: 'money',
|
||||||
|
sortable: true,
|
||||||
|
sortExpr: 'r.exposure',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'exposure', dir: 'DESC' },
|
defaultSort: { key: 'exposure', dir: 'DESC' },
|
||||||
chart: { type: 'bar', x: 'side', y: ['exposure'] },
|
chart: { type: 'bar', x: 'side', y: ['exposure'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select(SIDE_LABEL_EXPR, 'side')
|
.select(SIDE_LABEL_OF('r.side_key'), 'side')
|
||||||
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
|
||||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
.addSelect('r.doc_ref', 'invoiceNumber')
|
||||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
.addSelect('r.booking_ref', 'bookingRef')
|
||||||
.addSelect("COALESCE(b.status, '—')", 'bookingStatus')
|
.addSelect('r.booking_status', 'bookingStatus')
|
||||||
.addSelect(PAYER_EXPR, 'customer')
|
.addSelect('r.payer', 'customer')
|
||||||
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
|
.addSelect('ROUND(r.gross, 2)::float8', 'invoiced')
|
||||||
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid')
|
.addSelect('ROUND(r.settled, 2)::float8', 'paid')
|
||||||
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure');
|
.addSelect('ROUND(r.exposure, 2)::float8', 'exposure');
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await baseQuery(ctx)
|
const row = await baseQuery(ctx)
|
||||||
.select(
|
.select(
|
||||||
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`,
|
"ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'RECEIVABLE%'), 0))::float8",
|
||||||
'receivable',
|
'receivable',
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`,
|
"ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'PAYABLE%'), 0))::float8",
|
||||||
'payable',
|
'payable',
|
||||||
)
|
)
|
||||||
.addSelect('COUNT(*)::int', 'invoices')
|
.addSelect('COUNT(*)::int', 'items')
|
||||||
.getRawOne<{ receivable: number; payable: number; invoices: number }>();
|
.getRawOne<{ receivable: number; payable: number; items: number }>();
|
||||||
|
|
||||||
|
const receivable = Number(row?.receivable ?? 0);
|
||||||
|
const payable = Number(row?.payable ?? 0);
|
||||||
const currency = currencyOf(ctx.params);
|
const currency = currencyOf(ctx.params);
|
||||||
return [
|
return [
|
||||||
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency },
|
{ label: 'Receivable', value: receivable, unit: currency },
|
||||||
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency },
|
{ label: 'Payable', value: payable, unit: currency },
|
||||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
{
|
||||||
|
label: 'Net position',
|
||||||
|
value: Math.round(receivable - payable),
|
||||||
|
unit: currency,
|
||||||
|
},
|
||||||
|
{ label: 'Open items', value: Number(row?.items ?? 0) },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user