Fix rota-vs-actual comparison: base-cost mismatch and missing dept names

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-25 00:43:12 +00:00
parent 6339b33270
commit 661e219aac

View file

@ -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('')