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([
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`,
[actualsFrom, todayStr]
),
@ -249,7 +249,7 @@ export async function gatherForecastData(monthProgress) {
const dep = r.department_id
deptNames[dep] = r.department_name
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 = {}

View file

@ -13,7 +13,11 @@ export function forecastDayCost(dateStr, actualDays, scheduledDays, includeUnpub
let probe = dateStr
for (let hop = 0; hop <= MAX_HOPS; hop++) {
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 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(
dateStr: string,
actualDays: Record<string, { cost: number }> | undefined,
actualDays: Record<string, { cost: number; shift_count?: number }> | undefined,
scheduledDays: DeptScheduled['days'] | undefined,
includeUnpublished: boolean,
): ForecastDayResult {
let probe = dateStr
for (let hop = 0; hop <= MAX_HOPS; hop++) {
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 hasPublished = (sched?.published_shift_count ?? 0) > 0