mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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]);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 train’s ' +
|
||||
'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 leg’s 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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 corridor’s 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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -97,6 +97,7 @@ const SEEDED_REPORT_KEYS = [
|
||||
"cargo-volume-performance",
|
||||
"charged-vs-actual-volume",
|
||||
"cargo-volume-by-station",
|
||||
"port-warehouse-summary",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user