From 661e219aac04ed4598a5c6d665d5ee7f03788355 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 25 Jul 2026 00:43:12 +0000 Subject: [PATCH] Fix rota-vs-actual comparison: base-cost mismatch and missing dept names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs inflating the rota variance figures in AI insights: 1. Actual cost (incl. NI oncosts) was compared against rota cost that silently falls back to base pay — Workforce's schedules API never actually supplies oncosts (confirmed: published_total_cost equals published_base_cost on every synced row), so every variance was inflated by ~14-20% for reasons unrelated to real overspend. Now always compares base cost on both sides, with a note in the prompt that this is a base-pay-only comparison. 2. Variance was summed over the full window using actual cost, but only over covered days using rota cost, understating rota further whenever coverage was incomplete. Now restricts the actual-cost side to the same days rota data exists for, and reports coverage (days w/ rota vs total) explicitly so partial coverage isn't presented as a confirmed figure. Also: the employee anomalies section only had department_id (a raw Workforce code), not department_name, since wage_actuals_detail doesn't store it — resolved via wage_actuals so the model can say "Chef" instead of guessing from a numeric code. Co-Authored-By: Claude Sonnet 5 --- backend/src/jobs/ai-insights.js | 82 +++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/backend/src/jobs/ai-insights.js b/backend/src/jobs/ai-insights.js index 9392f4e..0892ce1 100644 --- a/backend/src/jobs/ai-insights.js +++ b/backend/src/jobs/ai-insights.js @@ -144,12 +144,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. export async function gatherRotaVsActualData(days = 28) { - 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 actualCostCol = 'base_cost' + const schedCostExpr = '(published_base_cost + unpublished_base_cost)' const today = new Date() const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000)) @@ -168,21 +172,28 @@ export async function gatherRotaVsActualData(days = 28) { ), ]) + const totalDaysInWindow = Math.round((new Date(toStr + 'T00:00:00') - new Date(fromStr + 'T00:00:00')) / 86_400_000) + 1 + const schedMap = {} for (const r of schedRes.rows) { schedMap[`${r.date.toISOString().slice(0, 10)}:${r.department_id}`] = parseFloat(r.cost) } + // actualTotal = full-window actual cost (context only). actualOnRotaDays = actual cost + // restricted to the SAME days rota data exists for — this is what variance is computed + // from, so a department with sparse rota history doesn't get a wildly inflated "overspend" + // that's really just missing rota rows, not real cost variance. const byDept = {} for (const r of actualRes.rows) { const dateStr = r.date.toISOString().slice(0, 10) const dep = r.department_id - byDept[dep] ??= { department_id: dep, department_name: r.department_name, actualTotal: 0, schedTotal: 0, daysWithRota: 0 } + byDept[dep] ??= { department_id: dep, department_name: r.department_name, actualTotal: 0, actualOnRotaDays: 0, schedTotal: 0, daysWithRota: 0 } const actualCost = parseFloat(r.cost) const schedCost = schedMap[`${dateStr}:${dep}`] byDept[dep].actualTotal += actualCost if (schedCost != null) { byDept[dep].schedTotal += schedCost + byDept[dep].actualOnRotaDays += actualCost byDept[dep].daysWithRota++ } } @@ -190,12 +201,12 @@ export async function gatherRotaVsActualData(days = 28) { const depts = Object.values(byDept) .map(d => ({ ...d, - variance: d.actualTotal - d.schedTotal, - variancePct: d.schedTotal > 0 ? (d.actualTotal - d.schedTotal) / d.schedTotal : null, + variance: d.actualOnRotaDays - d.schedTotal, + variancePct: d.schedTotal > 0 ? (d.actualOnRotaDays - d.schedTotal) / d.schedTotal : null, })) .sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance)) - return { fromStr, toStr, depts } + return { fromStr, toStr, totalDaysInWindow, depts } } // Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic @@ -283,16 +294,24 @@ export async function gatherEmployeeAnomalies(days = 7) { const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000)) const toStr = toISODate(new Date(today.getTime() - 86_400_000)) - const res = await pool.query( - `SELECT employee_id, employee_name, department_id, - SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count - FROM wage_actuals_detail - WHERE date >= $1 AND date <= $2 - GROUP BY employee_id, employee_name, department_id - ORDER BY SUM(${costCol}) DESC - LIMIT 15`, - [fromStr, toStr] - ) + // wage_actuals_detail only stores department_id (a raw Workforce code, e.g. "972312"), + // not a human-readable name — resolve it from wage_actuals, which has both, so the + // prompt (and the model) never has to guess which department a code refers to. + const [res, deptNamesRes] = await Promise.all([ + pool.query( + `SELECT employee_id, employee_name, department_id, + SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count + FROM wage_actuals_detail + WHERE date >= $1 AND date <= $2 + GROUP BY employee_id, employee_name, department_id + ORDER BY SUM(${costCol}) DESC + LIMIT 15`, + [fromStr, toStr] + ), + pool.query(`SELECT DISTINCT department_id, department_name FROM wage_actuals`), + ]) + + const deptNames = Object.fromEntries(deptNamesRes.rows.map(r => [r.department_id, r.department_name])) return { fromStr, @@ -301,6 +320,7 @@ export async function gatherEmployeeAnomalies(days = 7) { employee_id: r.employee_id, employee_name: r.employee_name, department_id: r.department_id, + department_name: deptNames[r.department_id] || r.department_id, cost: parseFloat(r.cost), shift_count: r.shift_count, })), @@ -365,17 +385,31 @@ 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)})`) - lines.push('Department | Actual | Rota | Variance | Variance %') + lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days)`) + 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." + ) + lines.push('Department | Actual base pay (days w/ rota) | Rota base pay | Variance | Variance % | Days w/ rota | Actual base pay (full window)') for (const d of rotaVsActual.depts) { lines.push( - `${d.department_name} | ${fmtMoney(d.actualTotal)} | ${fmtMoney(d.schedTotal)} | ` + - `${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)}` + `${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` + + `${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)} | ${d.daysWithRota}/${rotaVsActual.totalDaysInWindow} | ${fmtMoney(d.actualTotal)}` ) } lines.push('') lines.push(`## Forecast (method: ${forecast.forecastMethod}, ${forecast.remainingDaysCount} days remaining)`) + if (forecast.forecastMethod === 'rota') { + lines.push( + "Note: remaining days sourced from rota exclude employer NI oncosts (Workforce's schedules API " + + "doesn't supply them), so this projection may modestly understate the true month-end total — same " + + "known limitation shown on the Monthly page." + ) + } lines.push( `Projected month-end total: ${fmtMoney(forecast.projectedTotal)} vs budget ${fmtMoney(monthProgress.budgetAmount)}` + (monthProgress.budgetAmount != null @@ -388,7 +422,7 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, lines.push(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`) lines.push('Employee | Department | Cost | Shifts') for (const e of anomalies.employees) { - lines.push(`${e.employee_name} | ${e.department_id} | ${fmtMoney(e.cost)} | ${e.shift_count}`) + lines.push(`${e.employee_name} | ${e.department_name} | ${fmtMoney(e.cost)} | ${e.shift_count}`) } lines.push('')