Migrate rota sync from /api/v2/schedules to /api/v2/rosters/on/{date}

The schedules list endpoint has two confirmed problems: it silently
drops all shift data for deactivated/terminated employees (even for
historical dates while they were still active), and it never returns
real oncost figures (cost_with_oncosts always equals cost). Both
verified against the live API. The roster endpoint (a different data
path — Roster is the weekly container, Schedule is an individual shift
within it) has neither problem: leavers' historical shifts are intact,
and real cost_with_oncosts + an oncosts_breakdown are present.

syncScheduled() now fetches one whole Mon-Sun roster per distinct week
overlapping the requested range (rosters/on/{date} returns the entire
week regardless of which date you ask for), flattens the nested
day->shifts structure, and filters back down to the requested range.
Explicitly paginated (page_size=100, following meta.total_pages)
rather than relying on the undocumented behaviour that omitting
page/page_size returns everything unpaginated.

Validated on dev before deploying: re-synced the 27/06-24/07 window
and confirmed a known leaver's shifts reappeared, real oncosts are now
present (Chef: £11,898 base vs £13,572 with oncosts), and forward/
draft-shift handling still works correctly for the 14-day rolling
sync. The Chef "overspend" the AI insight flagged turned out to be
entirely this artifact — corrected comparison shows a small underspend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-25 10:01:35 +00:00
parent 216dacb0f2
commit c09b31c41e

View file

@ -208,18 +208,55 @@ export async function syncActuals(from, to) {
return Object.keys(byDateDept).length return Object.keys(byDateDept).length
} }
// Monday (YYYY-MM-DD) of the week containing dateStr — Workforce rosters run Mon-Sun.
function mondayOf(dateStr) {
const d = new Date(dateStr + 'T00:00:00')
const day = d.getDay() // 0=Sun..6=Sat
d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
return d.toISOString().slice(0, 10)
}
// Fetch one whole Mon-Sun roster week (any date within it works as the anchor) and flatten
// its nested day->shifts structure into a flat shift list, each tagged with its date.
// Explicitly paginated (page_size=100, following meta.total_pages) rather than relying on
// the fact that omitting page/page_size happens to return everything unpaginated — that
// behaviour isn't documented and silent truncation is exactly the failure mode this endpoint
// switch exists to fix (see workforce-api findings, 2026-07-25: /api/v2/schedules silently
// drops deactivated employees' shifts and never returns real oncosts; rosters have both).
async function fetchRosterWeek(anchorDateStr) {
const shifts = []
let page = 1
while (true) {
const data = await wfFetch(
`/api/v2/rosters/on/${anchorDateStr}?show_costs=true&include_oncosts=true&page=${page}&page_size=100`
)
for (const day of data.schedules || []) {
for (const s of day.schedules || []) {
shifts.push({ ...s, date: day.date })
}
}
const totalPages = data.meta?.total_pages ?? 1
if (page >= totalPages) break
page++
}
return shifts
}
export async function syncScheduled(from, to) { export async function syncScheduled(from, to) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const enabledDeptIds = await getEnabledDeptIds() const enabledDeptIds = await getEnabledDeptIds()
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true` // A roster call returns its ENTIRE Mon-Sun week regardless of which date within it you ask
if (locationId) path += `&location_id=${locationId}` // for, so fetch each distinct week overlapping [from, to] exactly once, then filter down to
// No published_only param: this endpoint defaults to published_only=false, returning // the requested range (a week's roster can extend past either edge of it).
// both published and draft schedules in one call. Each schedule's own last_published_at const weekStarts = new Set()
// (null until it's been published to its employee) tells us which bucket it belongs to. for (const dateStr of buildDateRange(from, to)) weekStarts.add(mondayOf(dateStr))
let schedules = []
for (const weekStart of weekStarts) {
schedules = schedules.concat(await fetchRosterWeek(weekStart))
}
schedules = schedules.filter(s => s.date >= from && s.date <= to)
const schedules = await wfFetchPaged(path)
const allDepts = await wfFetchPaged('/api/v2/departments') const allDepts = await wfFetchPaged('/api/v2/departments')
const deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name])) const deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name]))