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 {