diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index 6dd9615..a2f5ddc 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -103,81 +103,67 @@ async function fetchTimesheetsForDate(dateStr) { export async function syncActuals(from, to) { const creds = await getWorkforceCreds() - const locationId = creds.location_id ? String(creds.location_id) : null + const locationId = creds.location_id const enabledDeptIds = await getEnabledDeptIds() - // Fetch depts + users (inc. inactive — they may have timesheets in the date range) + // 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) const [allDepts, allUsers] = await Promise.all([ wfFetchPaged('/api/v2/departments'), wfFetchPaged('/api/v2/users?show_inactive=true'), ]) - const deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name])) - const userNameMap = Object.fromEntries(allUsers.map(u => [ + 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}`, ])) - // Set of department IDs belonging to this location — used to filter shift.department_id. - // This matches how WF's "Cost by Location and Team" report groups costs: by the - // department the shift was worked in, not by where the employee is based. - const locationDeptIds = locationId - ? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id))) - : null - // Fetch each day in parallel. The timesheets/on/{date} endpoint returns only that - // specific day's shift data (not the whole week), so per-day fetching is correct. - // Deduplicate by shift.id in case any shifts appear in more than one timesheet record. - const allDates = buildDateRange(from, to) - const perDay = await Promise.all(allDates.map(fetchTimesheetsForDate)) - - const seenShiftIds = new Set() const byDateDept = {} const byDateDeptEmp = {} - for (const timesheets of perDay) { - for (const t of timesheets) { - if (!Array.isArray(t.shifts)) continue - const userId = String(t.user_id) - const empName = userNameMap[userId] || `Employee ${userId}` + for (const s of shifts) { + if (s.leave_request_id != null) continue // exclude leave shifts - for (const sh of t.shifts) { - // Dedup by shift.id — robust against any overcount from overlapping timesheet records - const shiftId = String(sh.id) - if (seenShiftIds.has(shiftId)) continue - seenShiftIds.add(shiftId) + const deptId = String(s.department_id) + if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue - if (sh.date < from || sh.date > to) continue - if (sh.leave_request_id != null) continue + const date = s.date + const baseCost = parseFloat(s.cost ?? 0) + const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) - const deptId = String(sh.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 cost = parseFloat(sh.cost ?? 0) - const key = `${sh.date}:${deptId}` - if (!byDateDept[key]) { - byDateDept[key] = { - date: sh.date, department_id: deptId, - department_name: deptNameMap[deptId] || deptId, - base_cost: 0, total_cost: 0, shift_count: 0, - } - } - byDateDept[key].base_cost += cost - byDateDept[key].total_cost += cost - byDateDept[key].shift_count += 1 - - if (cost > 0) { - const empKey = `${sh.date}:${deptId}:${userId}` - if (!byDateDeptEmp[empKey]) { - byDateDeptEmp[empKey] = { - date: sh.date, department_id: deptId, employee_id: userId, - employee_name: empName, base_cost: 0, total_cost: 0, shift_count: 0, - } - } - byDateDeptEmp[empKey].base_cost += cost - byDateDeptEmp[empKey].total_cost += cost - byDateDeptEmp[empKey].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 } } @@ -305,28 +291,46 @@ export async function runRollingSync() { return { actualRows, scheduledRows } } -// Fetch a raw shifts list from a path and return a cost summary — used for endpoint comparison +// Fetch shifts from a path and return a cost summary — used for endpoint comparison. +// Reports both shift.cost (wages+allowances) and cost_with_oncosts, with/without leave. async function fetchShiftsAndSummarise(path, locationDeptIds) { try { const shifts = await wfFetchPaged(path) - if (!Array.isArray(shifts) || shifts.length === 0) return { count: 0, baseCost: 0, topDepts: [] } - let baseCost = 0 + if (!Array.isArray(shifts) || shifts.length === 0) return { count: 0, baseCost: 0, oncostTotal: 0, topDepts: [] } + let baseCost = 0, oncostTotal = 0, leaveCount = 0, baseCostExLeave = 0, oncostExLeave = 0 const byDept = {} let sampleShift = null for (const s of shifts) { - const deptId = String(s.department_id ?? 'unknown') + const deptId = String(s.department_id ?? 'unknown') if (locationDeptIds && !locationDeptIds.has(deptId)) continue - const cost = parseFloat(s.cost ?? 0) - baseCost += cost - if (!sampleShift && cost > 0) sampleShift = s - if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, cost: 0, count: 0 } - byDept[deptId].cost += cost + const isLeave = s.leave_request_id != null + const cost = parseFloat(s.cost ?? 0) + const oncost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) + baseCost += cost + oncostTotal += oncost + if (!sampleShift && cost > 0 && !isLeave) sampleShift = s + if (isLeave) { leaveCount++; continue } + baseCostExLeave += cost + oncostExLeave += oncost + if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, baseCost: 0, oncost: 0, count: 0 } + byDept[deptId].baseCost += cost + byDept[deptId].oncost += oncost byDept[deptId].count++ } const topDepts = Object.entries(byDept) - .map(([id, v]) => ({ id, name: v.name, cost: +v.cost.toFixed(2), count: v.count })) - .sort((a, b) => b.cost - a.cost) - const result = { rawCount: shifts.length, filteredCount: Object.values(byDept).reduce((s, v) => s + v.count, 0), baseCost: +baseCost.toFixed(2), topDepts: topDepts.slice(0, 15) } + .map(([id, v]) => ({ id, name: v.name, baseCost: +v.baseCost.toFixed(2), oncost: +v.oncost.toFixed(2), count: v.count })) + .sort((a, b) => b.oncost - a.oncost) + const result = { + rawCount: shifts.length, + leaveCount, + nonLeaveCount: shifts.length - leaveCount, + baseCost: +baseCost.toFixed(2), + oncostTotal: +oncostTotal.toFixed(2), + baseCostExLeave: +baseCostExLeave.toFixed(2), + oncostExLeave: +oncostExLeave.toFixed(2), + topDepts: topDepts.slice(0, 15), + note: 'oncostExLeave = cost_with_oncosts for non-leave shifts — compare to WF Timesheet exc.leave figure', + } if (sampleShift) result.sampleShiftFields = Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== '')) return result } catch (err) {