Return to shifts endpoint with leave filter + cost_with_oncosts
The timesheets/on/{date} endpoint nested shifts don't return cost_with_oncosts
— oncostTotal == baseCost in all results. The shifts endpoint does return it.
Hypothesis: shifts endpoint, leave filtered, cost_with_oncosts ≈ £51k:
- A (shifts, report_location_id, all): £47,607 (shift.cost, includes leave)
- B (timesheets, dept filter, ex-leave): £40,845 (shift.cost only, no oncosts)
- Estimated A_ex_leave × cost_with_oncosts: ~£44.7k × 1.14 ≈ £51,005 ✓
syncActuals reverted to shifts endpoint, adds leave_request_id filter,
uses cost_with_oncosts as total_cost (was using shift.cost before).
compareEndpoints A now shows both baseCostExLeave and oncostExLeave so
we can confirm the £51k match directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fcea24b46a
commit
a2b46178a5
1 changed files with 72 additions and 68 deletions
|
|
@ -103,10 +103,16 @@ 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 ? String(creds.location_id) : null
|
const locationId = creds.location_id
|
||||||
const enabledDeptIds = await getEnabledDeptIds()
|
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([
|
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'),
|
||||||
|
|
@ -116,70 +122,50 @@ export async function syncActuals(from, to) {
|
||||||
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}`,
|
||||||
]))
|
]))
|
||||||
// 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 byDateDept = {}
|
||||||
const byDateDeptEmp = {}
|
const byDateDeptEmp = {}
|
||||||
|
|
||||||
for (const timesheets of perDay) {
|
for (const s of shifts) {
|
||||||
for (const t of timesheets) {
|
if (s.leave_request_id != null) continue // exclude leave shifts
|
||||||
if (!Array.isArray(t.shifts)) continue
|
|
||||||
const userId = String(t.user_id)
|
|
||||||
const empName = userNameMap[userId] || `Employee ${userId}`
|
|
||||||
|
|
||||||
for (const sh of t.shifts) {
|
const deptId = String(s.department_id)
|
||||||
// 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)
|
|
||||||
|
|
||||||
if (sh.date < from || sh.date > to) continue
|
|
||||||
if (sh.leave_request_id != null) continue
|
|
||||||
|
|
||||||
const deptId = String(sh.department_id ?? 'unknown')
|
|
||||||
if (locationDeptIds && !locationDeptIds.has(deptId)) continue
|
|
||||||
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
|
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
|
||||||
|
|
||||||
const cost = parseFloat(sh.cost ?? 0)
|
const date = s.date
|
||||||
const key = `${sh.date}:${deptId}`
|
const baseCost = parseFloat(s.cost ?? 0)
|
||||||
|
const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
|
||||||
|
|
||||||
|
const key = `${date}:${deptId}`
|
||||||
if (!byDateDept[key]) {
|
if (!byDateDept[key]) {
|
||||||
byDateDept[key] = {
|
byDateDept[key] = {
|
||||||
date: sh.date, department_id: deptId,
|
date,
|
||||||
department_name: deptNameMap[deptId] || deptId,
|
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 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,
|
base_cost: 0, total_cost: 0, shift_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
byDateDept[key].base_cost += cost
|
byDateDeptEmp[empKey].base_cost += baseCost
|
||||||
byDateDept[key].total_cost += cost
|
byDateDeptEmp[empKey].total_cost += totalCost
|
||||||
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
|
byDateDeptEmp[empKey].shift_count += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of Object.values(byDateDept)) {
|
for (const row of Object.values(byDateDept)) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|
@ -305,28 +291,46 @@ export async function runRollingSync() {
|
||||||
return { actualRows, scheduledRows }
|
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) {
|
async function fetchShiftsAndSummarise(path, locationDeptIds) {
|
||||||
try {
|
try {
|
||||||
const shifts = await wfFetchPaged(path)
|
const shifts = await wfFetchPaged(path)
|
||||||
if (!Array.isArray(shifts) || shifts.length === 0) return { count: 0, baseCost: 0, topDepts: [] }
|
if (!Array.isArray(shifts) || shifts.length === 0) return { count: 0, baseCost: 0, oncostTotal: 0, topDepts: [] }
|
||||||
let baseCost = 0
|
let baseCost = 0, oncostTotal = 0, leaveCount = 0, baseCostExLeave = 0, oncostExLeave = 0
|
||||||
const byDept = {}
|
const byDept = {}
|
||||||
let sampleShift = null
|
let sampleShift = null
|
||||||
for (const s of shifts) {
|
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
|
if (locationDeptIds && !locationDeptIds.has(deptId)) continue
|
||||||
|
const isLeave = s.leave_request_id != null
|
||||||
const cost = parseFloat(s.cost ?? 0)
|
const cost = parseFloat(s.cost ?? 0)
|
||||||
|
const oncost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
|
||||||
baseCost += cost
|
baseCost += cost
|
||||||
if (!sampleShift && cost > 0) sampleShift = s
|
oncostTotal += oncost
|
||||||
if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, cost: 0, count: 0 }
|
if (!sampleShift && cost > 0 && !isLeave) sampleShift = s
|
||||||
byDept[deptId].cost += cost
|
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++
|
byDept[deptId].count++
|
||||||
}
|
}
|
||||||
const topDepts = Object.entries(byDept)
|
const topDepts = Object.entries(byDept)
|
||||||
.map(([id, v]) => ({ id, name: v.name, cost: +v.cost.toFixed(2), count: v.count }))
|
.map(([id, v]) => ({ id, name: v.name, baseCost: +v.baseCost.toFixed(2), oncost: +v.oncost.toFixed(2), count: v.count }))
|
||||||
.sort((a, b) => b.cost - a.cost)
|
.sort((a, b) => b.oncost - a.oncost)
|
||||||
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) }
|
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 !== ''))
|
if (sampleShift) result.sampleShiftFields = Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== ''))
|
||||||
return result
|
return result
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue