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:
parent
6339b33270
commit
661e219aac
1 changed files with 58 additions and 24 deletions
|
|
@ -144,12 +144,16 @@ export async function gatherPriorPeriodData() {
|
||||||
|
|
||||||
// Department-level variance between scheduled (rota) and actual cost, over the trailing
|
// 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.
|
// 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) {
|
export async function gatherRotaVsActualData(days = 28) {
|
||||||
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
const actualCostCol = 'base_cost'
|
||||||
const actualCostCol = showOncosts ? 'total_cost' : 'base_cost'
|
const schedCostExpr = '(published_base_cost + unpublished_base_cost)'
|
||||||
const schedCostExpr = showOncosts
|
|
||||||
? '(published_total_cost + unpublished_total_cost)'
|
|
||||||
: '(published_base_cost + unpublished_base_cost)'
|
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
|
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 = {}
|
const schedMap = {}
|
||||||
for (const r of schedRes.rows) {
|
for (const r of schedRes.rows) {
|
||||||
schedMap[`${r.date.toISOString().slice(0, 10)}:${r.department_id}`] = parseFloat(r.cost)
|
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 = {}
|
const byDept = {}
|
||||||
for (const r of actualRes.rows) {
|
for (const r of actualRes.rows) {
|
||||||
const dateStr = r.date.toISOString().slice(0, 10)
|
const dateStr = r.date.toISOString().slice(0, 10)
|
||||||
const dep = r.department_id
|
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 actualCost = parseFloat(r.cost)
|
||||||
const schedCost = schedMap[`${dateStr}:${dep}`]
|
const schedCost = schedMap[`${dateStr}:${dep}`]
|
||||||
byDept[dep].actualTotal += actualCost
|
byDept[dep].actualTotal += actualCost
|
||||||
if (schedCost != null) {
|
if (schedCost != null) {
|
||||||
byDept[dep].schedTotal += schedCost
|
byDept[dep].schedTotal += schedCost
|
||||||
|
byDept[dep].actualOnRotaDays += actualCost
|
||||||
byDept[dep].daysWithRota++
|
byDept[dep].daysWithRota++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,12 +201,12 @@ export async function gatherRotaVsActualData(days = 28) {
|
||||||
const depts = Object.values(byDept)
|
const depts = Object.values(byDept)
|
||||||
.map(d => ({
|
.map(d => ({
|
||||||
...d,
|
...d,
|
||||||
variance: d.actualTotal - d.schedTotal,
|
variance: d.actualOnRotaDays - d.schedTotal,
|
||||||
variancePct: d.schedTotal > 0 ? (d.actualTotal - d.schedTotal) / d.schedTotal : null,
|
variancePct: d.schedTotal > 0 ? (d.actualOnRotaDays - d.schedTotal) / d.schedTotal : null,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance))
|
.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
|
// 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 fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
|
||||||
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
|
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
|
||||||
|
|
||||||
const res = await pool.query(
|
// wage_actuals_detail only stores department_id (a raw Workforce code, e.g. "972312"),
|
||||||
`SELECT employee_id, employee_name, department_id,
|
// not a human-readable name — resolve it from wage_actuals, which has both, so the
|
||||||
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
|
// prompt (and the model) never has to guess which department a code refers to.
|
||||||
FROM wage_actuals_detail
|
const [res, deptNamesRes] = await Promise.all([
|
||||||
WHERE date >= $1 AND date <= $2
|
pool.query(
|
||||||
GROUP BY employee_id, employee_name, department_id
|
`SELECT employee_id, employee_name, department_id,
|
||||||
ORDER BY SUM(${costCol}) DESC
|
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
|
||||||
LIMIT 15`,
|
FROM wage_actuals_detail
|
||||||
[fromStr, toStr]
|
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 {
|
return {
|
||||||
fromStr,
|
fromStr,
|
||||||
|
|
@ -301,6 +320,7 @@ export async function gatherEmployeeAnomalies(days = 7) {
|
||||||
employee_id: r.employee_id,
|
employee_id: r.employee_id,
|
||||||
employee_name: r.employee_name,
|
employee_name: r.employee_name,
|
||||||
department_id: r.department_id,
|
department_id: r.department_id,
|
||||||
|
department_name: deptNames[r.department_id] || r.department_id,
|
||||||
cost: parseFloat(r.cost),
|
cost: parseFloat(r.cost),
|
||||||
shift_count: r.shift_count,
|
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)}`)
|
for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)})`)
|
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days)`)
|
||||||
lines.push('Department | Actual | Rota | Variance | Variance %')
|
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) {
|
for (const d of rotaVsActual.depts) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`${d.department_name} | ${fmtMoney(d.actualTotal)} | ${fmtMoney(d.schedTotal)} | ` +
|
`${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` +
|
||||||
`${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)}`
|
`${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)} | ${d.daysWithRota}/${rotaVsActual.totalDaysInWindow} | ${fmtMoney(d.actualTotal)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
lines.push(`## Forecast (method: ${forecast.forecastMethod}, ${forecast.remainingDaysCount} days remaining)`)
|
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(
|
lines.push(
|
||||||
`Projected month-end total: ${fmtMoney(forecast.projectedTotal)} vs budget ${fmtMoney(monthProgress.budgetAmount)}` +
|
`Projected month-end total: ${fmtMoney(forecast.projectedTotal)} vs budget ${fmtMoney(monthProgress.budgetAmount)}` +
|
||||||
(monthProgress.budgetAmount != null
|
(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(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`)
|
||||||
lines.push('Employee | Department | Cost | Shifts')
|
lines.push('Employee | Department | Cost | Shifts')
|
||||||
for (const e of anomalies.employees) {
|
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('')
|
lines.push('')
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue