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:
parent
216dacb0f2
commit
c09b31c41e
1 changed files with 45 additions and 8 deletions
|
|
@ -208,18 +208,55 @@ export async function syncActuals(from, to) {
|
|||
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) {
|
||||
const creds = await getWorkforceCreds()
|
||||
const locationId = creds.location_id
|
||||
const enabledDeptIds = await getEnabledDeptIds()
|
||||
|
||||
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
|
||||
if (locationId) path += `&location_id=${locationId}`
|
||||
// No published_only param: this endpoint defaults to published_only=false, returning
|
||||
// both published and draft schedules in one call. Each schedule's own last_published_at
|
||||
// (null until it's been published to its employee) tells us which bucket it belongs to.
|
||||
// A roster call returns its ENTIRE Mon-Sun week regardless of which date within it you ask
|
||||
// for, so fetch each distinct week overlapping [from, to] exactly once, then filter down to
|
||||
// the requested range (a week's roster can extend past either edge of it).
|
||||
const weekStarts = new Set()
|
||||
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 deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name]))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue