From 34b17b495e59bf6840faf6674409b16f0ed52e43 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 15:51:24 +0000 Subject: [PATCH] syncActuals: switch to timesheets endpoint to capture National Insurance oncosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shifts endpoint omits employer NI from cost_with_oncosts for some employees. The timesheets/on/{date}?include_oncosts=true endpoint includes NI, matching WF's native 'Cost by Location and Team' Timesheet exc. leave figure exactly. June 2026 verification: B.shiftLevel.oncostTotal = £51,005.72 = WF target. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/lib/workforce.js | 94 +++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index 10f1436..1ca4a4e 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -102,67 +102,81 @@ async function fetchTimesheetsForDate(dateStr) { export async function syncActuals(from, to) { const creds = await getWorkforceCreds() - const locationId = creds.location_id + const locationId = creds.location_id ? String(creds.location_id) : null const enabledDeptIds = await getEnabledDeptIds() - // Use the shifts endpoint (not timesheets) — it returns cost_with_oncosts which is what - // WF's "Cost by Location and Team" Timesheet figure uses (base wages + employer pension + - // leave accrual provision). Leave shifts are excluded (leave_request_id != null). - let path = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` - if (locationId) path += `&report_location_id=${locationId}` - - const shifts = await wfFetchPaged(path) + // Use timesheets/on/{date} with include_oncosts=true — this returns cost_with_oncosts + // INCLUDING National Insurance, which the shifts endpoint omits for some employees. + // This matches WF's "Cost by Location and Team - Timesheet exc. leave" figure exactly. const [allDepts, allUsers] = await Promise.all([ wfFetchPaged('/api/v2/departments'), wfFetchPaged('/api/v2/users?show_inactive=true'), ]) + const locationDeptIds = locationId + ? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id))) + : null const deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name])) const userNameMap = Object.fromEntries(allUsers.map(u => [ String(u.id), u.name || `${u.legal_first_name || ''} ${u.legal_last_name || ''}`.trim() || `User ${u.id}`, ])) + const allDates = buildDateRange(from, to) + const perDay = await Promise.all(allDates.map(fetchTimesheetsForDate)) + + const seenShiftIds = new Set() const byDateDept = {} const byDateDeptEmp = {} - for (const s of shifts) { - if (s.leave_request_id != null) continue // exclude leave shifts + for (const timesheets of perDay) { + for (const t of timesheets) { + if (!Array.isArray(t.shifts)) continue + for (const s of t.shifts) { + const shiftId = String(s.id) + if (seenShiftIds.has(shiftId)) continue + seenShiftIds.add(shiftId) - const deptId = String(s.department_id) - if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue + if (s.date < from || s.date > to) continue + if (s.leave_request_id != null) continue // exclude leave — matches WF "exc. leave" definition - const date = s.date - const baseCost = parseFloat(s.cost ?? 0) - const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) + const deptId = String(s.department_id ?? 'unknown') + if (locationDeptIds && !locationDeptIds.has(deptId)) continue + if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue - const key = `${date}:${deptId}` - if (!byDateDept[key]) { - byDateDept[key] = { - date, - department_id: deptId, - department_name: deptNameMap[deptId] || s.department_name || deptId, - base_cost: 0, - total_cost: 0, - shift_count: 0, - } - } - byDateDept[key].base_cost += baseCost - byDateDept[key].total_cost += totalCost - byDateDept[key].shift_count += 1 + const date = s.date + const baseCost = parseFloat(s.cost ?? 0) + const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) - if (totalCost > 0 || baseCost > 0) { - const empId = String(s.user_id) - const empName = userNameMap[empId] || `Employee ${empId}` - const empKey = `${date}:${deptId}:${empId}` - if (!byDateDeptEmp[empKey]) { - byDateDeptEmp[empKey] = { - date, department_id: deptId, employee_id: empId, employee_name: empName, - base_cost: 0, total_cost: 0, shift_count: 0, + const key = `${date}:${deptId}` + if (!byDateDept[key]) { + byDateDept[key] = { + date, + department_id: deptId, + department_name: deptNameMap[deptId] || deptId, + base_cost: 0, + total_cost: 0, + shift_count: 0, + } + } + byDateDept[key].base_cost += baseCost + byDateDept[key].total_cost += totalCost + byDateDept[key].shift_count += 1 + + if (totalCost > 0 || baseCost > 0) { + const empId = String(s.user_id) + const empName = userNameMap[empId] || `Employee ${empId}` + const empKey = `${date}:${deptId}:${empId}` + if (!byDateDeptEmp[empKey]) { + byDateDeptEmp[empKey] = { + date, department_id: deptId, employee_id: empId, employee_name: empName, + base_cost: 0, total_cost: 0, shift_count: 0, + } + } + byDateDeptEmp[empKey].base_cost += baseCost + byDateDeptEmp[empKey].total_cost += totalCost + byDateDeptEmp[empKey].shift_count += 1 } } - byDateDeptEmp[empKey].base_cost += baseCost - byDateDeptEmp[empKey].total_cost += totalCost - byDateDeptEmp[empKey].shift_count += 1 } }