From c09b31c41e9d0f64487765499cfd91ba4c24811e Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 25 Jul 2026 10:01:35 +0000 Subject: [PATCH] Migrate rota sync from /api/v2/schedules to /api/v2/rosters/on/{date} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/lib/workforce.js | 53 ++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index 6a3f945..781f81d 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -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]))