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

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-24 12:04:30 +03:00
committed by GitHub
32 changed files with 1058 additions and 128 deletions

View File

@@ -37,7 +37,11 @@ import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import {
applySettlement,
invoicePaymentMethodExpr,
round2,
} from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
@@ -111,6 +115,8 @@ export interface InvoiceListFilters {
statuses?: Freight.InvoiceStatus[];
sources?: string[];
eimsStatuses?: string[];
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
paymentMethods?: string[];
currency?: string;
search?: string;
issuedFrom?: string;
@@ -125,6 +131,13 @@ export interface InvoiceListFilters {
tradeDirections?: string[];
}
/**
* The list/summary query builders both alias the invoice as `invoice` and the
* joined gateway payment as `payment`; TypeORM rewrites those alias.property
* references into real quoted columns.
*/
const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment");
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
@@ -294,6 +307,13 @@ export class BillingService {
eimsStatuses: filter.eimsStatuses,
});
}
if (filter.paymentMethods?.length) {
// Requires the `payment` alias to be joined by the caller — both call
// sites do, unconditionally, so this can never reference a missing alias.
qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, {
paymentMethods: filter.paymentMethods,
});
}
if (filter.currency) {
// Stored casing has drifted ("usd" rows exist) — compare normalised.
qb.andWhere("UPPER(invoice.currency) = :currency", {
@@ -333,20 +353,24 @@ export class BillingService {
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
}
if (filter.search) {
// Searches what the row actually shows: its number, who it bills, and
// the source record behind it (booking reference, GRN, shipping line).
// Searches what the row actually shows: its number, who it bills, the
// source record behind it (booking reference, PNR, GRN, shipping line)
// and the payment references a customer or a provider support desk would
// quote back — the gateway transaction id and our merchant order id.
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
// Requires the `company` alias — every caller of this joins it.
// Requires the `company` and `payment` aliases — every caller joins both.
qb.andWhere(
`(invoice.invoiceNumber ILIKE :search
OR invoice.sourceId ILIKE :search
OR company.name ILIKE :search
OR payment.transactionId ILIKE :search
OR payment.merchantOrderId ILIKE :search
OR EXISTS (
SELECT 1 FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
WHERE b.reference ILIKE :search
WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search)
AND (b.id::text = invoice.source_id
OR wi.id::text = invoice.source_id
OR fm.id::text = invoice.source_id
@@ -388,6 +412,9 @@ export class BillingService {
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
// The gateway payment behind the invoice: the settled method and the
// provider's transaction reference both live on it, and nowhere else.
.leftJoinAndSelect("invoice.payment", "payment")
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
// raw. The id tiebreaker keeps paging stable when the sort column ties
// (issuedAt is null on every DRAFT row).
@@ -544,6 +571,7 @@ export class BillingService {
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
// so the alias has to exist even though the summary only sums money.
.leftJoin("invoice.company", "company")
.leftJoin("invoice.payment", "payment")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");
@@ -757,7 +785,7 @@ export class BillingService {
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
relations: { company: true, companyProfile: true, payment: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);

View File

@@ -15,6 +15,7 @@ import {
} from "class-validator";
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util";
/** Columns the invoice list may be ordered by -> their query-builder expression. */
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
@@ -96,6 +97,19 @@ export class FilterInvoiceDto {
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
eimsStatuses?: EimsInvoiceStatus[];
/**
* Settled payment method (`?paymentMethods=CBE_BILL,BANK_TRANSFER`). Values are
* the normalised UPPER_SNAKE vocabulary of `invoicePaymentMethodExpr`. Not
* validated against a fixed list — the manual pay endpoint takes a free-form
* method, so an `IsIn` here would silently drop a real value.
*/
@ApiPropertyOptional({ isArray: true, enum: INVOICE_PAYMENT_METHODS })
@IsOptional()
@Transform(csv)
@IsArray()
@IsString({ each: true })
paymentMethods?: string[];
/** Manual-payments worklist and the invoice list: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional()

View File

@@ -34,3 +34,43 @@ export function applySettlement(
const balanceAmount = Math.max(0, round2(total - paidAmount));
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
}
/**
* SQL for an invoice's settled payment method, normalised to one vocabulary.
*
* Two sources have to be merged: gateway settlements carry the real provider on
* the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the
* invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual
* settlements have no payments row at all and the ledger is the ONLY source
* (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried).
* So: provider first, newest ledger entry as the fallback.
*
* `-> -1` is the last ledger element — the ledger is appended newest-last.
* `::text` is not cosmetic: `payments.method` is a real Postgres enum, and
* COALESCE against a text fallback fails without the cast.
*
* Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one
* value on screen, in the filter and in the export.
*/
export const invoicePaymentMethodExpr = (invoice: string, payment: string): string =>
`UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`;
/**
* The methods the filter offers. Not exhaustive by construction — the manual
* pay endpoint takes a free-form `method` string — so nothing validates against
* this list; it is the pick-list, not a constraint.
*/
export const INVOICE_PAYMENT_METHODS = [
"TELEBIRR",
"CBE_BIRR",
"CBE_BILL",
"EBIRR",
"WAAFI",
"CARD",
"DMONEY",
"CAC_BANK",
"BANK_TRANSFER",
"OFFLINE",
/** Settled at a gateway whose provider row is no longer linked. */
"GATEWAY",
] as const;

View File

@@ -1,11 +1,17 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Invoice } from '../../billing/entities/invoice.entity';
import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/** Same expression the list endpoint filters by, in this dataset's aliases. */
const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p');
/**
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
* signature blob) and `eims_last_error` (a raw error dump). The
@@ -26,8 +32,16 @@ export const invoicesDataset: ExportDataset = {
// with a second query. In a dataset it is just a join by column.
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
// The gateway payment behind the invoice — provider method and its
// transaction reference. Always joined: `scope()` filters on it.
{ alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' },
// Booking behind the invoice, for the PNR alone. `i.source_id` is a bare
// varchar pointer that is not always a UUID (EIMS self-test rows carry a
// slug), so the cast goes on `bk.id`, never on `source_id` — casting the
// other way throws on those rows.
{ alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" },
],
alwaysJoin: ['c'],
alwaysJoin: ['c', 'p', 'bk'],
groups: [
{ id: 'invoice', label: 'Invoice' },
@@ -66,6 +80,12 @@ export const invoicesDataset: ExportDataset = {
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD },
{ key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' },
// The CBE_BILL reference the customer pays against — stamped onto the
// booking at payment-initiation time, not held on the invoice or payment.
{ key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' },
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' },
{
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
@@ -104,6 +124,7 @@ export const invoicesDataset: ExportDataset = {
{ key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'sources', label: 'Source', type: 'multiselect' },
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
{ key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' },
@@ -113,7 +134,7 @@ export const invoicesDataset: ExportDataset = {
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
{ key: 'overdue', label: 'Overdue only', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
{ key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
@@ -132,6 +153,10 @@ export const invoicesDataset: ExportDataset = {
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
const eimsStatuses = params.eimsStatuses as string[] | null;
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
const paymentMethods = params.paymentMethods as string[] | null;
if (paymentMethods?.length) {
qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods });
}
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
// same as the list endpoint does.
if (params.currency) {
@@ -146,7 +171,18 @@ export const invoicesDataset: ExportDataset = {
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
// Same reach as the list page's search box, minus the source-record
// lookups it does with correlated subqueries: number, customer, the PNR
// the customer pays against, and the payment references support desks
// quote back.
qb.andWhere(
`(i.invoice_number ILIKE :search
OR c.name ILIKE :search
OR bk.pnr_code ILIKE :search
OR p.transaction_id ILIKE :search
OR p.merchant_order_id ILIKE :search)`,
{ search: `%${params.search as string}%` },
);
}
// ACL: invoices.source_id is a varchar pointer at the originating booking.
applyBookingRefDirectionScope(qb, 'i.source_id', directions);

View File

@@ -1,5 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
import {
ACTUAL_TONS_EXPR,
@@ -14,23 +15,53 @@ import {
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
distanceKmBetween,
} from '../operations-classification';
/**
* Distance and empty-wagon count belong to the departure, so they are constant
* within a group that includes `ts.id` — MAX() satisfies Postgres without
* dragging a scalar subselect through the GROUP BY.
* A leg is one station-to-station move the train actually made: two consecutive
* checkpoints at different yards. DISTINCT because a train that works the same
* pair twice in one departure is still one leg — without it the join would fan
* the cargo out again and double every SUM in the group.
*/
const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`;
const LEGS = `(
SELECT DISTINCT e.train_schedule_id, e.from_yard_id, e.to_yard_id
FROM (
SELECT ev.train_schedule_id,
ev.yard_id AS from_yard_id,
lead(ev.yard_id) OVER (
PARTITION BY ev.train_schedule_id ORDER BY ev.occurred_at
) AS to_yard_id
FROM freight.train_checkpoint_events ev
WHERE ev.deleted_at IS NULL
) e
WHERE e.to_yard_id IS NOT NULL AND e.to_yard_id <> e.from_yard_id
)`;
/**
* LEFT joined, and both ends fall back to the schedule's own corridor: a
* departure with no checkpoints logged has no legs, and must keep the single
* origin-to-destination row it had before this report knew about legs.
*/
const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)';
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
/**
* Distance and empty-wagon count are constant within a group that includes
* `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar
* subselect through the GROUP BY.
*/
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
const TOTAL_WAGONS = `(COUNT(DISTINCT tsw.id) + ${EMPTY_WAGONS})::int`;
/**
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
* configured distance. A missing distance is not a zero distance, and zeroing
* it would understate the corridor's work without anyone noticing.
*/
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`;
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
@@ -38,55 +69,74 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return qb;
}
/** The row grain: one leg of one departure. Only `query()` needs it — the KPIs
* are corridor-level and would count the same cargo once per leg if they had
* this join. */
function legQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return baseQuery(ctx)
.leftJoin(LEGS, 'leg', 'leg.train_schedule_id = ts.id')
.leftJoin(Yard, 'lfy', `lfy.id = ${LEG_FROM}`)
.leftJoin(Yard, 'lty', `lty.id = ${LEG_TO}`);
}
export const chargedVsActualVolumeReport: ReportDefinition = {
key: 'charged-vs-actual-volume',
title: 'Charged and Actual Volumes',
description:
'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' +
'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' +
'2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' +
'all editable in Operating standards. Actual volume is what the marshalling recorded. ' +
'Vehicle-Km counts the empty wagons on that train, so it repeats across the trains ' +
'cargo types rather than being split between them.',
'Charged versus actual volume per leg and cargo type, with Ton/Km and Vehicle-Km. ' +
'A leg is one station-to-station move the train actually made, read from its logged ' +
'checkpoints; a departure with no checkpoints logged shows as its single planned ' +
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' +
'perishables — all editable in Operating standards. Actual volume is what the ' +
'marshalling recorded. Volumes and wagon counts belong to the train, not to the leg, ' +
'so they repeat on every leg it ran and across its cargo types rather than being split ' +
'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' +
'exception and are the legs own, so they add up across legs into the real corridor ' +
'figure.',
group: 'Operations',
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
{ key: 'station', label: 'Station', type: 'string' },
{ key: 'leg', label: 'Leg', type: 'string' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
{ key: 'totalWagons', label: 'Total wagons', type: 'number', sortable: true },
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
],
defaultSort: { key: 'departedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
return legQuery(ctx)
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg')
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
.addSelect(TEU_EXPR, 'teu')
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
.addSelect(TOTAL_WAGONS, 'totalWagons')
.addSelect(`${LEG_KM}::float8`, 'distanceKm')
.addSelect(TON_KM, 'tonKm')
.addSelect(VEHICLE_KM, 'vehicleKm')
.groupBy('ts.id')
.addGroupBy('ts.train_number')
.addGroupBy('ts.actual_departure_at')
.addGroupBy('ts.scheduled_departure_date')
.addGroupBy('oy.label')
.addGroupBy('oy.code')
.addGroupBy('dy.label')
.addGroupBy('dy.code')
.addGroupBy('leg.from_yard_id')
.addGroupBy('leg.to_yard_id')
.addGroupBy('lfy.label')
.addGroupBy('lfy.code')
.addGroupBy('lty.label')
.addGroupBy('lty.code')
.addGroupBy(CARGO_CATEGORY_EXPR);
},
async summary(ctx) {

View File

@@ -91,8 +91,8 @@ export const contractUtilizationReport: ReportDefinition = {
.addSelect('c.name', 'customer')
.addSelect('ct.status', 'status')
.addSelect('ct.contract_kind', 'kind')
.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.contract_valid_from, 'YYYY-MM-DD HH24:MI')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD HH24:MI')`, 'validUntil')
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')

View File

@@ -3,6 +3,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// FirstMile and LastMile are separate tables with an identical shape (status,
@@ -27,11 +28,11 @@ const STATUS_OPTIONS = [
];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(LEG_UNION, 'fl')
.innerJoin(Booking, 'b', 'b.id = fl.booking_id')
.innerJoin(Booking, 'b', 'b.id = fl.booking_id AND b.deleted_at IS NULL')
.leftJoin(Company, 'c', 'c.id = b.company_id')
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
.where('1 = 1');
@@ -41,6 +42,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
// Every leg hangs off a booking, so the trade scope is the booking's own
// direction — the same rule the booking-grain reports apply.
applyDirectionScope(qb, 'b.trade_direction', directions);
return qb;
}
@@ -72,7 +77,7 @@ export const firstLastMileBookingsReport: ReportDefinition = {
.addSelect('fl.status', 'status')
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt');
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD HH24:MI')`, 'createdAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)

View File

@@ -2,22 +2,28 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(ScheduleWagonAdjustmentLog, 'l')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id AND ts.deleted_at IS NULL')
.where('l.deleted_at IS NULL')
.andWhere("l.action IN ('ADD', 'REMOVE')");
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
// A log row whose schedule is gone carries no direction to scope by and
// stays visible — the rule the other ledgers apply to booking-less rows.
const scope = directionScopeSql('ts.direction', directions);
qb.andWhere(`(ts.id IS NULL OR ${scope.sql})`, scope.params);
return qb;
}

View File

@@ -3,13 +3,14 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
// maintained by the wagon-allocation flow — no need to re-derive it from
// bulk/container line items.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSetWagon, 'tsw')
@@ -24,6 +25,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
@@ -49,7 +52,7 @@ export const loadedCapacityReport: ReportDefinition = {
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')

View File

@@ -12,7 +12,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
.createQueryBuilder()
.from(Locomotive, 'l')
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
.where('l.deleted_at IS NULL');
.where('l.deleted_at IS NULL')
// Names ending '#' are excluded from the fleet report by request; the
// marker is a roster convention, not a column the schema tracks.
.andWhere("COALESCE(l.name, '') NOT LIKE '%#'");
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });

