Fix current-period % Budget bugs on Rolling 12 Weeks/Months tables
Rolling12Months: current month's budget wasn't prorated at all, so %Budget compared MTD actual against the FULL monthly budget — always looked artificially favorable. Now prorates by elapsed PY DOW-matched sales share (flat day-count fallback), reusing already-fetched data. Rolling12Weeks: budget was prorated, but the elapsed-day count mixed a real timestamp with Math.ceil(...)+1, always overstating elapsed days by one and inflating the budget denominator. Rewrote to sum per-day budgets by date string (no fractional-time bug), and applied the same PY DOW-weighting as the Weekly page, sourcing each day from its own calendar month so weeks spanning a month boundary split correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
47e899f636
commit
a1a8900196
2 changed files with 72 additions and 14 deletions
|
|
@ -90,8 +90,19 @@ export default function Rolling12Months() {
|
||||||
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
|
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
|
||||||
}
|
}
|
||||||
|
|
||||||
const monKey = `${monthFrom}`
|
// Budget: prorated to elapsed-to-date by each day's share of the month's PY (DOW-matched)
|
||||||
const budget = budgetMap[monKey] ?? null
|
// sales — falls back to flat day-count when PY data is missing. For complete past months
|
||||||
|
// effectiveTo === monthTo, so this always resolves to the full budget (fraction = 1).
|
||||||
|
let monthPyTotal = 0
|
||||||
|
for (const [date, val] of Object.entries(salesByDate)) {
|
||||||
|
if (date >= monthFrom && date <= monthTo) monthPyTotal += val.py
|
||||||
|
}
|
||||||
|
const monKey = `${monthFrom}`
|
||||||
|
const budgetRaw = budgetMap[monKey] ?? null
|
||||||
|
const mtdFrac = monthPyTotal > 0
|
||||||
|
? pySales / monthPyTotal
|
||||||
|
: parseInt(effectiveTo.slice(8), 10) / dim
|
||||||
|
const budget = budgetRaw != null ? budgetRaw * mtdFrac : null
|
||||||
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
|
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
|
||||||
|
|
||||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })
|
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,14 @@ function startOfWeek(d: Date): Date {
|
||||||
r.setHours(0, 0, 0, 0)
|
r.setHours(0, 0, 0, 0)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() }
|
function monthKeyOf(dateStr: string): string { return dateStr.slice(0, 7) }
|
||||||
|
function daysInMonthKey(monthKey: string): number {
|
||||||
|
const [y, m] = monthKey.split('-').map(Number)
|
||||||
|
return new Date(y, m, 0).getDate()
|
||||||
|
}
|
||||||
|
function monthRangeOf(monthKey: string): { from: string; to: string } {
|
||||||
|
return { from: `${monthKey}-01`, to: `${monthKey}-${String(daysInMonthKey(monthKey)).padStart(2, '0')}` }
|
||||||
|
}
|
||||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||||
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
||||||
function fmtDelta(p: number): string { const d = p - 100; return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` }
|
function fmtDelta(p: number): string { const d = p - 100; return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` }
|
||||||
|
|
@ -43,10 +50,24 @@ export default function Rolling12Weeks() {
|
||||||
const rangeStart = addDays(thisMonday, -12 * 7)
|
const rangeStart = addDays(thisMonday, -12 * 7)
|
||||||
const rangeEnd = addDays(thisMonday, 6) // end of current week
|
const rangeEnd = addDays(thisMonday, 6) // end of current week
|
||||||
|
|
||||||
const [actRes, salesRes, budRes] = await Promise.all([
|
// Budget is monthly, so weighting each week's share needs full-month PY sales as the
|
||||||
|
// denominator — gather every distinct calendar month touched by any of the 13 weeks
|
||||||
|
// (a week can span two months) and fetch each one's full range.
|
||||||
|
const monthKeySet = new Set<string>()
|
||||||
|
for (let w = 0; w < 13; w++) {
|
||||||
|
const wStart = addDays(rangeStart, w * 7)
|
||||||
|
const wEnd = addDays(wStart, 6)
|
||||||
|
monthKeySet.add(monthKeyOf(fmt(wStart)))
|
||||||
|
monthKeySet.add(monthKeyOf(fmt(wEnd)))
|
||||||
|
}
|
||||||
|
const monthKeys = Array.from(monthKeySet)
|
||||||
|
const monthRanges = monthKeys.map(monthRangeOf)
|
||||||
|
|
||||||
|
const [actRes, salesRes, budRes, monthSalesResList] = await Promise.all([
|
||||||
getActuals(fmt(rangeStart), fmt(rangeEnd)),
|
getActuals(fmt(rangeStart), fmt(rangeEnd)),
|
||||||
getNetSales(fmt(rangeStart), fmt(rangeEnd)),
|
getNetSales(fmt(rangeStart), fmt(rangeEnd)),
|
||||||
getBudgets(),
|
getBudgets(),
|
||||||
|
Promise.all(monthRanges.map(r => getNetSales(r.from, r.to))),
|
||||||
])
|
])
|
||||||
|
|
||||||
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
||||||
|
|
@ -54,9 +75,30 @@ export default function Rolling12Weeks() {
|
||||||
salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
||||||
}
|
}
|
||||||
|
|
||||||
const budgetMap: Record<string, number> = {}
|
// Per-day PY sales and per-month PY totals, used to weight each week's budget share
|
||||||
|
const pySalesByDay: Record<string, number> = {}
|
||||||
|
const monthPyTotals: Record<string, number> = {}
|
||||||
|
monthKeys.forEach((key, i) => {
|
||||||
|
let total = 0
|
||||||
|
for (const d of monthSalesResList[i].days) { pySalesByDay[d.date] = d.py_sales; total += d.py_sales }
|
||||||
|
monthPyTotals[key] = total
|
||||||
|
})
|
||||||
|
|
||||||
|
const budgetMap: Record<string, number> = {} // monthKey (YYYY-MM) -> amount
|
||||||
for (const b of budRes.budgets as WageBudget[]) {
|
for (const b of budRes.budgets as WageBudget[]) {
|
||||||
budgetMap[b.month] = b.budget_amount
|
budgetMap[b.month.slice(0, 7)] = b.budget_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget for a single day: sourced from that day's own calendar month, weighted by its
|
||||||
|
// share of that month's PY (DOW-matched) sales — falls back to a flat day-count split
|
||||||
|
// within that month when PY sales data isn't available.
|
||||||
|
const dayBudget = (ds: string): number => {
|
||||||
|
const mKey = monthKeyOf(ds)
|
||||||
|
const monthBudget = budgetMap[mKey]
|
||||||
|
if (monthBudget == null) return 0
|
||||||
|
const monthTotal = monthPyTotals[mKey] ?? 0
|
||||||
|
if (monthTotal > 0) return monthBudget * ((pySalesByDay[ds] ?? 0) / monthTotal)
|
||||||
|
return monthBudget / daysInMonthKey(mKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dept lookup
|
// Dept lookup
|
||||||
|
|
@ -95,20 +137,25 @@ export default function Rolling12Weeks() {
|
||||||
pySales += salesByDate[ds]?.py ?? 0
|
pySales += salesByDate[ds]?.py ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pro-rata budget
|
// Budget: sum of each elapsed day's own-month, PY-weighted share (see dayBudget above)
|
||||||
const monStr = `${wStart.getFullYear()}-${String(wStart.getMonth() + 1).padStart(2, '0')}-01`
|
let budget = 0
|
||||||
const monthBudget = budgetMap[monStr]
|
let anyBudget = false
|
||||||
const budget = monthBudget != null
|
for (let i = 0; i <= 6; i++) {
|
||||||
? monthBudget * (isPartial ? (Math.ceil((today.getTime() - wStart.getTime()) / 86_400_000) + 1) : 7) / daysInMonth(wStart)
|
const d = addDays(wStart, i)
|
||||||
: null
|
if (d > effectiveEnd) break
|
||||||
|
const ds = fmt(d)
|
||||||
|
if (budgetMap[monthKeyOf(ds)] != null) anyBudget = true
|
||||||
|
budget += dayBudget(ds)
|
||||||
|
}
|
||||||
|
const budgetVal = anyBudget ? budget : null
|
||||||
|
|
||||||
const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`
|
const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`
|
||||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isPartial })
|
tableRows.push({ label, wages, budget: budgetVal, sales, pySales, partial: isPartial })
|
||||||
|
|
||||||
const cdRow: Record<string, number | string> = { label }
|
const cdRow: Record<string, number | string> = { label }
|
||||||
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
||||||
cdRow._wages = wages
|
cdRow._wages = wages
|
||||||
cdRow._budget = budget ?? 0
|
cdRow._budget = budgetVal ?? 0
|
||||||
cData.push(cdRow)
|
cData.push(cdRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue