chore: reporting and filtering

This commit is contained in:
Nathnael
2026-08-21 09:25:21 +00:00
parent f55645fea9
commit 25d13baa6f
23 changed files with 941 additions and 902 deletions

View File

@@ -1,129 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
// (same guard as the retired report-queries.ts).
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking.
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function applyFilters(
ctx: ReportContext,
qb: SelectQueryBuilder<ObjectLiteral>,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (params.search) {
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', {
search: `%${params.search}%`,
});
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const bookingsListReport: ReportDefinition = {
key: 'bookings-list',
title: 'Bookings',
description: 'Every booking with customer, route, cargo and revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{ key: 'cargo', label: 'Cargo', type: 'string' },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'created', dir: 'DESC' },
query(ctx) {
const qb = ctx.ds
.createQueryBuilder()
.select('b.reference', 'reference')
.addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created')
.addSelect('c.name', 'customer')
.addSelect('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect('o.label', 'origin')
.addSelect('d.label', 'destination')
.addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo')
.addSelect(`ROUND(${TONS})::float8`, 'tons')
.addSelect(`ROUND(${REVENUE})::float8`, 'amount')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.innerJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.innerJoin(Yard, 'd', 'd.id = b.destination_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id');
return applyFilters(ctx, qb);
},
async summary(ctx) {
const qb = applyFilters(
ctx,
ctx.ds
.createQueryBuilder()
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id'),
);
const row = await qb.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,83 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Contract, 'ct')
.leftJoin(Company, 'c', 'c.id = ct.company_id')
.where('ct.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo });
if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind });
if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const contractLifecycleReport: ReportDefinition = {
key: 'contract-lifecycle',
title: 'Contracts',
description: 'Signed, active and cancelled contracts',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Valid from', type: 'daterange' },
{ key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'kind', label: 'Kind', type: 'string' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'freightType', label: 'Freight type', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' },
{ key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' },
{ key: 'validUntil', label: 'Valid until', type: 'date' },
{ key: 'signedAt', label: 'Signed', type: 'date' },
],
defaultSort: { key: 'validFrom', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ct.reference', 'reference')
.addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer')
.addSelect('ct.contract_kind', 'kind')
.addSelect('ct.trade_direction', 'direction')
.addSelect('ct.freight_type', 'freightType')
.addSelect('ct.status', 'status')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed')
.addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Contracts', value: Number(row?.total ?? 0) },
{ label: 'Signed', value: Number(row?.signed ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -1,67 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are
// CompanyProfile fields, not Company's — a company can hold several profiles
// (e.g. importer AND exporter), each independently approved/suspended.
const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(CompanyProfile, 'cp')
.innerJoin(Company, 'c', 'c.id = cp.company_id')
.where('cp.deleted_at IS NULL');
if (params.type) qb.andWhere('cp.type = :type', { type: params.type });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses });
return qb;
}
export const customerStatusReport: ReportDefinition = {
key: 'customer-status',
title: 'Customer Profiles',
description: 'Company profiles by role type and approval status',
group: 'Commercial',
filters: [
{ key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'note', label: 'Note', type: 'string' },
{ key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' },
],
defaultSort: { key: 'reviewedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'company')
.addSelect('cp.type', 'type')
.addSelect('cp.status', 'status')
.addSelect("COALESCE(cp.reference, '')", 'reference')
.addSelect("COALESCE(cp.review_note, '')", 'note')
.addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended')
.setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended })
.getRawOne();
return [
{ label: 'Profiles', value: Number(row?.total ?? 0) },
{ label: 'Active', value: Number(row?.active ?? 0) },
{ label: 'Suspended', value: Number(row?.suspended ?? 0) },
];
},
};

View File

@@ -1,72 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id')
.where('i.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
}
export const invoicesByStatusReport: ReportDefinition = {
key: 'invoices-by-status',
title: 'Invoices',
description: 'Every invoice with customer, profile type and settlement status',
group: 'Finance',
filters: [
{ key: 'date', label: 'Issued', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'profileType', label: 'Profile', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
{ key: 'paidAmount', label: 'Paid', type: 'money' },
{ key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true },
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' },
{ key: 'dueAt', label: 'Due', type: 'date' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('i.invoice_number', 'invoiceNumber')
.addSelect('c.name', 'customer')
.addSelect("COALESCE(cp.type, 'Unknown')", 'profileType')
.addSelect('i.status', 'status')
.addSelect('ROUND(i.total_amount)::float8', 'totalAmount')
.addSelect('ROUND(i.paid_amount)::float8', 'paidAmount')
.addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount')
.addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,73 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// No direct company link on payments (refId points at whatever the intent was
// for — booking, demurrage, ...); breakdown stops at status/method/currency.
const STATUS_OPTIONS = [
{ value: 'action-required', label: 'Action required' },
{ value: 'processing', label: 'Processing' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'canceled', label: 'Canceled' },
{ value: 'refunded', label: 'Refunded' },
];
const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map(
(v) => ({ value: v, label: v }),
);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
// payments carries no deleted_at column (unlike the rest of the schema) —
// confirmed against the live DB, not assumed from BaseEntity.
const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1');
if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo });
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses });
return qb;
}
export const paymentsByStatusReport: ReportDefinition = {
key: 'payments-by-status',
title: 'Payments by Status',
description: 'Payment volume and value by status, method and currency',
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'method', label: 'Method', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'payments', label: 'Payments', type: 'number', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'amount', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('p.status', 'status')
.addSelect('p.method', 'method')
.addSelect('p.currency', 'currency')
.addSelect('COUNT(*)::int', 'payments')
.addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount')
.groupBy('p.status')
.addGroupBy('p.method')
.addGroupBy('p.currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'payments')
.addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid')
.getRawOne();
return [
{ label: 'Payments', value: Number(row?.payments ?? 0) },
{ label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,91 +1,106 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportContext, ReportColumn, ReportDefinition } from "../report.types";
import {
PAID_SHARE,
PAYER_EXPR,
PAYMENT_CLASSES,
PAYMENT_CLASS_EXPR,
REVENUE_FILTERS,
REVENUE_SUM,
currencyOf,
revenueLedgerQb,
} from "../revenue-classification";
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
/**
* One column per payment class, pivoted with FILTER. The class values are the
* compile-time constants in PAYMENT_CLASSES, never user input, so they are
* safe to interpolate.
*/
const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({
value: c.value,
key: c.value
.toLowerCase()
.replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()),
label: c.label,
}));
const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({
key: c.key,
label: c.label,
type: "money",
sortable: true,
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
return revenueLedgerQb(ctx);
}
export const revenueByCustomerReport: ReportDefinition = {
key: 'revenue-by-customer',
title: 'Revenue by Customer',
description: 'Ranked customers by booking revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
key: "revenue-by-customer",
title: "Revenue by Customer",
description:
"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 " +
"clearance, first/last mile, overweight, cancellation, demurrage, storage, loading " +
"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 " +
"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, " +
"double-handling and lashing stand in for it.",
group: "Finance",
filters: REVENUE_FILTERS,
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
{
key: "customer",
label: "Customer",
type: "string",
sortable: true,
sortExpr: PAYER_EXPR,
},
{ key: "revenue", label: "Total revenue", type: "money", sortable: true },
{ key: "paid", label: "Paid", type: "money", sortable: true },
{ key: "outstanding", label: "Outstanding", type: "money", sortable: true },
...classMoneyColumns,
{ 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"] },
drill: { to: "revenue-transactions", carry: { customer: "customer" } },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('c.name');
const qb = baseQuery(ctx)
.select(PAYER_EXPR, "customer")
.addSelect(REVENUE_SUM, "revenue")
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid")
.addSelect(
`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`,
"outstanding",
)
.addSelect("COUNT(DISTINCT i.id)::int", "invoices")
.groupBy(PAYER_EXPR);
for (const c of CLASS_COLUMNS) {
qb.addSelect(
`ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${PAYMENT_CLASS_EXPR} = '${c.value}'), 0))::float8`,
c.key,
);
}
return qb;
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT c.name)::int', 'customers')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.getRawOne();
.select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, "customers")
.addSelect(REVENUE_SUM, "revenue")
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid")
.getRawOne<{ customers: number; revenue: number; paid: number }>();
const revenue = Number(row?.revenue ?? 0);
const paid = Number(row?.paid ?? 0);
const unit = currencyOf(ctx.params);
return [
{ label: 'Customers', value: Number(row?.customers ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
{ label: "Customers", value: Number(row?.customers ?? 0) },
{ label: "Total revenue", value: revenue, unit },
{ label: "Paid", value: paid, unit },
{ label: "Outstanding", value: Math.round(revenue - paid), unit },
];
},
};

View File

@@ -1,62 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const revenueSummaryReport: ReportDefinition = {
key: 'revenue-summary',
title: 'Revenue Summary',
description: 'Booking revenue by direction, cargo type and currency',
group: 'Finance',
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
columns: [
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')
.addSelect('b.freight_type', 'freightType')
.addSelect('b.payment_currency', 'currency')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('b.trade_direction')
.addGroupBy('b.freight_type')
.addGroupBy('b.payment_currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.addSelect('COUNT(*)::int', 'bookings')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,6 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportContext, ReportDefinition } from "../report.types";
import {
CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR,
@@ -14,8 +14,8 @@ import {
implementRateExpr,
plannedRowsParams,
plannedRowsSql,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
} from "../operations-classification";
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from "../revenue-classification";
const CONTAINERS_20 = `COALESCE(SUM((
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
@@ -39,41 +39,39 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
}
export const teuPerformanceReport: ReportDefinition = {
key: 'teu-performance',
title: 'TEU Performance',
key: "teu-performance",
title: "TEU Performance",
description:
'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' +
'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' +
'marshalling record — the containers actually allocated to wagons — not from the ' +
'billing lines. Plan comes from Operational targets.' +
"Twenty-foot equivalent units moved per container class against plan. Every 40ft box " +
"counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the " +
"marshalling record — the containers actually allocated to wagons — not from the " +
"billing lines. Plan comes from Operational targets." +
PLAN_GRANULARITY_NOTE,
group: 'Operations',
group: "Operations",
filters: [
PERIOD_FILTER,
...OPERATIONS_FILTERS,
{ key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES },
{ key: "classes", label: "Container class", type: "multiselect", options: CONTAINER_CLASSES },
],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{ key: 'containerClass', label: 'Container type', type: 'string', sortable: true },
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: "period", label: "Period", type: "string", sortable: true },
{ key: "containerClass", label: "Container type", type: "string", sortable: true },
{ key: "containers20", label: "20ft", type: "number", sortable: true },
{ key: "containers40", label: "40ft", type: "number", sortable: true },
{ key: "operated", label: "Operated (TEU)", type: "number", sortable: true },
{ key: "plan", label: "Plan", type: "number" },
{ key: "implementRate", label: "Implement rate", type: "percent" },
],
defaultSort: { key: 'operated', dir: 'DESC' },
chart: { type: 'bar', x: 'containerClass', y: ['operated'] },
defaultSort: { key: "operated", dir: "DESC" },
chart: { type: "bar", x: "containerClass", y: ["operated"] },
query(ctx) {
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
const operated = baseQuery(ctx)
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
.addSelect(CONTAINER_CLASS_EXPR, 'class_key')
.addSelect(CONTAINERS_20, 'containers20')
.addSelect(CONTAINERS_40, 'containers40')
.addSelect(CONTAINERS_EXPR, 'containers')
.addSelect(TEU_EXPR, 'operated')
.select(periodExprOn(OPS_DATE, ctx.params), "period")
.addSelect(CONTAINER_CLASS_EXPR, "class_key")
.addSelect(CONTAINERS_20, "containers20")
.addSelect(CONTAINERS_40, "containers40")
.addSelect(TEU_EXPR, "operated")
.groupBy(bucket)
.addGroupBy(CONTAINER_CLASS_EXPR);
@@ -84,38 +82,36 @@ export const teuPerformanceReport: ReportDefinition = {
COALESCE(o.class_key, p.plan_key) AS class_key,
COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40,
COALESCE(o.containers, 0) AS containers,
COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p
FULL OUTER JOIN (${plannedRowsSql("TEU", "container_class", ctx.params)}) p
ON p.period = o.period AND p.plan_key = o.class_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.from(`(${combined})`, "r")
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.select('r.period', 'period')
.addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass')
.addSelect('r.class_key', 'containerClassKey')
.addSelect('r.containers20::int', 'containers20')
.addSelect('r.containers40::int', 'containers40')
.addSelect('r.containers::int', 'containers')
.addSelect('r.operated::int', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
.select("r.period", "period")
.addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass")
.addSelect("r.class_key", "containerClassKey")
.addSelect("r.containers20::int", "containers20")
.addSelect("r.containers40::int", "containers40")
.addSelect("r.operated::int", "operated")
.addSelect("r.plan::float8", "plan")
.addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate");
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(TEU_EXPR, 'teu')
.addSelect(CONTAINERS_EXPR, 'containers')
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
.select(TEU_EXPR, "teu")
.addSelect(CONTAINERS_EXPR, "containers")
.addSelect("COUNT(DISTINCT ts.id)::int", "trains")
.getRawOne<{ teu: number; containers: number; trains: number }>();
return [
{ label: 'TEU', value: Number(row?.teu ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) },
{ label: 'Trains', value: Number(row?.trains ?? 0) },
{ label: "TEU", value: Number(row?.teu ?? 0) },
{ label: "Containers", value: Number(row?.containers ?? 0) },
{ label: "Trains", value: Number(row?.trains ?? 0) },
];
},
};

View File

@@ -1,45 +1,39 @@
import { ReportKey } from '../../seed/freight-permissions.registry';
import { bookingsListReport } from './definitions/bookings-list.report';
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report';
import { agingReceivablesReport } from './definitions/aging-receivables.report';
import { contractUtilizationReport } from './definitions/contract-utilization.report';
import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report';
import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report';
import { wagonRequestsReport } from './definitions/wagon-requests.report';
import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report';
import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report';
import { trainScheduleStatusReport } from './definitions/train-schedule-status.report';
import { trainTurnaroundReport } from './definitions/train-turnaround.report';
import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report';
import { loadedCapacityReport } from './definitions/loaded-capacity.report';
import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report';
import { customerStatusReport } from './definitions/customer-status.report';
import { contractLifecycleReport } from './definitions/contract-lifecycle.report';
import { customsDocumentsReport } from './definitions/customs-documents.report';
import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report';
import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report';
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 { stationStayingTimeReport } from './definitions/station-staying-time.report';
import { turnaroundCycleReport } from './definitions/turnaround-cycle.report';
import { trainDelaysReport } from './definitions/train-delays.report';
import { trainsetPerformanceReport } from './definitions/trainset-performance.report';
import { teuPerformanceReport } from './definitions/teu-performance.report';
import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report';
import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report';
import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report';
import { ReportDefinition } from './report.types';
import { ReportKey } from "../../seed/freight-permissions.registry";
import { revenueByCustomerReport } from "./definitions/revenue-by-customer.report";
import { agingReceivablesReport } from "./definitions/aging-receivables.report";
import { contractUtilizationReport } from "./definitions/contract-utilization.report";
import { wagonFleetStatusReport } from "./definitions/wagon-fleet-status.report";
import { wagonStatusDurationReport } from "./definitions/wagon-status-duration.report";
import { wagonRequestsReport } from "./definitions/wagon-requests.report";
import { locomotiveFleetStatusReport } from "./definitions/locomotive-fleet-status.report";
import { bookingStatusBreakdownReport } from "./definitions/booking-status-breakdown.report";
import { trainScheduleStatusReport } from "./definitions/train-schedule-status.report";
import { trainTurnaroundReport } from "./definitions/train-turnaround.report";
import { wagonTeuUtilizationReport } from "./definitions/wagon-teu-utilization.report";
import { loadedCapacityReport } from "./definitions/loaded-capacity.report";
import { globalLogisticsWagonsReport } from "./definitions/global-logistics-wagons.report";
import { customsDocumentsReport } from "./definitions/customs-documents.report";
import { invoicingPipelineReport } from "./definitions/invoicing-pipeline.report";
import { firstLastMileBookingsReport } from "./definitions/first-last-mile-bookings.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 { stationStayingTimeReport } from "./definitions/station-staying-time.report";
import { turnaroundCycleReport } from "./definitions/turnaround-cycle.report";
import { trainDelaysReport } from "./definitions/train-delays.report";
import { trainsetPerformanceReport } from "./definitions/trainset-performance.report";
import { teuPerformanceReport } from "./definitions/teu-performance.report";
import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report";
import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
import { ReportDefinition } from "./report.types";
/**
* Every report the platform knows about. Adding one = a new file under
@@ -47,7 +41,6 @@ import { ReportDefinition } from './report.types';
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
*/
export const REPORTS: ReportDefinition[] = [
bookingsListReport,
revenueByCustomerReport,
agingReceivablesReport,
contractUtilizationReport,
@@ -61,14 +54,9 @@ export const REPORTS: ReportDefinition[] = [
wagonTeuUtilizationReport,
loadedCapacityReport,
globalLogisticsWagonsReport,
customerStatusReport,
contractLifecycleReport,
customsDocumentsReport,
invoicingPipelineReport,
firstLastMileBookingsReport,
invoicesByStatusReport,
paymentsByStatusReport,
revenueSummaryReport,
cargoSummaryReport,
revenueByCategoryReport,
revenueTransactionsReport,
@@ -89,7 +77,9 @@ export const REPORTS: ReportDefinition[] = [
cargoVolumeByStationReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
const BY_KEY = new Map<ReportKey, ReportDefinition>(
REPORTS.map((r) => [r.key, r]),
);
export function getReport(key: string): ReportDefinition | undefined {
return BY_KEY.get(key as ReportKey);