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]))