mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
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.
140 lines
6.0 KiB
TypeScript
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)');
|
|
});
|
|
});
|