Add endpoint comparison diagnostic for cost figure discrepancies

GET /wages/api/sync/compare?from=YYYY-MM-DD&to=YYYY-MM-DD returns cost totals
from four Workforce endpoint variants side-by-side: shifts (all statuses),
shifts (APPROVED only), timesheets (all statuses), timesheets (APPROVED only).
Useful for identifying which endpoint matches the expected payroll figure.
Also adds data.timesheets to wfFetchPaged response unwrap chain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 13:43:19 +00:00
parent 3a068ece23
commit c15147b298
2 changed files with 84 additions and 2 deletions

View file

@ -40,7 +40,7 @@ async function wfFetchPaged(path) {
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`) const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
const items = Array.isArray(data) const items = Array.isArray(data)
? data ? data
: (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? []) : (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? data.timesheets ?? [])
results.push(...items) results.push(...items)
if (items.length < 100) break if (items.length < 100) break
page++ page++
@ -258,6 +258,75 @@ export async function runRollingSync() {
return { actualRows, scheduledRows } return { actualRows, scheduledRows }
} }
// Fetch a raw item list from a path and return a cost summary — used for endpoint comparison
async function fetchAndSummarise(path) {
try {
const items = await wfFetchPaged(path)
if (!Array.isArray(items) || items.length === 0) return { count: 0, baseCost: 0, totalCost: 0, byDept: {} }
let baseCost = 0, totalCost = 0
const byDept = {}
for (const s of items) {
// timesheets may use start (unix ts) as the date source; shifts have date directly
const base = parseFloat(s.cost ?? 0)
const total = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
baseCost += base
totalCost += total
const deptId = String(s.department_id ?? 'unknown')
const deptName = s.department_name || deptId
if (!byDept[deptId]) byDept[deptId] = { name: deptName, baseCost: 0, totalCost: 0, count: 0 }
byDept[deptId].baseCost += base
byDept[deptId].totalCost += total
byDept[deptId].count++
}
const deptList = Object.entries(byDept)
.map(([id, v]) => ({ id, name: v.name, baseCost: +v.baseCost.toFixed(2), totalCost: +v.totalCost.toFixed(2), count: v.count }))
.sort((a, b) => b.totalCost - a.totalCost)
return { count: items.length, baseCost: +baseCost.toFixed(2), totalCost: +totalCost.toFixed(2), topDepts: deptList.slice(0, 15) }
} catch (err) {
return { error: err.message }
}
}
// Compare cost totals from all available endpoints for a given date range.
// Call GET /api/sync/compare?from=YYYY-MM-DD&to=YYYY-MM-DD to diagnose figure discrepancies.
export async function compareEndpoints(from, to) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
// Shifts — all statuses (current sync method)
let shiftsAll = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) shiftsAll += `&report_location_id=${locationId}`
// Shifts — APPROVED only
let shiftsApproved = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true&status=APPROVED`
if (locationId) shiftsApproved += `&report_location_id=${locationId}`
// Timesheets — range endpoint (closest to payroll actuals)
let timesheets = `/api/v2/timesheets?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) timesheets += `&location_id=${locationId}`
// Timesheets — APPROVED only
let timesheetsApproved = `/api/v2/timesheets?from=${from}&to=${to}&show_costs=true&include_oncosts=true&status=APPROVED`
if (locationId) timesheetsApproved += `&location_id=${locationId}`
const [s_all, s_approved, ts_all, ts_approved] = await Promise.all([
fetchAndSummarise(shiftsAll),
fetchAndSummarise(shiftsApproved),
fetchAndSummarise(timesheets),
fetchAndSummarise(timesheetsApproved),
])
return {
from, to,
current_method: 'shifts_all_statuses',
shifts_all_statuses: s_all,
shifts_approved_only: s_approved,
timesheets_all_statuses: ts_all,
timesheets_approved_only: ts_approved,
note: 'baseCost = wage only; totalCost = with estimated employer on-costs. Switch current_method to whichever matches your expected payroll figure.',
}
}
export async function runBackfill(onProgress, signal) { export async function runBackfill(onProgress, signal) {
const today = new Date() const today = new Date()
const endDate = new Date(today) const endDate = new Date(today)

View file

@ -1,6 +1,6 @@
import { requireAuth, requireCap } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig } from '../db.js' import { getConfig, setConfig } from '../db.js'
import { runRollingSync, runBackfill } from '../lib/workforce.js' import { runRollingSync, runBackfill, compareEndpoints } from '../lib/workforce.js'
import { fetchAllDepartments } from '../lib/workforce.js' import { fetchAllDepartments } from '../lib/workforce.js'
let _backfillAbort = null let _backfillAbort = null
@ -66,6 +66,19 @@ export async function syncRoutes(fastify) {
return { ok: true } return { ok: true }
}) })
// Compare cost totals from all available Workforce endpoints for a date range.
// Useful for diagnosing figure discrepancies between shifts vs timesheets.
// Usage: GET /wages/api/sync/compare?from=2025-06-01&to=2025-06-30
fastify.get('/api/sync/compare', { preHandler: requireCap('sync') }, async (req, reply) => {
const { from, to } = req.query
if (!from || !to) return reply.status(400).send({ error: 'from and to are required (YYYY-MM-DD)' })
try {
return await compareEndpoints(from, to)
} catch (err) {
return reply.status(500).send({ error: err.message })
}
})
// Fetch departments from Workforce (for the settings filter) // Fetch departments from Workforce (for the settings filter)
fastify.get('/api/departments', { preHandler: requireCap('settings') }, async (_, reply) => { fastify.get('/api/departments', { preHandler: requireCap('settings') }, async (_, reply) => {
try { try {