diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39c5a36ad..175e3e00c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { 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]); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index aec6e4ac0..a98ad06c1 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -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 = { @@ -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() diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index ab1e27b1a..172e74c17 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 67f2207e9..56ab3b74e 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index 6da342e17..5fe6ba81b 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -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 { const qb = allocationLedgerQb(ctx); @@ -38,55 +69,74 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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 { + 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) { diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 03f98d306..747a14891 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts index d3abe10dc..5832e75f3 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts @@ -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 { - 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 { 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) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts index d3fd759f7..c6a8edc7a 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -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 { - 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; } diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts index 8827b77b8..0c8f08c7f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts @@ -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 { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSetWagon, 'tsw') @@ -24,6 +25,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts index cf971cf2c..a9e74039d 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -12,7 +12,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { .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 }); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts new file mode 100644 index 000000000..2254cb53d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts @@ -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", + ]), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts new file mode 100644 index 000000000..9cd3a11c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts @@ -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.` is a runtime 42703 that + * neither tsc nor a type-check can see. + */ +const COUNTS: Record = { + 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.` 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 { + 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 { + 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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 0e07710bb..190e83c02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts index 73de3f505..c7bec9497 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts index ad96b5b9e..9b060659c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -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 { } 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) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts index 88a25d890..1104d5895 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts @@ -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 { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSchedule, 'ts') @@ -28,6 +29,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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'); }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts index 7dc7b8c3c..48304f054 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts @@ -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 { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSchedule, 'ts') @@ -22,6 +23,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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; } diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts index df5ac364c..67a916d45 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts index 348f681d1..771eec71d 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts index 7dabec37c..3d81c06ed 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts @@ -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 { - 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 { 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') diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts index 63a7db20b..a37bc3f0c 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -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 => + 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'); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index 1c8f8504c..cffb787ee 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -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 => { + 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 => + 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 => { + 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 diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index fc404738d..53273fa69 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -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( diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 8204ee2d3..b7e0ecaf2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -97,6 +97,7 @@ const SEEDED_REPORT_KEYS = [ "cargo-volume-performance", "charged-vs-actual-volume", "cargo-volume-by-station", + "port-warehouse-summary", ] as const; /** diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx index 86beb22fb..2194930ec 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -58,9 +58,15 @@ export function PageHeader({ {meta} {subtitle ? ( - - {subtitle} - + typeof subtitle === "string" ? ( + + {subtitle} + + ) : ( + // A component subtitle handles its own layout — truncating it + // to one line would defeat e.g. an expandable description. +
{subtitle}
+ ) ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx new file mode 100644 index 000000000..69707dac8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx @@ -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 ( + + + {text} + + + ); +} + +export default ReportDescription; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx index e686bc9d0..09da81439 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx @@ -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
{def.title} - - {def.description} - +
diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx index 2634e882c..2c78a9b6c 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx @@ -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 ? ( } action={ {exportButton} diff --git a/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts index d9b174ef1..15933ee78 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts +++ b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts @@ -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); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 23a974856..def389687 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -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 ( + + ); +} + /** 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() { + diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 8026f692b..8b28fd71a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -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() { ), }, + { + id: "paymentMethod", + header: "Method", + cell: ({ row }) => { + const method = invoicePaymentMethod(row.original); + return method ? ( + + {paymentMethodLabel(method)} + + ) : ( + + — + + ); + }, + }, { id: "dueAt", header: "Due", @@ -342,7 +370,7 @@ export default function InvoicesPanel() { @@ -361,7 +389,7 @@ export default function InvoicesPanel() { - + ; + +/** + * 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;