Don't treat today's in-progress £0 actuals as ground truth in forecast

wage_actuals rows for the current day exist with real shift_count but
£0 cost until Workforce finishes costing the shift (clock-out/approval
pending). forecastDayCost was treating that as a legitimate zero-cost
actual, both zeroing today's own forecast contribution and poisoning
every future date whose 7-day hop-back chain lands on today — e.g.
every remaining same-weekday this month resolved straight to £0
instead of falling through to published rota or older actuals.
This commit is contained in:
jtricerolph 2026-08-03 10:36:56 +00:00
parent 0ea4efdb70
commit 221a5598c0
3 changed files with 13 additions and 5 deletions

View file

@ -230,7 +230,7 @@ export async function gatherForecastData(monthProgress) {
const [actualRes, schedRes] = await Promise.all([ const [actualRes, schedRes] = await Promise.all([
pool.query( pool.query(
`SELECT date, department_id, department_name, ${showOncosts ? 'total_cost' : 'base_cost'} AS cost `SELECT date, department_id, department_name, ${showOncosts ? 'total_cost' : 'base_cost'} AS cost, shift_count
FROM wage_actuals WHERE date >= $1 AND date < $2`, FROM wage_actuals WHERE date >= $1 AND date < $2`,
[actualsFrom, todayStr] [actualsFrom, todayStr]
), ),
@ -249,7 +249,7 @@ export async function gatherForecastData(monthProgress) {
const dep = r.department_id const dep = r.department_id
deptNames[dep] = r.department_name deptNames[dep] = r.department_name
actualByDept[dep] ??= {} actualByDept[dep] ??= {}
actualByDept[dep][r.date.toISOString().slice(0, 10)] = { cost: parseFloat(r.cost) } actualByDept[dep][r.date.toISOString().slice(0, 10)] = { cost: parseFloat(r.cost), shift_count: r.shift_count }
} }
const schedByDept = {} const schedByDept = {}

View file

@ -13,7 +13,11 @@ export function forecastDayCost(dateStr, actualDays, scheduledDays, includeUnpub
let probe = dateStr let probe = dateStr
for (let hop = 0; hop <= MAX_HOPS; hop++) { for (let hop = 0; hop <= MAX_HOPS; hop++) {
const actual = actualDays?.[probe] const actual = actualDays?.[probe]
if (actual != null) return { cost: actual.cost, tier: 'actual' } // A same-day row with shifts but £0 cost means Workforce hasn't costed them yet
// (shift still in progress / not clocked out) — not a genuine zero, so fall through
// to rota/repeat instead of treating it as ground truth.
const actualIncomplete = actual != null && actual.cost === 0 && (actual.shift_count ?? 0) > 0
if (actual != null && !actualIncomplete) return { cost: actual.cost, tier: 'actual' }
const sched = scheduledDays?.[probe] const sched = scheduledDays?.[probe]
const hasPublished = (sched?.published_shift_count ?? 0) > 0 const hasPublished = (sched?.published_shift_count ?? 0) > 0

View file

@ -29,14 +29,18 @@ const MAX_HOPS = 6 // 6 * 7 = 42 days back, comfortably within the 35-day actual
*/ */
export function forecastDayCost( export function forecastDayCost(
dateStr: string, dateStr: string,
actualDays: Record<string, { cost: number }> | undefined, actualDays: Record<string, { cost: number; shift_count?: number }> | undefined,
scheduledDays: DeptScheduled['days'] | undefined, scheduledDays: DeptScheduled['days'] | undefined,
includeUnpublished: boolean, includeUnpublished: boolean,
): ForecastDayResult { ): ForecastDayResult {
let probe = dateStr let probe = dateStr
for (let hop = 0; hop <= MAX_HOPS; hop++) { for (let hop = 0; hop <= MAX_HOPS; hop++) {
const actual = actualDays?.[probe] const actual = actualDays?.[probe]
if (actual != null) return { cost: actual.cost, tier: 'actual' } // A same-day row with shifts but £0 cost means Workforce hasn't costed them yet
// (shift still in progress / not clocked out) — not a genuine zero, so fall through
// to rota/repeat instead of treating it as ground truth.
const actualIncomplete = actual != null && actual.cost === 0 && (actual.shift_count ?? 0) > 0
if (actual != null && !actualIncomplete) return { cost: actual.cost, tier: 'actual' }
const sched = scheduledDays?.[probe] const sched = scheduledDays?.[probe]
const hasPublished = (sched?.published_shift_count ?? 0) > 0 const hasPublished = (sched?.published_shift_count ?? 0) > 0