Merge pull request #1423 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-27 08:27:47 +03:00
committed by GitHub
4 changed files with 113 additions and 43 deletions

View File

@@ -34,10 +34,6 @@ import {
directionScopeSql, directionScopeSql,
} from "../user-trade-access/trade-scope.util"; } from "../user-trade-access/trade-scope.util";
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
export type OverviewBookingKpisRow = { export type OverviewBookingKpisRow = {
total: number; total: number;
totalActive: number; totalActive: number;
@@ -147,7 +143,6 @@ export class OverviewRepository {
"submittedToday", "submittedToday",
) )
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.setParameters({ .setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES], closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
@@ -337,7 +332,6 @@ export class OverviewRepository {
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count") .addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("booking.created_at::date") .groupBy("booking.created_at::date")
@@ -357,7 +351,6 @@ export class OverviewRepository {
.select("booking.status", "status") .select("booking.status", "status")
.addSelect("COUNT(*)::int", "count") .addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.groupBy("booking.status") .groupBy("booking.status")
.getRawMany<{ status: string; count: string }>(); .getRawMany<{ status: string; count: string }>();
@@ -427,7 +420,6 @@ export class OverviewRepository {
.addSelect("booking.payment_currency", "paymentCurrency") .addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt") .addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.orderBy("booking.created_at", "DESC") .orderBy("booking.created_at", "DESC")
.limit(limit) .limit(limit)
@@ -463,7 +455,6 @@ export class OverviewRepository {
.select("booking.freight_type", "label") .select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count") .addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'") .andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.groupBy("booking.freight_type") .groupBy("booking.freight_type")
@@ -485,7 +476,6 @@ export class OverviewRepository {
.select("booking.payment_currency", "label") .select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count") .addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'") .andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.groupBy("booking.payment_currency") .groupBy("booking.payment_currency")
@@ -602,7 +592,6 @@ export class OverviewRepository {
this.bookingRepository this.bookingRepository
.createQueryBuilder("booking") .createQueryBuilder("booking")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(bookingScope.sql, bookingScope.params) .andWhere(bookingScope.sql, bookingScope.params)
.andWhere(windowSql("booking.created_at"), { days, offsetDays }) .andWhere(windowSql("booking.created_at"), { days, offsetDays })
.getCount(), .getCount(),
@@ -802,7 +791,6 @@ export class OverviewRepository {
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block") .addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
.addSelect("COUNT(*)::int", "count") .addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL") .where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int") .groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
@@ -1457,7 +1445,6 @@ export class OverviewRepository {
ON y.id = CASE WHEN b.trade_direction = 'EXPORT' ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int) AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1 GROUP BY 1
ORDER BY count DESC ORDER BY count DESC
@@ -1475,7 +1462,6 @@ export class OverviewRepository {
ON y.id = CASE WHEN b.trade_direction = 'EXPORT' ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int) AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1, 2 GROUP BY 1, 2
ORDER BY 1, 2 ORDER BY 1, 2

View File

@@ -2,8 +2,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity'; import { Company } from '../../companies/entities/company.entity';
import { Invoice } from '../../billing/entities/invoice.entity'; import { Invoice } from '../../billing/entities/invoice.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types'; import { ReportContext, ReportDefinition } from '../report.types';
import { CURRENCY_FILTER, PAYER_EXPR, currencyOf } from '../revenue-classification';
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE']; const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
@@ -13,13 +15,22 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
// to now() in SQL when the filter is unset (see the COALESCE below). // to now() in SQL when the filter is unset (see the COALESCE below).
const asOf = (params.asOf as string | null) ?? null; const asOf = (params.asOf as string | null) ?? null;
// Both payer joins are LEFT: an invoice billed to a shipping line carries no
// company, and an INNER join on `companies` silently drops its balance out of
// the arrears total.
const qb = ctx.ds const qb = ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(Invoice, 'i') .from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id') .leftJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
.where('i.deleted_at IS NULL') .where('i.deleted_at IS NULL')
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES }) .andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
.andWhere('i.balance_amount > 0') .andWhere('i.balance_amount > 0')
// Stored casing has drifted ("usd" rows exist), and one arrears figure
// cannot span two currencies.
.andWhere('UPPER(i.currency) = :currency', {
currency: currencyOf(params).toUpperCase(),
})
.setParameter('asOf', asOf); .setParameter('asOf', asOf);
// ACL: invoices.source_id is a varchar pointer at the originating booking. // ACL: invoices.source_id is a varchar pointer at the originating booking.
@@ -32,9 +43,15 @@ export const agingReceivablesReport: ReportDefinition = {
title: 'Aging Receivables', title: 'Aging Receivables',
description: 'Outstanding customer balances bucketed by days overdue', description: 'Outstanding customer balances bucketed by days overdue',
group: 'Finance', group: 'Finance',
filters: [{ key: 'asOf', label: 'As of', type: 'date' }], filters: [{ key: 'asOf', label: 'As of', type: 'date' }, CURRENCY_FILTER],
columns: [ columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, {
key: 'customer',
label: 'Customer',
type: 'string',
sortable: true,
sortExpr: PAYER_EXPR,
},
{ key: 'invoices', label: 'Invoices', type: 'number' }, { key: 'invoices', label: 'Invoices', type: 'number' },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true }, { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
{ key: 'current', label: 'Current', type: 'money' }, { key: 'current', label: 'Current', type: 'money' },
@@ -46,7 +63,7 @@ export const agingReceivablesReport: ReportDefinition = {
defaultSort: { key: 'outstanding', dir: 'DESC' }, defaultSort: { key: 'outstanding', dir: 'DESC' },
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select('c.name', 'customer') .select(PAYER_EXPR, 'customer')
.addSelect('COUNT(*)::int', 'invoices') .addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding') .addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
.addSelect( .addSelect(
@@ -72,15 +89,19 @@ export const agingReceivablesReport: ReportDefinition = {
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`, `ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue90plus', 'overdue90plus',
) )
.groupBy('c.name'); .groupBy(PAYER_EXPR);
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding') .select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
.addSelect('COUNT(DISTINCT c.id)::int', 'customers') .addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
.getRawOne(); .getRawOne();
return [ return [
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' }, {
label: 'Outstanding',
value: Number(row?.outstanding ?? 0),
unit: currencyOf(ctx.params),
},
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) }, { label: 'Customers with balance', value: Number(row?.customers ?? 0) },
]; ];
}, },

View File

@@ -2,19 +2,34 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types'; import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity'; import { Invoice } from '../../billing/entities/invoice.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types'; import { ReportContext, ReportDefinition } from '../report.types';
import { CURRENCY_FILTER, currencyOf } from '../revenue-classification';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({
value: v,
label: v,
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx; const { params, directions } = ctx;
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL'); const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.where('i.deleted_at IS NULL')
// Both currencies live in this table; one money column cannot hold both.
.andWhere('UPPER(i.currency) = :currency', {
currency: currencyOf(params).toUpperCase(),
});
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom }); if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo }); if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null; const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
// Every other Finance report scopes by the caller's trade directions; without
// it this one reports the value of invoices its reader may not see.
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
} }
export const invoicingPipelineReport: ReportDefinition = { export const invoicingPipelineReport: ReportDefinition = {
@@ -24,7 +39,13 @@ export const invoicingPipelineReport: ReportDefinition = {
group: 'Finance', group: 'Finance',
filters: [ filters: [
{ key: 'date', label: 'Created', type: 'daterange' }, { key: 'date', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, CURRENCY_FILTER,
{
key: 'statuses',
label: 'Status',
type: 'multiselect',
options: STATUS_OPTIONS,
},
], ],
columns: [ columns: [
{ key: 'type', label: 'Type', type: 'string', sortable: true }, { key: 'type', label: 'Type', type: 'string', sortable: true },
@@ -52,8 +73,16 @@ export const invoicingPipelineReport: ReportDefinition = {
.getRawOne(); .getRawOne();
return [ return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) }, { label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' }, {
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, label: 'Total value',
value: Number(row?.totalAmount ?? 0),
unit: currencyOf(ctx.params),
},
{
label: 'Outstanding',
value: Number(row?.balance ?? 0),
unit: currencyOf(ctx.params),
},
]; ];
}, },
}; };