View File

@@ -0,0 +1,45 @@
import {
LINES,
countAliases,
lineRefs,
portWarehouseSummaryReport,
} from "./port-warehouse-summary.report";
/**
* The sheet's lines are SQL fragments over the aggregate's select aliases, so a
* renamed or dropped count is invisible to the type-checker and surfaces as a
* 42703 the first time someone opens the report.
*/
describe("port-warehouse-summary", () => {
it("every line reads a column the aggregate selects", () => {
expect(lineRefs().filter((ref) => !countAliases().includes(ref))).toEqual(
[],
);
});
it("every selected count is used by a line", () => {
expect(
countAliases().filter((alias) => !lineRefs().includes(alias)),
).toEqual([]);
});
it("labels are unique — the sheet groups on them", () => {
const labels = LINES.map((l) => l.label);
expect(new Set(labels).size).toBe(labels.length);
});
it("declares a column for every direction the pivot emits", () => {
const keys = portWarehouseSummaryReport.columns.map((c) => c.key);
expect(keys).toEqual(
expect.arrayContaining([
"sn",
"section",
"metric",
"export",
"import",
"domestic",
"total",
]),
);
});
});

View File

@@ -0,0 +1,322 @@
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { ReportContext, ReportDefinition } from "../report.types";
import {
ALLOC_CONTAINERS_20,
ALLOC_CONTAINERS_40,
OPERATIONS_FILTERS,
SCHEDULE_IS_CONTAINER,
allocationLedgerQb,
} from "../operations-classification";
import { yardOptions } from "../revenue-classification";
/**
* The monthly operations summary a port warehouse publishes — the shape of the
* GMP workbook: one line per operation, counted separately for export and
* import, and again over everything.
*
* Every other operations report is a normal grouped table; this one is a
* transposed count sheet, because that is the artefact being reproduced. It is
* built by aggregating the allocation ledger once per direction and once
* overall, unpivoting each of those rows into one row per named operation, then
* pivoting direction back out into columns.
*
* Two deliberate departures from the workbook:
*
* - **Wagons are counted, not derived.** The workbook computes wagons as
* `20ft/2 + 40ft + bulk wagons` because it has no marshalling record. We do —
* `COUNT(DISTINCT train_set_wagons.id)` is what actually carried the cargo.
* The two disagree whenever a wagon ran part-loaded, and the counted figure
* is the true one.
* - **Demurrage is not here.** It is billed on invoice lines, a different fact
* table entirely; Revenue by Category filtered to Demurrage already answers
* it and joining it in at allocation grain would double-count.
*/
/** Booking-level empty marker, NULL-safe so an allocation with no booking is "laden". */
const IS_EMPTY = "COALESCE(b.equipment_return = 'RETURN', false)";
const IS_CONTAINER_LOAD = "wba.load_type = 'CONTAINER'";
/** The bulk twin of {@link SCHEDULE_IS_CONTAINER} — same train-set grain. */
const SCHEDULE_IS_BULK = `EXISTS (
SELECT 1 FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL
WHERE w.train_set_id = ts.train_set_id
AND a.deleted_at IS NULL AND a.load_type <> 'CONTAINER'
)`;
const DIRECTION = "COALESCE(b.trade_direction, ts.direction)";
const boxes = (perAllocation: string, cond: string): string =>
`(COALESCE(SUM(${perAllocation}) FILTER (WHERE ${cond}), 0))::int`;
const trains = (cond?: string): string =>
`(COUNT(DISTINCT ts.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`;
const wagons = (cond?: string): string =>
`(COUNT(DISTINCT tsw.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`;
/**
* The raw counts the sheet is built from, keyed by the alias each is selected
* as. {@link LINES} may only reference these; the spec beside this file is what
* keeps the two in step, since a stale `p.<alias>` is a runtime 42703 that
* neither tsc nor a type-check can see.
*/
const COUNTS: Record<string, string> = {
trains: trains(),
container_trains: trains(
`${SCHEDULE_IS_CONTAINER} AND NOT ${SCHEDULE_IS_BULK}`,
),
bulk_trains: trains(`${SCHEDULE_IS_BULK} AND NOT ${SCHEDULE_IS_CONTAINER}`),
mixed_trains: trains(`${SCHEDULE_IS_CONTAINER} AND ${SCHEDULE_IS_BULK}`),
full_20: boxes(
ALLOC_CONTAINERS_20,
`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`,
),
full_40: boxes(
ALLOC_CONTAINERS_40,
`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`,
),
empty_20: boxes(ALLOC_CONTAINERS_20, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
empty_40: boxes(ALLOC_CONTAINERS_40, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
full_wagons: wagons(`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`),
empty_wagons: wagons(`${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
bulk_wagons: wagons(`NOT ${IS_CONTAINER_LOAD}`),
wagons: wagons(),
};
/**
* The operation lines, in the workbook's order. `value` is an expression over
* the per-direction aggregate `p`, so a derived line (totals, TEU) is plain
* arithmetic rather than a second pass over the ledger.
*/
export const LINES: { section: string; label: string; value: string }[] = [
{ section: "Trains", label: "Total trains", value: "p.trains" },
{ section: "Trains", label: "Container trains", value: "p.container_trains" },
{ section: "Trains", label: "Bulk cargo trains", value: "p.bulk_trains" },
{
section: "Trains",
label: "Mixed bulk and container trains",
value: "p.mixed_trains",
},
{ section: "Containers", label: "Full containers 20ft", value: "p.full_20" },
{ section: "Containers", label: "Full containers 40ft", value: "p.full_40" },
{
section: "Containers",
label: "Empty containers 20ft",
value: "p.empty_20",
},
{
section: "Containers",
label: "Empty containers 40ft",
value: "p.empty_40",
},
{
section: "Containers",
label: "Total 20ft containers",
value: "p.full_20 + p.empty_20",
},
{
section: "Containers",
label: "Total 40ft containers",
value: "p.full_40 + p.empty_40",
},
{
section: "Containers",
label: "Total containers",
value: "p.full_20 + p.empty_20 + p.full_40 + p.empty_40",
},
{
section: "Containers",
label: "Total TEU",
value: "p.full_20 + p.empty_20 + (p.full_40 + p.empty_40) * 2",
},
{
section: "Wagons",
label: "Wagons loaded with full containers",
value: "p.full_wagons",
},
{
section: "Wagons",
label: "Wagons loaded with empty containers",
value: "p.empty_wagons",
},
{
section: "Wagons",
label: "Wagons loaded with bulk cargo",
value: "p.bulk_wagons",
},
{ section: "Wagons", label: "Total wagons", value: "p.wagons" },
];
/** Every `p.<alias>` a line reads, or the aliases the aggregate offers. */
export const lineRefs = (): string[] => [
...new Set(
LINES.flatMap((l) => [...l.value.matchAll(/\bp\.(\w+)/g)].map((m) => m[1])),
),
];
export const countAliases = (): string[] => Object.keys(COUNTS);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
if (ctx.params.station) {
// Either end of the corridor — a warehouse reports the trains it worked,
// whichever direction they ran.
qb.andWhere("(oy.code = :station OR dy.code = :station)", {
station: ctx.params.station,
});
}
return qb;
}
/**
* The raw counts, one row per direction — or, with `grouped` false, one row for
* everything under the pseudo-direction `ALL`.
*
* The second form is not a convenience: trains and wagons are counted
* distinctly, and a train carrying both an import and an export booking belongs
* to both directions. Adding the direction columns up would count it twice, so
* the Total column reads this row instead of summing the others.
*/
function aggregate(
ctx: ReportContext,
grouped: boolean,
): SelectQueryBuilder<ObjectLiteral> {
const qb = baseQuery(ctx).select(grouped ? DIRECTION : "'ALL'", "dir");
for (const [alias, expr] of Object.entries(COUNTS)) qb.addSelect(expr, alias);
if (grouped) qb.groupBy(DIRECTION);
return qb;
}
/**
* The bulk sheet: wagons per cargo type. Grouped on the cargo type itself, not
* the coarse cargo category the other operations reports use — the workbook
* lists wheat, sugar and lentils separately and the category vocabulary folds
* all three into BULK.
*/
function bulkByCargoType(
ctx: ReportContext,
grouped: boolean,
): SelectQueryBuilder<ObjectLiteral> {
const qb = baseQuery(ctx)
.andWhere(`NOT ${IS_CONTAINER_LOAD}`)
.select(grouped ? DIRECTION : "'ALL'", "dir")
.addSelect("900", "sn")
.addSelect("'Bulk cargo'", "section")
.addSelect("COALESCE(ct.cargo_type_name, 'Unclassified')", "metric")
.addSelect(wagons(), "value")
.groupBy("ct.cargo_type_name");
if (grouped) qb.addGroupBy(DIRECTION);
return qb;
}
export const portWarehouseSummaryReport: ReportDefinition = {
key: "port-warehouse-summary",
title: "Port Warehouse Operations Summary",
description:
"The monthly count sheet a port warehouse publishes: trains, containers by size and " +
"laden state, wagons and TEU, each split into export and import beside an overall total, " +
"followed " +
"by bulk cargo wagons per cargo type. Pick a station to report one warehouse and a date " +
"range to report one month. Counted from the marshalling record — what was actually put " +
"on the train. Wagons are counted distinctly rather than derived from container counts, " +
"so a part-loaded wagon counts once. Total is counted over everything rather than summed " +
"across the direction columns, because a train carrying both an import and an export " +
"booking belongs to both and would otherwise count twice. Demurrage is billed on invoice lines and is not " +
"part of this report; use Revenue by Category filtered to Demurrage.",
group: "Operations",
filters: [
...OPERATIONS_FILTERS,
{
key: "station",
label: "Station / warehouse",
type: "select",
optionsQuery: yardOptions,
},
],
columns: [
{ key: "sn", label: "S/N", type: "number", sortable: true },
{ key: "section", label: "Section", type: "string", sortable: true },
{
key: "metric",
label: "Name of operation",
type: "string",
sortable: true,
},
{ key: "export", label: "Export", type: "number", sortable: true },
{ key: "import", label: "Import", type: "number", sortable: true },
{ key: "domestic", label: "Domestic", type: "number", sortable: true },
{ key: "total", label: "Total", type: "number", sortable: true },
],
defaultSort: { key: "sn", dir: "ASC" },
query(ctx) {
const aggs = [aggregate(ctx, true), aggregate(ctx, false)];
const bulks = [bulkByCargoType(ctx, true), bulkByCargoType(ctx, false)];
// The fixed lines, unpivoted. sn is the line's position in LINES, so the
// sheet keeps the workbook's order regardless of what the values are.
const values = LINES.map(
(l, i) =>
`(${i + 1}, '${l.section}', '${l.label.replace(/'/g, "''")}', (${l.value})::int)`,
).join(",\n ");
const long = [
...aggs.map(
(agg) => `
SELECT p.dir, v.sn, v.section, v.metric, v.value
FROM (${agg.getQuery()}) p
CROSS JOIN LATERAL (VALUES
${values}
) AS v(sn, section, metric, value)`,
),
...bulks.map(
(bulk) =>
`SELECT bq.dir, bq.sn, bq.section, bq.metric, bq.value FROM (${bulk.getQuery()}) bq`,
),
].join("\n UNION ALL\n");
const dirSum = (dir: string): string =>
`(COALESCE(SUM(l.value) FILTER (WHERE l.dir = '${dir}'), 0))::int`;
return (
ctx.ds
.createQueryBuilder()
.from(`(${long})`, "l")
.setParameters(
Object.assign(
{},
...[...aggs, ...bulks].map((qb) => qb.getParameters()),
),
)
// Renumbered after grouping so the bulk lines continue the sheet's
// numbering instead of all sharing the 900 that ordered them.
.select("(ROW_NUMBER() OVER (ORDER BY l.sn, l.metric))::int", "sn")
.addSelect("l.section", "section")
.addSelect("l.metric", "metric")
.addSelect(dirSum("EXPORT"), "export")
.addSelect(dirSum("IMPORT"), "import")
.addSelect(dirSum("DOMESTIC"), "domestic")
.addSelect(dirSum("ALL"), "total")
.groupBy("l.sn")
.addGroupBy("l.section")
.addGroupBy("l.metric")
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(trains(), "trains")
.addSelect(wagons(), "wagons")
.addSelect(
`${boxes(ALLOC_CONTAINERS_20, IS_CONTAINER_LOAD)} + ${boxes(ALLOC_CONTAINERS_40, IS_CONTAINER_LOAD)} * 2`,
"teu",
)
.getRawOne<{ trains: number; wagons: number; teu: number }>();
return [
{ label: "Trains", value: Number(row?.trains ?? 0) },
{ label: "Wagons", value: Number(row?.wagons ?? 0) },
{ label: "TEU", value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -378,7 +378,7 @@ export const receivablesPayablesReport: ReportDefinition = {
query(ctx) {
return baseQuery(ctx)
.select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD HH24:MI')", 'issuedAt')
.addSelect('r.doc_ref', 'invoiceNumber')
.addSelect('r.booking_ref', 'bookingRef')
.addSelect('r.booking_status', 'bookingStatus')

View File

@@ -63,7 +63,7 @@ export const revenueReconciliationReport: ReportDefinition = {
defaultSort: { key: 'variance', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect(PAYER_EXPR, 'customer')

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 {
PAYMENT_CLASS_EXPR,
PAYER_EXPR,
@@ -13,7 +13,7 @@ import {
currencyOf,
periodExpr,
revenueLedgerQb,
} from '../revenue-classification';
} from "../revenue-classification";
/**
* The gateway payment behind an invoice, for traceability. `invoices.payment_id`
@@ -51,81 +51,109 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
}
export const revenueTransactionsReport: ReportDefinition = {
key: 'revenue-transactions',
title: 'Revenue Transactions',
key: "revenue-transactions",
title: "Revenue Transactions",
description:
'Every billed revenue line, at transaction level — booking reference, invoice number, ' +
'charge type, cargo, quantity and the payment reference behind it. This is the ' +
'drill-down target for the revenue summaries and the audit trail for an export.',
group: 'Finance',
"Every billed revenue line, at transaction level — booking reference, invoice number, " +
"charge type, cargo, quantity and the payment reference behind it. This is the " +
"drill-down target for the revenue summaries and the audit trail for an export.",
group: "Finance",
filters: [
PERIOD_FILTER,
...REVENUE_FILTERS,
{ key: 'period_value', label: 'Period bucket', type: 'text' },
{ key: "period_value", label: "Period bucket", type: "text" },
{
key: 'categoryKey',
label: 'Category (exact)',
type: 'select',
key: "categoryKey",
label: "Category (exact)",
type: "select",
options: REVENUE_CATEGORIES,
},
],
columns: [
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'bookingId', label: 'Booking ID', type: 'string' },
{ key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR },
{ key: 'paymentClass', label: 'Payment class', type: 'string' },
{ key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' },
{ key: 'cargo', label: 'Cargo', type: 'string' },
{ key: 'route', label: 'Route', type: 'string' },
{ key: 'quantity', label: 'Qty', type: 'number' },
{ key: 'unit', label: 'Unit', type: 'string' },
{ key: 'unitRate', label: 'Unit rate', type: 'money' },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' },
{ key: 'paymentRef', label: 'Payment ref', type: 'string' },
{ key: 'paymentMethod', label: 'Method', type: 'string' },
{ key: 'paymentStatus', label: 'Payment status', type: 'string' },
{ key: "issuedAt", label: "Issued", type: "date", sortable: true, sortExpr: REVENUE_DATE },
{
key: "invoiceNumber",
label: "Invoice No.",
type: "string",
sortable: true,
sortExpr: "i.invoice_number",
},
{
key: "bookingRef",
label: "Booking",
type: "string",
sortable: true,
sortExpr: "b.reference",
},
{ key: "payer", label: "Customer", type: "string", sortable: true, sortExpr: PAYER_EXPR },
{
key: "category",
label: "Revenue category",
type: "string",
sortable: true,
sortExpr: REVENUE_CATEGORY_EXPR,
},
{ key: "paymentClass", label: "Payment class", type: "string" },
{
key: "chargeType",
label: "Charge type",
type: "string",
sortable: true,
sortExpr: "il.charge_type",
},
{ key: "cargo", label: "Cargo", type: "string" },
{ key: "route", label: "Route", type: "string" },
{ key: "quantity", label: "Qty", type: "number" },
{ key: "unit", label: "Unit", type: "string" },
{ key: "unitRate", label: "Unit rate", type: "money" },
{ key: "amount", label: "Amount", type: "money", sortable: true, sortExpr: "il.amount" },
{ key: "currency", label: "Currency", type: "string" },
{
key: "invoiceStatus",
label: "Invoice status",
type: "string",
sortable: true,
sortExpr: "i.status",
},
{ key: "paymentRef", label: "Payment ref", type: "string" },
{ key: "paymentMethod", label: "Method", type: "string" },
{ key: "paymentStatus", label: "Payment status", type: "string" },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
defaultSort: { key: "issuedAt", dir: "DESC" },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect("COALESCE(b.id::text, '')", 'bookingId')
.addSelect(PAYER_EXPR, 'payer')
.addSelect(REVENUE_CATEGORY_EXPR, 'category')
.addSelect(PAYMENT_CLASS_EXPR, 'paymentClass')
.addSelect('il.charge_type', 'chargeType')
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '')", 'cargo')
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route')
.addSelect('il.quantity::float8', 'quantity')
.addSelect("COALESCE(il.metadata->>'unit', '')", 'unit')
.addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate')
.addSelect('ROUND(il.amount, 2)::float8', 'amount')
.addSelect('il.currency', 'currency')
.addSelect('i.status', 'invoiceStatus')
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, "issuedAt")
.addSelect("i.invoice_number", "invoiceNumber")
.addSelect("COALESCE(b.reference, '—')", "bookingRef")
.addSelect(PAYER_EXPR, "payer")
.addSelect(REVENUE_CATEGORY_EXPR, "category")
.addSelect(PAYMENT_CLASS_EXPR, "paymentClass")
.addSelect("il.charge_type", "chargeType")
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", "cargo")
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", "route")
.addSelect("il.quantity::float8", "quantity")
.addSelect("COALESCE(il.metadata->>'unit', '')", "unit")
.addSelect("ROUND(il.unit_rate, 2)::float8", "unitRate")
.addSelect("ROUND(il.amount, 2)::float8", "amount")
.addSelect("il.currency", "currency")
.addSelect("i.status", "invoiceStatus")
.addSelect(
`COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`,
'paymentRef',
`COALESCE(${latestPayment("transaction_id")}, ${latestPayment("merchant_order_id")}, '')`,
"paymentRef",
)
.addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod')
.addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus');
.addSelect(`COALESCE(${latestPayment("method")}, '')`, "paymentMethod")
.addSelect(`COALESCE(${latestPayment("status")}, '')`, "paymentStatus");
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(REVENUE_SUM, 'revenue')
.addSelect('COUNT(*)::int', 'lines')
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
.select(REVENUE_SUM, "revenue")
.addSelect("COUNT(*)::int", "lines")
.addSelect("COUNT(DISTINCT i.id)::int", "invoices")
.getRawOne<{ revenue: number; lines: number; invoices: number }>();
return [
{ label: 'Lines', value: Number(row?.lines ?? 0) },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
{ label: "Lines", value: Number(row?.lines ?? 0) },
{ label: "Invoices", value: Number(row?.invoices ?? 0) },
{ label: "Revenue", value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
];
},
};

View File

@@ -2,6 +2,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the
@@ -11,7 +12,7 @@ import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
@@ -28,6 +29,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
@@ -76,7 +79,7 @@ export const trainScheduleStatusReport: ReportDefinition = {
.addSelect('ts.direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'scheduledDeparture')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival');
},

View File

@@ -2,6 +2,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// "Turnaround" here is departure-to-arrival transit time on the actual (not
@@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types';
// departure) would need pairing consecutive schedules by physical train,
// which isn't tracked directly — deferred, not modeled as a shortcut.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
@@ -22,6 +23,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}

View File

@@ -57,8 +57,8 @@ export const wagonRequestsReport: ReportDefinition = {
.addSelect('r.quantity', 'quantity')
.addSelect('r.fulfilled_quantity', 'fulfilledQuantity')
.addSelect('r.status', 'status')
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt')
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt')
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD HH24:MI')`, 'requestedAt')
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD HH24:MI')`, 'fulfilledAt')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`,
'delayDays',

View File

@@ -69,7 +69,7 @@ export const wagonStatusDurationReport: ReportDefinition = {
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since')
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD HH24:MI')`, 'since')
.addSelect(
`FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`,
'daysInStatus',

View File

@@ -5,16 +5,21 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Container } from '../../container-management/entities/container.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to
// each wagon's CURRENT schedule pin — a live-state view, not a historical one.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id')
.innerJoin(
TrainSchedule,
'ts',
'ts.id = w.current_train_schedule_id AND ts.deleted_at IS NULL',
)
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL')
.leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id')
@@ -27,6 +32,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
@@ -53,7 +60,7 @@ export const wagonTeuUtilizationReport: ReportDefinition = {
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.groupBy('w.wagon_number')

View File

@@ -7,6 +7,7 @@ import {
TARGET_DIMENSION_KEYS,
cycleRateExpr,
implementRateExpr,
plannedRowsSql,
} from './operations-classification';
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
@@ -91,4 +92,74 @@ describe('operations classification', () => {
expect(rate(65, 78)).toBeLessThan(100);
expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)');
});
/**
* The plan side is FULL OUTER JOINed to the operated side, so a plan row for
* a category the user filtered out comes back as a row of zeros — the bug
* where `?categories=FERTILIZER` still returned all ten planned categories.
*/
describe('plannedRowsSql cargo filter', () => {
const sqlFor = (dimension: string, params: Record<string, unknown>): string =>
plannedRowsSql('VOLUME_TONS', dimension, params, 'SELECT 1');
it('restricts targets to the selected categories', () => {
expect(sqlFor('cargo_category', { categories: ['FERTILIZER'] })).toContain(
"AND ot.dimension_key IN ('FERTILIZER')",
);
});
it('restricts a station target on its cargo type, not its key', () => {
const sql = sqlFor('station', { categories: ['SAND'] });
expect(sql).toContain("AND ot.cargo_category IN ('SAND')");
expect(sql).not.toContain('ot.dimension_key IN');
});
it('filters a container class report on its own vocabulary', () => {
expect(sqlFor('container_class', { classes: ['CONTAINER_EXPORT'] })).toContain(
"AND ot.dimension_key IN ('CONTAINER_EXPORT')",
);
});
it('leaves every target when nothing is selected', () => {
expect(sqlFor('cargo_category', {})).not.toContain('ot.dimension_key IN');
});
it('matches nothing on a value no category expression can emit', () => {
expect(sqlFor('cargo_category', { categories: ["x'; DROP TABLE"] })).toContain('AND FALSE');
});
it('narrows a station plan to the chosen country rather than suppressing it', () => {
const sql = sqlFor('station', { country: 'Djibouti' });
expect(sql).toContain("y.country = 'Djibouti'");
expect(sql).not.toContain('AND FALSE');
});
it('ignores a country that is not one of the two sides', () => {
expect(sqlFor('station', { country: "' OR true --" })).not.toContain('y.country');
});
it('leaves the country alone on a plan not keyed by station', () => {
expect(sqlFor('cargo_category', { country: 'Djibouti' })).not.toContain('y.country');
});
/**
* No target carries a route, a train or a direction, so beside a
* route-filtered actual the plan would be the whole corridor's target.
*/
it.each(['origin', 'destination', 'trainNumber', 'direction'])(
'reports no plan at all when %s narrows below the target grain',
(key) => {
expect(sqlFor('cargo_category', { [key]: 'X' })).toContain('AND FALSE');
},
);
it('keeps the plan when only period, date and category are set', () => {
const sql = sqlFor('cargo_category', {
period: 'month',
dateFrom: '2026-01-01',
categories: ['SAND'],
});
expect(sql).not.toContain('AND FALSE');
});
});
});

View File

@@ -513,7 +513,9 @@ export const PLAN_GRANULARITY_NOTE =
'Plan is the committed figure and never moves. Required is the same target treated as a ' +
'quota: whatever is still outstanding, spread across the time still left, so a period ' +
'that fell behind raises what the periods after it must carry. A target already met in ' +
'full requires nothing further.';
'full requires nothing further. No target carries a route, a train or a direction, so ' +
'filtering by one leaves the plan columns empty rather than comparing a corridors whole ' +
'target against one slice of its work.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
@@ -575,6 +577,60 @@ END`;
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter.
*/
/**
* The plan side of a plan-versus-actual report has to obey the same cargo
* filter the operated side does. Without it the FULL OUTER JOIN re-introduces
* every planned key the user filtered out, as a row of zeros.
*
* Values are whitelisted against the vocabulary and inlined rather than bound:
* this fragment is assembled into raw CTE text, and the filter params are not
* validated upstream. An unknown value matches nothing — same as it does on the
* operated side, where the CASE can never emit it.
*/
const planKeyFilter = (dimension: string, params: Record<string, unknown>): string => {
const isClass = dimension === 'container_class';
const selected = (isClass ? params.classes : params.categories) as string[] | null;
if (!selected?.length) return '';
const vocab = isClass ? CONTAINER_CLASSES : CARGO_CATEGORIES;
const valid = selected.filter((v) => vocab.some((o) => o.value === v));
// A station target is keyed on the yard and carries its cargo type alongside.
const column = dimension === 'station' ? 'ot.cargo_category' : 'ot.dimension_key';
return valid.length ? `AND ${column} IN (${quote(valid)})` : 'AND FALSE';
};
/**
* Filters that narrow the operated population below the grain any target is
* kept at. No target carries a route, a train or a direction, so a plan read
* beside a route-filtered actual is the whole corridor's plan sitting next to
* one slice of its work — the implement rate then reads as a miss that never
* happened.
*
* There is no honest number to show, so the plan side reports nothing at all
* and Implement Rate goes NULL, the same way it does for a period with no
* target. `country` is absent on purpose: on a station plan it is a property of
* the planned key itself, and {@link planCountryFilter} narrows rather than
* suppresses.
*/
const PLAN_GRAIN_BREAKERS = ['origin', 'destination', 'trainNumber', 'direction'];
const planGrainFilter = (params: Record<string, unknown>): string =>
PLAN_GRAIN_BREAKERS.some((key) => params[key]) ? 'AND FALSE' : '';
/**
* A station target is keyed on a yard code, so the country filter — which
* decides which end of the corridor the report calls "the station" — is a real
* predicate on the plan, not a grain break. Without it the Djibouti view lists
* every Ethiopian station's target as a row that moved nothing.
*/
const planCountryFilter = (dimension: string, params: Record<string, unknown>): string => {
if (dimension !== 'station') return '';
const country = COUNTRY_FILTER.options?.find((o) => o.value === params.country)?.value;
if (!country) return '';
return `AND EXISTS (SELECT 1 FROM freight.yards y
WHERE y.code = ot.dimension_key AND y.deleted_at IS NULL
AND y.country = '${country}')`;
};
export const plannedRowsSql = (
metric: string,
dimension: string,
@@ -597,6 +653,9 @@ export const plannedRowsSql = (
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND ot.planned_value > 0
${planKeyFilter(dimension, params)}
${planCountryFilter(dimension, params)}
${planGrainFilter(params)}
),
-- One row per target per bucket. Generated a day at a time rather than a
-- bucket at a time: the ragged units restart their blocks each January, so

View File

@@ -33,6 +33,7 @@ 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 { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report";
import { ReportDefinition } from "./report.types";
/**
@@ -75,6 +76,7 @@ export const REPORTS: ReportDefinition[] = [
cargoVolumePerformanceReport,
chargedVsActualVolumeReport,
cargoVolumeByStationReport,
portWarehouseSummaryReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(

View File

@@ -97,6 +97,7 @@ const SEEDED_REPORT_KEYS = [
"cargo-volume-performance",
"charged-vs-actual-volume",
"cargo-volume-by-station",
"port-warehouse-summary",
] as const;
/**

View File

@@ -58,9 +58,15 @@ export function PageHeader({
{meta}
</Group>
{subtitle ? (
typeof subtitle === "string" ? (
<Text c="dimmed" size="sm" mt={4} className="truncate">
{subtitle}
</Text>
) : (
// A component subtitle handles its own layout — truncating it
// to one line would defeat e.g. an expandable description.
<div style={{ marginTop: 4 }}>{subtitle}</div>
)
) : null}
</div>
</Group>

View File

@@ -0,0 +1,27 @@
import { Spoiler, Text } from "@mantine/core";
interface ReportDescriptionProps {
text: string;
}
/**
* Report descriptions run to a paragraph. Clamps to roughly two lines and adds
* a Show more toggle — Spoiler measures the content, so the toggle only appears
* when the text actually overflows.
*/
export function ReportDescription({ text }: ReportDescriptionProps) {
return (
<Spoiler
maxHeight={44}
showLabel="Show more"
hideLabel="Show less"
styles={{ control: { fontSize: "var(--mantine-font-size-xs)" } }}
>
<Text c="dimmed" size="sm">
{text}
</Text>
</Spoiler>
);
}
export default ReportDescription;

View File

@@ -1,8 +1,9 @@
import { Stack, Text, Title } from "@mantine/core";
import { Stack, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { ReportDescription } from "./ReportDescription";
import { ReportView } from "./ReportView";
interface ReportSectionProps {
@@ -29,9 +30,7 @@ export function ReportSection({ reportKey, idKeyValue, defaultView }: ReportSect
<Stack gap="xs">
<div>
<Title order={4}>{def.title}</Title>
<Text size="sm" c="dimmed">
{def.description}
</Text>
<ReportDescription text={def.description} />
</div>
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} defaultView={defaultView} />
</Stack>

View File

@@ -13,6 +13,7 @@ import type { ReportFilterDef, ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportChart } from "./ReportChart";
import { ReportDescription } from "./ReportDescription";
import { ReportExportButton } from "./ReportExportButton";
import { formatKpiValue, formatReportCell } from "./report-format";
@@ -229,7 +230,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
{pageHeader ? (
<PageHeader
title={def.title}
subtitle={def.description}
subtitle={<ReportDescription text={def.description} />}
action={
<Group gap="xs">
{exportButton}

View File

@@ -17,10 +17,17 @@ export function formatReportCell(value: unknown, type: ReportColumnType): string
case "number":
return Number(value).toLocaleString();
case "date": {
const d = new Date(String(value));
return Number.isNaN(d.getTime())
? String(value)
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
const raw = String(value);
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
// Day-bucket columns carry no time part — don't invent a 12:00 AM for them.
const hasTime = /\d:\d/.test(raw);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
...(hasTime ? { hour: "2-digit", minute: "2-digit" } : {}),
});
}
default:
return String(value);

View File

@@ -15,7 +15,14 @@ import {
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
import {
ArrowLeft,
Building2,
CreditCard,
Download,
FileText,
Printer,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
@@ -34,7 +41,11 @@ import { LinkedEntityCard, type FieldRowProps } from "@/components/detail";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import type { Invoice } from "@/types/invoice";
import {
invoicePaymentMethod,
paymentMethodLabel,
type Invoice,
} from "@/types/invoice";
function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
@@ -124,6 +135,54 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
);
}
/**
* How the invoice was settled. The method and the provider's transaction
* reference live on the linked gateway payment for an online settlement, and in
* the invoice's own `payments` ledger for a manual one — this reads whichever
* exists, same precedence the API filters by. Not rendered at all until
* something has been settled.
*/
function PaymentCard({ invoice }: { invoice: Invoice }) {
const method = invoicePaymentMethod(invoice);
const lastEntry = invoice.payments?.at(-1);
// The PNR is the CBE_BILL reference the customer pays against. It is stamped
// onto the BOOKING at payment-initiation time, not onto the invoice or the
// payment, so it has to be read back from there — same lookup the sealed PDF
// does. Shares react-query's cache with `SourceCard`, so this costs no
// second request.
const { data: booking } = useBookingDetail(
invoice.source === "booking" ? invoice.sourceId : undefined,
);
if (!method && !lastEntry) return null;
// The gateway's own reference first; `merchantOrderId` is our order id, which
// is still the number a provider support desk can trace. The ledger reference
// is what a manual settlement carries (bank slip / transfer reference).
const transactionRef =
invoice.payment?.transactionId ??
invoice.payment?.merchantOrderId ??
lastEntry?.reference ??
undefined;
return (
<LinkedEntityCard
icon={CreditCard}
title="Payment"
name={method ? paymentMethodLabel(method) : "Settled"}
rows={[
{ label: "Transaction ref", value: transactionRef },
{ label: "PNR", value: booking?.pnrCode ?? undefined },
{ label: "Provider status", value: invoice.payment?.status },
{
label: "Paid",
value: invoice.paidAt ? formatDate(invoice.paidAt) : undefined,
},
]}
emptyMessage="No provider reference recorded for this settlement."
/>
);
}
/** What the invoice was raised for — a booking's route/wagons when the
* source is a booking; otherwise just the source type and its raw id
* (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */
@@ -393,6 +452,7 @@ export default function InvoiceDetailPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<RecipientCard invoice={invoice} />
<PaymentCard invoice={invoice} />
<SourceCard invoice={invoice} />
</Stack>
</Grid.Col>

View File

@@ -18,7 +18,13 @@ import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActio
import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import type { Invoice, InvoiceListFilter } from "@/types/invoice";
import {
PAYMENT_METHOD_OPTIONS,
invoicePaymentMethod,
paymentMethodLabel,
type Invoice,
type InvoiceListFilter,
} from "@/types/invoice";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
@@ -74,6 +80,12 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [
toParams: (v) =>
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
},
{
key: "paymentMethods",
label: "Payment method",
type: "enum",
options: PAYMENT_METHOD_OPTIONS,
},
{
key: "issued",
label: "Issued",
@@ -274,6 +286,22 @@ export default function InvoicesPanel() {
</Text>
),
},
{
id: "paymentMethod",
header: "Method",
cell: ({ row }) => {
const method = invoicePaymentMethod(row.original);
return method ? (
<Badge size="sm" variant="light" color="gray">
{paymentMethodLabel(method)}
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
id: "dueAt",
header: "Due",
@@ -342,7 +370,7 @@ export default function InvoicesPanel() {
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId="invoices"
>
@@ -361,7 +389,7 @@ export default function InvoicesPanel() {
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<Box miw={1040}>
<DataTable
columns={columns}
data={rows}

View File

@@ -16,10 +16,40 @@ export interface InvoiceSourceRef {
shippingLineName: string | null;
}
/** One recorded settlement from the invoice's own `payments` jsonb ledger. */
export interface InvoicePaymentEntry {
amount: number;
/** "GATEWAY" for an online settlement, else the manual method ("BANK_TRANSFER", …). */
method?: string | null;
reference?: string | null;
paidAt: string;
}
/**
* The gateway payment intent that settled the invoice, joined on `paymentId`.
* Absent on manually-settled invoices — those carry their method and reference
* in the `payments` ledger instead.
*/
export interface InvoiceGatewayPayment {
id: string;
/** Provider slug as the gateway stores it, lowercase-hyphen: "cbe-bill", "telebirr". */
method: string;
status: string;
/** The provider's own transaction reference. Null until the provider returns one. */
transactionId?: string | null;
merchantOrderId?: string | null;
paidAt?: string | null;
}
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
export interface Invoice extends Freight.IInvoice {
subtotalAmount: number;
taxAmount: number;
/** Set once fully settled. */
paidAt?: string | null;
/** Settlement ledger, newest last. */
payments?: InvoicePaymentEntry[];
payment?: InvoiceGatewayPayment | null;
/** Present on list reads (`findAllPaginated`), absent on a single-invoice fetch. */
sourceRef?: InvoiceSourceRef | null;
}
@@ -42,6 +72,8 @@ export interface InvoiceListFilter {
sources?: string;
/** CSV of EIMS filing states. */
eimsStatuses?: string;
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
paymentMethods?: string;
search?: string;
currency?: "USD" | "ETB";
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
@@ -90,3 +122,47 @@ export interface PaginatedOfflineUsdInvoices {
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
export type InvoiceCollectedSummary = Record<string, number>;
/**
* The payment methods the invoice list filters by. Normalised UPPER_SNAKE, the
* one vocabulary the API's `invoicePaymentMethodExpr` folds both sources into:
* the gateway's own provider slugs (`cbe-bill` → CBE_BILL) and the flat labels
* a manual settlement writes to the ledger.
*
* Not exhaustive — the manual pay endpoint takes a free-form method — so treat
* an unknown value as displayable, never as invalid.
*/
export const PAYMENT_METHOD_OPTIONS: { value: string; label: string }[] = [
{ value: "TELEBIRR", label: "Telebirr" },
{ value: "CBE_BIRR", label: "CBE Birr" },
{ value: "CBE_BILL", label: "CBE Bill" },
{ value: "EBIRR", label: "E-Birr" },
{ value: "WAAFI", label: "Waafi" },
{ value: "CARD", label: "Card" },
{ value: "DMONEY", label: "D-Money" },
{ value: "CAC_BANK", label: "CAC Bank" },
{ value: "BANK_TRANSFER", label: "Bank transfer" },
{ value: "OFFLINE", label: "Offline" },
{ value: "GATEWAY", label: "Gateway" },
];
const PAYMENT_METHOD_LABELS = new Map(
PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]),
);
/** Normalise a raw method from either source to the vocabulary above. */
export const normalizePaymentMethod = (raw?: string | null): string | null =>
raw ? raw.toUpperCase().replace(/-/g, "_") : null;
/**
* An invoice's settled method, resolved the same way the API does: the gateway
* provider first, the newest ledger entry as the fallback. Null while unpaid.
*/
export function invoicePaymentMethod(invoice: Invoice): string | null {
const ledger = invoice.payments?.at(-1)?.method;
return normalizePaymentMethod(invoice.payment?.method ?? ledger);
}
/** Human label for a method value; unknown values are shown as-is. */
export const paymentMethodLabel = (method: string): string =>
PAYMENT_METHOD_LABELS.get(method) ?? method;