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