Files
edr-platform/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts
ghost2023 260d5a1590 feat(reports): cascade an unmet plan onto the periods that remain
A target is a quota, not a flat allowance. The Plan column spread it evenly
and kept asking for the same twelfth of a yearly figure no matter how far
behind the year had fallen, so the one number operations actually needs —
what must move per month for the rest of the year — was nowhere on the
page.

Plan now keeps its meaning and a Required column sits beside it. Plan is
the committed spread and never moves, which is the whole reason it stays:
Implement Rate is measured against it, so a month that missed still reads
as a month that missed. Required is the same target read as a quota — at
each bucket, whatever is still outstanding spread across the time still
left. A 1,200 t year 20% met by June asks 140 t of June and 960 t of
December, which is 1,200 less the 240 delivered. Over-delivery clamps to
zero rather than going negative.

Attainment is deliberately measured with the user's date bounds stripped
(attainmentCtx) and every other filter left in place. Reusing the report's
own filtered aggregate would make a July-only view read year-to-date as
nothing delivered and demand the entire year's tonnage from one month —
the failure would look like a plausible number, not an error.

Granularity gains half-year, nine-month and 90-day. Postgres has no
date_trunc for any of them, so PERIOD_UNITS entries became builders rather
than fragments to interpolate, and all eight blocks anchor to the calendar
year. Nine does not divide twelve and 90 does not divide 365: a nine-month
year is Jan-Sep plus a short Oct-Dec, and the fourth 90-day block absorbs
the remainder at 95 days. That last one is a choice — uncapped floor
division opens a five-day stub bucket every December, which is noise
rather than a period.

Two consequences of the shared unit table, both handled here:

- plannedRowsSql now generates a day at a time and groups, instead of
  stepping by the bucket width. The ragged blocks restart each January, so
  stepping 90 days from January 1st walks off the anchor in the second
  year. Day grain also gets partial-bucket overlap for free, at the same
  sub-day precision the old clipping had.
- nextPeriodOrdinalExpr asks the unit for its next block start rather than
  adding its own step. revenue-by-period evaluates a regression there, and
  a ragged unit's final block is shorter than its nominal width, so + step
  would land past the next block and forecast at the wrong x.

Verified against Postgres 16 with the entities synchronised into it: all
272 report/granularity combinations in the registry EXPLAIN clean, and the
1,200 t drill-down sums back to 1,200 at every one of the eight grains.
2026-08-21 17:40:35 +03:00

140 lines
6.0 KiB
TypeScript

