feat(reports): add port warehouse operations summary

Reproduces the monthly count sheet a port warehouse publishes: trains,
containers by size and laden state, wagons, TEU and bulk wagons per cargo
type, each split into export and import beside an overall total.

Two departures from the spreadsheet it replaces:

- Wagons are counted distinctly from the marshalling record rather than
  derived as 20ft/2 + 40ft + bulk wagons, which overstates whenever a wagon
  ran part-loaded.
- Total is counted over everything rather than summed across the direction
  columns — 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 left to Revenue by Category.

Adds a station filter matching either end of the corridor, so one warehouse
can report the trains it worked in both directions.
This commit is contained in:
Nathnael
2026-08-24 08:58:58 +00:00
parent ee2caa332c
commit 2286135228
4 changed files with 370 additions and 0 deletions

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ import { teuPerformanceReport } from "./definitions/teu-performance.report";
import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report";
import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report";
import { ReportDefinition } from "./report.types";
/**
@@ -75,6 +76,7 @@ export const REPORTS: ReportDefinition[] = [
cargoVolumePerformanceReport,
chargedVsActualVolumeReport,
cargoVolumeByStationReport,
portWarehouseSummaryReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(

View File

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