From c15147b2989fb73dfba4ffd3e95097e12a73a043 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 13:43:19 +0000 Subject: [PATCH] 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 --- backend/src/lib/workforce.js | 71 +++++++++++++++++++++++++++++++++++- backend/src/routes/sync.js | 15 +++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index ab251bd..ddbc7b5 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -40,7 +40,7 @@ async function wfFetchPaged(path) { const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`) const items = Array.isArray(data) ? data - : (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? []) + : (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? data.timesheets ?? []) results.push(...items) if (items.length < 100) break page++ @@ -258,6 +258,75 @@ export async function runRollingSync() { 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) { const today = new Date() const endDate = new Date(today) diff --git a/backend/src/routes/sync.js b/backend/src/routes/sync.js index b2397fc..449a576 100644 --- a/backend/src/routes/sync.js +++ b/backend/src/routes/sync.js @@ -1,6 +1,6 @@ import { requireAuth, requireCap } from '../auth.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' let _backfillAbort = null @@ -66,6 +66,19 @@ export async function syncRoutes(fastify) { 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) fastify.get('/api/departments', { preHandler: requireCap('settings') }, async (_, reply) => { try {