import {
BULK_FREIGHT_CHARGES,
PAYMENT_CLASSES,
PAYMENT_CLASS_EXPR,
PERIOD_FILTER,
REVENUE_CATEGORIES,
REVENUE_CATEGORY_EXPR,
periodExpr,
} from './revenue-classification';
/**
* `invoice_lines.charge_type` is an unconstrained varchar written by eight
* unrelated code paths. Nothing at the type level stops someone adding a ninth
* spelling, whose revenue would then land silently in the ELSE arm.
*
* This list is every value the codebase writes today. When it grows, these
* tests are what fail — which is the whole trade the const-map design makes.
*/
const KNOWN_CHARGE_TYPES = [
// booking base freight (rate_type codes)
'CONTAINER_IMPORT', 'CONTAINER_EXPORT', 'CONTAINER_20FT', 'CONTAINER_40FT',
'BULK_IMPORT', 'BULK_EXPORT', 'INTERCITY_BULK', 'INTERCITY_CONTAINER', 'FREIGHT',
// surcharges
'FUEL_SURCHARGE', 'LASHING', 'OVERWEIGHT_PER_TON', 'HAZARD_SURCHARGE',
'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', 'RETURN_SURCHARGE', 'RETURN_SURCHARGE_20FT',
'RETURN_SURCHARGE_40FT', 'CONTAINER_WITH_RETURN', 'ADJUSTMENT', 'RATE_ADJUSTMENT',
// customs
'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE_20FT', 'CUSTOMS_CLEARANCE_40FT',
// mile legs
'FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE',
// warehouse fees
'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'DEMURRAGE', 'STORAGE_FEE',
'HANDLING_FEE', 'DOUBLE_HANDLING', 'TRUCK_DETENTION',
// other producers
'CANCELLATION_FEE', 'SHIPPING_LINE_SERVICE',
];
/**
* Does the expression name this charge type — either as a literal or through
* one of its `LIKE 'PREFIX%'` arms?
*
* Deliberately a substring check, not a SQL parser: a parser would be more
* fragile than the expression it is guarding. This catches the failure that
* actually happens (a new charge type nobody added to the map) and nothing
* pretends it verifies the branch order.
*/
function isNamed(expr: string, chargeType: string): boolean {
if (expr.includes(`'${chargeType}'`)) return true;
return [...expr.matchAll(/LIKE '([^']*)%'/g)].some(([, prefix]) =>
chargeType.startsWith(prefix),
);
}
describe('revenue classification', () => {
it('names every charge type the codebase writes in the payment-class map', () => {
const unmapped = KNOWN_CHARGE_TYPES.filter((c) => !isNamed(PAYMENT_CLASS_EXPR, c));
expect(unmapped).toEqual([]);
});
it('names every ancillary charge type in the revenue-category map', () => {
// Bulk freight lines carry no category of their own — the CASE falls
// through to the booking's cargo type and trade direction for those.
const cargoDerived = new Set(BULK_FREIGHT_CHARGES);
const unmapped = KNOWN_CHARGE_TYPES.filter(
(c) => !cargoDerived.has(c) && !isNamed(REVENUE_CATEGORY_EXPR, c),
);
expect(unmapped).toEqual([]);
});
it('emits only categories that are offered as filter options', () => {
const declared = new Set(REVENUE_CATEGORIES.map((c) => c.value));
const emitted = [...REVENUE_CATEGORY_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]);
expect(emitted.length).toBeGreaterThan(0);
expect(emitted.filter((c) => !declared.has(c))).toEqual([]);
expect(declared.has('UNCLASSIFIED')).toBe(true);
});
it('emits only payment classes that are offered as filter options', () => {
const declared = new Set(PAYMENT_CLASSES.map((c) => c.value));
const emitted = [...PAYMENT_CLASS_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]);
expect(emitted.filter((c) => !declared.has(c))).toEqual([]);
expect(declared.has('ADDITIONAL')).toBe(true);
});
it('falls back to a whitelisted period unit instead of interpolating input', () => {
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
// Anything unrecognised — including an injection attempt — becomes 'month'.
const injection = "day'); DROP TABLE freight.invoices; --";
expect(periodExpr({ period: injection })).toContain("date_trunc('month'");
expect(periodExpr({ period: injection })).not.toContain('DROP TABLE');
expect(periodExpr({})).toContain("date_trunc('month'");
});
it('offers exactly the period units the expression understands', () => {
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
expect(offered).toEqual([
'day',
'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
]);
// Every offered unit resolves to its own expression rather than silently
// falling through to the month default — which is what a missing entry or a
// typo'd key would look like.
const expressions = offered.map((unit) => periodExpr({ period: unit }));
expect(new Set(expressions).size).toBe(offered.length);
});
/**
* Half-year, nine-month and ninety-day have no `date_trunc` unit, so they are
* offset arithmetic anchored to January 1st. These pin the anchor: they are
* the SQL half of a pair whose other half is `normalisePeriodStart` in
* `operations-targets.service.ts`, and a target that snaps to a boundary the
* report does not bucket on plans against a period that does not exist.
*/
it('anchors the irregular units to the start of the calendar year', () => {
for (const unit of ['half_year', 'nine_month', 'ninety_day']) {
const expr = periodExpr({ period: unit });
expect(expr).toContain("date_trunc('year'");
expect(expr).not.toContain(`date_trunc('${unit}'`);
}
// Six- and nine-month blocks count whole months from January.
expect(periodExpr({ period: 'half_year' })).toContain("INTERVAL '6 months'");
expect(periodExpr({ period: 'nine_month' })).toContain("INTERVAL '9 months'");
// 90-day blocks count days, and cap at the fourth so the last days of
// December widen block four instead of forming a 5-day stub of their own.
const ninety = periodExpr({ period: 'ninety_day' });
expect(ninety).toContain("INTERVAL '90 days'");
expect(ninety).toContain('LEAST(');
expect(ninety).toContain('/ 90, 3)');
});
});