mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
@@ -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
|
||||||
|
|||||||
@@ -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) },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user