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/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; /**