mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
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.
This commit is contained in:
@@ -86,17 +86,54 @@ describe('revenue classification', () => {
|
||||
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
|
||||
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
|
||||
// Anything unrecognised — including an injection attempt — becomes 'month'.
|
||||
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain(
|
||||
"date_trunc('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.length).toBe(5);
|
||||
for (const unit of offered) {
|
||||
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`);
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user