From 1ffaece87496cd2c7d3fc846a0b82dbb010e811c Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 25 Jul 2026 10:17:11 +0000 Subject: [PATCH] Revert rota comparison to real oncosts; remove stale NI footnote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the sync uses /api/v2/rosters/on/{date} (2026-07-25) instead of /api/v2/schedules, real employer-oncost figures are available for rota data, so gatherRotaVsActualData() no longer needs the base-cost- only workaround — it respects show_oncosts like the rest of the app again. Also removes the now-inaccurate Monthly.tsx footnote claiming rota-based forecasts exclude NI, and the now-obsolete methodology note that told the model to expect a swing from the base-cost fix (moot — insight history is being cleared, so there's nothing to compare against). Co-Authored-By: Claude Sonnet 5 --- backend/src/jobs/ai-insights.js | 49 ++++++++++----------------------- frontend/src/pages/Monthly.tsx | 3 -- 2 files changed, 15 insertions(+), 37 deletions(-) diff --git a/backend/src/jobs/ai-insights.js b/backend/src/jobs/ai-insights.js index 8d82e82..f5f0cfb 100644 --- a/backend/src/jobs/ai-insights.js +++ b/backend/src/jobs/ai-insights.js @@ -9,11 +9,6 @@ export const DEFAULT_DAILY_TOKEN_BUDGET = 5000 const MAX_OUTPUT_TOKENS = 900 export const MANUAL_RATE_LIMIT_MINUTES = 5 -// One-time context so the model doesn't flag the 25/07 rota-methodology fix (oncost-inclusive -// -> base-pay-only comparison) as an unexplained swing when it sees a prior insight's figures -// change. Safe to delete this constant and its usage below once it's a few days stale. -const METHOD_CHANGE_NOTE_UNTIL = '2026-07-29' - function toISODate(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } @@ -152,15 +147,16 @@ export async function gatherPriorPeriodData() { // Department-level variance between scheduled (rota) and actual cost, over the trailing // window. Rota rows for past dates aren't deleted once synced, so this covers real history. // -// Always compares on BASE cost for both sides, regardless of the show_oncosts display -// setting: Workforce's schedules API never actually supplies employer NI oncosts (confirmed — -// published_total_cost is identical to published_base_cost on every synced row), so comparing -// oncost-inclusive actuals against rota would inflate every variance by ~14-20% for reasons -// that have nothing to do with real overspend. Matches the same caveat already surfaced on the -// Monthly page's rota-based forecast footnote. +// Respects show_oncosts like the rest of the app. Previously hardcoded to base cost because +// the old /api/v2/schedules-based sync never supplied real oncosts on rota data (confirmed: +// published_total_cost == published_base_cost on every row) — fixed 2026-07-25 by switching +// the sync to /api/v2/rosters/on/{date}, which does supply real cost_with_oncosts. export async function gatherRotaVsActualData(days = 28) { - const actualCostCol = 'base_cost' - const schedCostExpr = '(published_base_cost + unpublished_base_cost)' + const showOncosts = (await getConfig('show_oncosts')) !== 'false' + const actualCostCol = showOncosts ? 'total_cost' : 'base_cost' + const schedCostExpr = showOncosts + ? '(published_total_cost + unpublished_total_cost)' + : '(published_base_cost + unpublished_base_cost)' const today = new Date() const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000)) @@ -213,7 +209,7 @@ export async function gatherRotaVsActualData(days = 28) { })) .sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance)) - return { fromStr, toStr, totalDaysInWindow, depts } + return { fromStr, toStr, totalDaysInWindow, depts, costBasis: showOncosts ? 'incl. employer NI oncosts' : 'base pay only' } } // Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic @@ -374,19 +370,6 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, lines.push('') } - if (recentInsights.length && toISODate(new Date()) <= METHOD_CHANGE_NOTE_UNTIL) { - lines.push('## Methodology Note (25/07/2026)') - lines.push( - "The Rota vs Actual Variance comparison was corrected on 25/07/2026 to use base pay only on both " + - "sides (previously actual cost included employer NI oncosts while rota did not, since Workforce's " + - "schedules API never supplies oncosts — this inflated every rota variance figure by roughly 14-20%). " + - "If a recent previous insight below shows a notably different rota variance figure for the same " + - "department than today's, that is this correction taking effect, not a real change in performance — " + - "do not describe it as unexplained or needing clarification." - ) - lines.push('') - } - if (recentInsights.length) { lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)') for (const r of recentInsights) { @@ -414,15 +397,13 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`) lines.push('') - lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days)`) + lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days, ${rotaVsActual.costBasis})`) lines.push( - "Figures below are BASE PAY only (excluding employer NI) on both sides — Workforce's rota/schedules " + - "API doesn't supply oncosts, so this is the only basis that's genuinely comparable; do not describe " + - "this variance using total-cost figures from other sections. Variance is computed only over days that " + - "have rota data (\"days w/ rota\" below) — if that's well below the total window, treat the variance " + - "as partial/uncertain due to missing rota history, not a confirmed overspend, and say so explicitly." + "Variance is computed only over days that have rota data (\"days w/ rota\" below) — if that's well " + + "below the total window, treat the variance as partial/uncertain due to missing rota history, not a " + + "confirmed overspend, and say so explicitly rather than stating it as fact." ) - lines.push('Department | Actual base pay (days w/ rota) | Rota base pay | Variance | Variance % | Days w/ rota | Actual base pay (full window)') + lines.push('Department | Actual (days w/ rota) | Rota | Variance | Variance % | Days w/ rota | Actual (full window)') for (const d of rotaVsActual.depts) { lines.push( `${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` + diff --git a/frontend/src/pages/Monthly.tsx b/frontend/src/pages/Monthly.tsx index 74f8583..5991b6d 100644 --- a/frontend/src/pages/Monthly.tsx +++ b/frontend/src/pages/Monthly.tsx @@ -508,9 +508,6 @@ export default function Monthly() { {showOncosts &&

Includes estimated employer on-costs. Final payroll figures are in Sage.

} - {isCurrentMonth && forecastMethod === 'rota' && ( -

Rota-based forecast figures exclude employer National Insurance — Workforce's schedules API doesn't provide it, only the timesheets/actuals API does.

- )} {pyDepts.length > 0 && (