View File

@@ -28,8 +28,14 @@ import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.typ
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const REVENUE_CATEGORIES: ReportFilterOption[] = [ 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_IMPORT_MULTIMODAL',
label: 'Full Container Import — Multimodal',
},
{
value: 'CONTAINER_IMPORT_UNIMODAL',
label: 'Full Container Import — Unimodal',
},
{ value: 'CONTAINER_EXPORT', label: 'Full Container Export' }, { value: 'CONTAINER_EXPORT', label: 'Full Container Export' },
{ value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' }, { value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' },
{ value: 'FERTILIZER', label: 'Fertilizer Transportation' }, { value: 'FERTILIZER', label: 'Fertilizer Transportation' },
@@ -332,7 +338,10 @@ export const PERIOD_FILTER: ReportFilterDef = {
key: 'period', key: 'period',
label: 'Granularity', label: 'Granularity',
type: 'select', type: 'select',
options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })), options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({
value,
label: u.label,
})),
}; };
/** The timestamp every revenue report buckets and filters on. */ /** The timestamp every revenue report buckets and filters on. */
@@ -478,7 +487,12 @@ export const REVENUE_FILTERS: ReportFilterDef[] = [
options: REVENUE_CATEGORIES, options: REVENUE_CATEGORIES,
}, },
{ key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions },
{ key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, {
key: 'destination',
label: 'Destination',
type: 'select',
optionsQuery: yardOptions,
},
{ key: 'customer', label: 'Customer / booking ref', type: 'text' }, { key: 'customer', label: 'Customer / booking ref', type: 'text' },
{ {
key: 'methods', key: 'methods',
@@ -537,9 +551,6 @@ export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
deadInvoiceStatuses: DEAD_INVOICE_STATUSES, deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
}) })
.andWhere("i.source <> 'eims_self_test'") .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. // Mixing ETB and USD into one SUM produces a meaningless number.
.andWhere('il.currency = :currency', { currency: currencyOf(params) }); .andWhere('il.currency = :currency', { currency: currencyOf(params) });
@@ -611,13 +622,13 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
deadInvoiceStatuses: DEAD_INVOICE_STATUSES, deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
}) })
.andWhere("i.source <> 'eims_self_test'") .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) }); .andWhere('i.currency = :currency', { currency: currencyOf(params) });
if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo });
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); if (params.destination)
qb.andWhere('dy.code = :destination', { destination: params.destination });
if (params.customer) { if (params.customer) {
qb.andWhere( qb.andWhere(
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
@@ -630,14 +641,37 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
} }
/** /**
* What the payment gateway actually recorded against this invoice, summed. * What the payment gateway actually recorded against this invoice.
* `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 * `freight.payments` is keyed by the booking, not the invoice — `ref_id` holds
* carry. * the booking id and there is no invoice column — while one booking routinely
* carries several invoices (25 booking ids here back 58 of them). Reading the
* booking's gateway total straight off each invoice therefore hands the same
* money to every sibling: 113M of gateway receipts claimed against 44M of
* recorded settlement, which surfaced as ~89M of variance that does not exist.
*
* So the booking's receipts are apportioned across its invoices by their share
* of what was recorded as settled — the same device as {@link PAID_SHARE}, and
* the only split that makes the report's gateway column sum to the payments
* table. A booking whose invoices record no settlement at all cannot be split
* that way; it falls back to the billed share, so gateway money nobody booked
* still shows up as variance instead of vanishing.
*
* `invoices.payment_id` does resolve to a `freight.payments` row, but only 72
* of 85 successful payments are pointed at by one, so keying on it drops real
* receipts.
*/ */
export const GATEWAY_PAID = `( export const GATEWAY_PAID = `(
SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p (SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
WHERE p.ref_id = i.source_id AND p.status = 'success' WHERE p.ref_id = i.source_id AND p.status = 'success')
* COALESCE(
i.paid_amount / NULLIF((SELECT SUM(i2.paid_amount) FROM freight.invoices i2
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
i.total_amount / NULLIF((SELECT SUM(i2.total_amount) FROM freight.invoices i2
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
0)
)`; )`;
/** The payer, whichever of the two mutually exclusive payer columns is set. */ /** The payer, whichever of the two mutually exclusive payer columns is set. */