Revert rota comparison to real oncosts; remove stale NI footnote
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 <noreply@anthropic.com>
This commit is contained in:
parent
e06d04d517
commit
1ffaece874
2 changed files with 15 additions and 37 deletions
|
|
@ -9,11 +9,6 @@ export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
|
||||||
const MAX_OUTPUT_TOKENS = 900
|
const MAX_OUTPUT_TOKENS = 900
|
||||||
export const MANUAL_RATE_LIMIT_MINUTES = 5
|
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) {
|
function toISODate(d) {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
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
|
// 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
|
// Respects show_oncosts like the rest of the app. Previously hardcoded to base cost because
|
||||||
// setting: Workforce's schedules API never actually supplies employer NI oncosts (confirmed —
|
// the old /api/v2/schedules-based sync never supplied real oncosts on rota data (confirmed:
|
||||||
// published_total_cost is identical to published_base_cost on every synced row), so comparing
|
// published_total_cost == published_base_cost on every row) — fixed 2026-07-25 by switching
|
||||||
// oncost-inclusive actuals against rota would inflate every variance by ~14-20% for reasons
|
// the sync to /api/v2/rosters/on/{date}, which does supply real cost_with_oncosts.
|
||||||
// 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 actualCostCol = 'base_cost'
|
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
||||||
const schedCostExpr = '(published_base_cost + unpublished_base_cost)'
|
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 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))
|
||||||
|
|
@ -213,7 +209,7 @@ export async function gatherRotaVsActualData(days = 28) {
|
||||||
}))
|
}))
|
||||||
.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, 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
|
// Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic
|
||||||
|
|
@ -374,19 +370,6 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast,
|
||||||
lines.push('')
|
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) {
|
if (recentInsights.length) {
|
||||||
lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)')
|
lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)')
|
||||||
for (const r of recentInsights) {
|
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)}`)
|
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)}, ${rotaVsActual.totalDaysInWindow} days)`)
|
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days, ${rotaVsActual.costBasis})`)
|
||||||
lines.push(
|
lines.push(
|
||||||
"Figures below are BASE PAY only (excluding employer NI) on both sides — Workforce's rota/schedules " +
|
"Variance is computed only over days that have rota data (\"days w/ rota\" below) — if that's well " +
|
||||||
"API doesn't supply oncosts, so this is the only basis that's genuinely comparable; do not describe " +
|
"below the total window, treat the variance as partial/uncertain due to missing rota history, not a " +
|
||||||
"this variance using total-cost figures from other sections. Variance is computed only over days that " +
|
"confirmed overspend, and say so explicitly rather than stating it as fact."
|
||||||
"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)')
|
lines.push('Department | Actual (days w/ rota) | Rota | Variance | Variance % | Days w/ rota | Actual (full window)')
|
||||||
for (const d of rotaVsActual.depts) {
|
for (const d of rotaVsActual.depts) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` +
|
`${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` +
|
||||||
|
|
|
||||||
|
|
@ -508,9 +508,6 @@ export default function Monthly() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
||||||
{isCurrentMonth && forecastMethod === 'rota' && (
|
|
||||||
<p className="footnote">Rota-based forecast figures exclude employer National Insurance — Workforce's schedules API doesn't provide it, only the timesheets/actuals API does.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pyDepts.length > 0 && (
|
{pyDepts.length > 0 && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue