diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts index aec11ae24..1bcbe908b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -1,8 +1,26 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -/** Planning buckets the reports offer. Mirrors the reports' period filter. */ -export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const; +/** + * Planning buckets the reports offer. Mirrors the reports' period filter + * (`PERIOD_UNITS` in `reports/revenue-classification.ts`) — a planner must be + * able to commit a number at whatever grain the business quotes it, and the + * report then re-gathers it into whatever grain the viewer asks for. + * + * All eight anchor to the calendar year. `nine_month` and `ninety_day` are the + * two that do not divide it evenly: their last block of a year is short (Oct–Dec + * and the 5–6 days after day 360). That is inherent to the unit, not a bug. + */ +export const TARGET_PERIOD_TYPES = [ + 'day', + 'week', + 'month', + 'quarter', + 'half_year', + 'nine_month', + 'ninety_day', + 'year', +] as const; export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number]; /** What is being planned. */ @@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record = { }; export const TARGET_PERIOD_LABELS: Record = { + day: 'Daily', week: 'Weekly', month: 'Monthly', quarter: 'Quarterly', + half_year: 'Half-yearly', + nine_month: 'Nine-monthly', + ninety_day: '90-day', year: 'Yearly', }; diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts new file mode 100644 index 000000000..69ad38db6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts @@ -0,0 +1,144 @@ +import { + TARGET_PERIOD_LABELS, + TARGET_PERIOD_TYPES, + TargetPeriodType, +} from './entities/operations-target.entity'; +import { normalisePeriodStart } from './operations-targets.service'; + +/** + * `normalisePeriodStart` decides which slot a target occupies — the unique + * index is keyed on its output — and it is one half of a pair. The other half + * is `PERIOD_UNITS[...].truncOn` in `reports/revenue-classification.ts`, which + * buckets the actuals. A target that snaps to a boundary the report does not + * bucket on is a plan measured against a period that does not exist, and + * nothing downstream would say so. + * + * Everything here is UTC on purpose: the column is a bare `date`, and the same + * arithmetic in local time shifts a 1st-of-month target into the previous month + * for anyone east of Greenwich. + */ +describe('normalisePeriodStart', () => { + it('leaves a daily target on its own day', () => { + expect(normalisePeriodStart('day', '2026-08-21')).toBe('2026-08-21'); + }); + + it('snaps a week to its Monday', () => { + // 2026-08-21 is a Friday. + expect(normalisePeriodStart('week', '2026-08-21')).toBe('2026-08-17'); + // A Sunday belongs to the week that started six days earlier, not the next. + expect(normalisePeriodStart('week', '2026-08-23')).toBe('2026-08-17'); + expect(normalisePeriodStart('week', '2026-08-17')).toBe('2026-08-17'); + }); + + it('snaps a month to the 1st', () => { + expect(normalisePeriodStart('month', '2026-08-21')).toBe('2026-08-01'); + expect(normalisePeriodStart('month', '2026-08-01')).toBe('2026-08-01'); + }); + + it('snaps a quarter to Jan/Apr/Jul/Oct', () => { + expect(normalisePeriodStart('quarter', '2026-02-14')).toBe('2026-01-01'); + expect(normalisePeriodStart('quarter', '2026-05-01')).toBe('2026-04-01'); + expect(normalisePeriodStart('quarter', '2026-08-21')).toBe('2026-07-01'); + expect(normalisePeriodStart('quarter', '2026-12-31')).toBe('2026-10-01'); + }); + + it('snaps a half-year to Jan/Jul', () => { + expect(normalisePeriodStart('half_year', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('half_year', '2026-06-30')).toBe('2026-01-01'); + expect(normalisePeriodStart('half_year', '2026-07-01')).toBe('2026-07-01'); + expect(normalisePeriodStart('half_year', '2026-12-31')).toBe('2026-07-01'); + }); + + it('snaps a nine-month to Jan/Oct, leaving a short final block', () => { + expect(normalisePeriodStart('nine_month', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('nine_month', '2026-09-30')).toBe('2026-01-01'); + // Oct–Dec is three months, not nine. The block is short by design: nine + // does not divide twelve, and drifting out of the calendar year is worse. + expect(normalisePeriodStart('nine_month', '2026-10-01')).toBe('2026-10-01'); + expect(normalisePeriodStart('nine_month', '2026-12-31')).toBe('2026-10-01'); + }); + + it('snaps a 90-day block to day 1/91/181/271 of its year', () => { + expect(normalisePeriodStart('ninety_day', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('ninety_day', '2026-03-31')).toBe('2026-01-01'); // day 90 + expect(normalisePeriodStart('ninety_day', '2026-04-01')).toBe('2026-04-01'); // day 91 + expect(normalisePeriodStart('ninety_day', '2026-06-29')).toBe('2026-04-01'); // day 180 + expect(normalisePeriodStart('ninety_day', '2026-06-30')).toBe('2026-06-30'); // day 181 + expect(normalisePeriodStart('ninety_day', '2026-07-01')).toBe('2026-06-30'); + expect(normalisePeriodStart('ninety_day', '2026-09-27')).toBe('2026-06-30'); // day 270 + expect(normalisePeriodStart('ninety_day', '2026-09-28')).toBe('2026-09-28'); // day 271 + }); + + it('widens the fourth 90-day block instead of opening a stub fifth', () => { + // Day 361 onwards would be its own block under an uncapped floor division — + // a five-day bucket at the end of every year. The cap keeps it in block 4, + // which must therefore match what late September resolves to. + const blockFour = normalisePeriodStart('ninety_day', '2026-09-28'); + expect(normalisePeriodStart('ninety_day', '2026-12-27')).toBe(blockFour); + expect(normalisePeriodStart('ninety_day', '2026-12-31')).toBe(blockFour); + }); + + it('handles a leap year, where day 366 still lands in the fourth block', () => { + // 2028 is a leap year: Dec 31 is day 366. + expect(normalisePeriodStart('ninety_day', '2028-12-31')).toBe( + normalisePeriodStart('ninety_day', '2028-09-27'), + ); + }); + + it('snaps a year to Jan 1', () => { + expect(normalisePeriodStart('year', '2026-08-21')).toBe('2026-01-01'); + expect(normalisePeriodStart('year', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('year', '2026-12-31')).toBe('2026-01-01'); + }); + + it('ignores any time component rather than letting it shift the day', () => { + expect(normalisePeriodStart('day', '2026-08-21T23:59:59.999Z')).toBe('2026-08-21'); + expect(normalisePeriodStart('month', '2026-08-01T22:00:00+03:00')).toBe('2026-08-01'); + }); + + it('is idempotent for every period type', () => { + // A normalised start must survive a second pass untouched, because `update` + // re-normalises whatever is already stored. + for (const periodType of TARGET_PERIOD_TYPES) { + for (const date of ['2026-01-01', '2026-05-17', '2026-08-21', '2026-12-31']) { + const once = normalisePeriodStart(periodType, date); + expect(normalisePeriodStart(periodType, once)).toBe(once); + } + } + }); + + it('never moves a date forward, only back to its block start', () => { + for (const periodType of TARGET_PERIOD_TYPES) { + for (const date of ['2026-02-28', '2026-06-15', '2026-10-02', '2026-12-31']) { + expect(normalisePeriodStart(periodType, date) <= date).toBe(true); + } + } + }); +}); + +describe('target period vocabulary', () => { + it('labels every period type, so the admin grid shows no raw key', () => { + for (const periodType of TARGET_PERIOD_TYPES) { + expect(TARGET_PERIOD_LABELS[periodType]).toBeTruthy(); + } + expect(Object.keys(TARGET_PERIOD_LABELS).sort()).toEqual([...TARGET_PERIOD_TYPES].sort()); + }); + + it('keeps every period type inside the column width', () => { + // `period_type` is varchar(10); `nine_month` and `ninety_day` are exactly 10. + for (const periodType of TARGET_PERIOD_TYPES) { + expect(periodType.length).toBeLessThanOrEqual(10); + } + }); + + it('has a normalisation branch for every declared period type', () => { + // A type added to the union without a `case` would silently fall through + // and store an un-snapped date. Every type must move Dec 31 to a block + // start except `day`, which legitimately keeps it. + const unhandled = TARGET_PERIOD_TYPES.filter( + (t: TargetPeriodType) => + t !== 'day' && normalisePeriodStart(t, '2026-12-31') === '2026-12-31', + ); + expect(unhandled).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts index b3753e343..fbcbc6d7b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -26,10 +26,19 @@ import { CONTAINER_CLASSES, } from '../reports/operations-classification'; +const MS_PER_DAY = 86_400_000; + /** - * Normalises any date inside a bucket to the bucket's first day, matching - * Postgres `date_trunc` — which is what the reports group by. Week starts - * Monday, the same as `date_trunc('week', …)` and ISO week numbering. + * Normalises any date inside a bucket to the bucket's first day, matching the + * bucket expression the reports group by (`PERIOD_UNITS` in + * `reports/revenue-classification.ts`). Week starts Monday, the same as + * `date_trunc('week', …)` and ISO week numbering. + * + * The four units Postgres has no `date_trunc` for are anchored to the calendar + * year, exactly as their SQL twins are: half-years at Jan/Jul, nine-months at + * Jan/Oct, ninety-days at day 1/91/181/271. **This function and + * `PERIOD_UNITS[...].truncOn` must agree** — a target whose `period_start` is + * not a real block start plans against a bucket boundary that does not exist. * * Done in UTC throughout: the stored column is a bare `date`, and running the * arithmetic in local time would shift a 1st-of-month target into the previous @@ -38,6 +47,8 @@ import { export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string { const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); switch (periodType) { + case 'day': + break; case 'week': { // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. const offset = (d.getUTCDay() + 6) % 7; @@ -50,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string case 'quarter': d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); break; + case 'half_year': + d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1); + break; + case 'nine_month': + // Two blocks a year, not 1.33: Jan–Sep, then a short Oct–Dec. + d.setUTCMonth(Math.floor(d.getUTCMonth() / 9) * 9, 1); + break; + case 'ninety_day': { + // Day-of-year, zero-based, so this matches SQL's 1-based `(doy - 1) / 90`. + // Capped at block 3 for the same reason the SQL caps it: uncapped, the + // last days of December become a 5-day stub block of their own. + const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1); + const dayIndex = Math.floor((d.getTime() - yearStart) / MS_PER_DAY); + d.setTime(yearStart + Math.min(Math.floor(dayIndex / 90), 3) * 90 * MS_PER_DAY); + break; + } case 'year': d.setUTCMonth(0, 1); break; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index e95724912..10fef2982 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -704,14 +704,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], }, { + // Mirrors TARGET_PERIOD_LABELS in the API's operations-target entity. + // Commit the number at whatever grain the business quotes it — the + // report re-gathers it into whichever grain the viewer asks for. name: "periodType", label: "Period", type: "select", required: true, options: [ + { label: "Daily", value: "day" }, { label: "Weekly", value: "week" }, { label: "Monthly", value: "month" }, { label: "Quarterly", value: "quarter" }, + { label: "Half-yearly", value: "half_year" }, + { label: "Nine-monthly", value: "nine_month" }, + { label: "90-day", value: "ninety_day" }, { label: "Yearly", value: "year" }, ], },