syncActuals: switch to timesheets endpoint to capture National Insurance oncosts

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 15:51:24 +00:00
parent 91bdb63a90
commit 34b17b495e

View file

@ -102,67 +102,81 @@ async function fetchTimesheetsForDate(dateStr) {
export async function syncActuals(from, to) { export async function syncActuals(from, to) {
const creds = await getWorkforceCreds() const creds = await getWorkforceCreds()
const locationId = creds.location_id const locationId = creds.location_id ? String(creds.location_id) : null
const enabledDeptIds = await getEnabledDeptIds() const enabledDeptIds = await getEnabledDeptIds()
// Use the shifts endpoint (not timesheets) — it returns cost_with_oncosts which is what // Use timesheets/on/{date} with include_oncosts=true — this returns cost_with_oncosts
// WF's "Cost by Location and Team" Timesheet figure uses (base wages + employer pension + // INCLUDING National Insurance, which the shifts endpoint omits for some employees.
// leave accrual provision). Leave shifts are excluded (leave_request_id != null). // This matches WF's "Cost by Location and Team - Timesheet exc. leave" figure exactly.
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)
const [allDepts, allUsers] = await Promise.all([ const [allDepts, allUsers] = await Promise.all([
wfFetchPaged('/api/v2/departments'), wfFetchPaged('/api/v2/departments'),
wfFetchPaged('/api/v2/users?show_inactive=true'), 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 deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name]))
const userNameMap = Object.fromEntries(allUsers.map(u => [ const userNameMap = Object.fromEntries(allUsers.map(u => [
String(u.id), String(u.id),
u.name || `${u.legal_first_name || ''} ${u.legal_last_name || ''}`.trim() || `User ${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 byDateDept = {}
const byDateDeptEmp = {} const byDateDeptEmp = {}
for (const s of shifts) { for (const timesheets of perDay) {
if (s.leave_request_id != null) continue // exclude leave shifts 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 (s.date < from || s.date > to) continue
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue if (s.leave_request_id != null) continue // exclude leave — matches WF "exc. leave" definition
const date = s.date const deptId = String(s.department_id ?? 'unknown')
const baseCost = parseFloat(s.cost ?? 0) if (locationDeptIds && !locationDeptIds.has(deptId)) continue
const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
const key = `${date}:${deptId}` const date = s.date
if (!byDateDept[key]) { const baseCost = parseFloat(s.cost ?? 0)
byDateDept[key] = { const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
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
if (totalCost > 0 || baseCost > 0) { const key = `${date}:${deptId}`
const empId = String(s.user_id) if (!byDateDept[key]) {
const empName = userNameMap[empId] || `Employee ${empId}` byDateDept[key] = {
const empKey = `${date}:${deptId}:${empId}` date,
if (!byDateDeptEmp[empKey]) { department_id: deptId,
byDateDeptEmp[empKey] = { department_name: deptNameMap[deptId] || deptId,
date, department_id: deptId, employee_id: empId, employee_name: empName, base_cost: 0,
base_cost: 0, total_cost: 0, shift_count: 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
} }
} }