From 4cba6a2ffa0dd37a0c29af7885bc21374bd13f44 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 13:48:42 +0000 Subject: [PATCH] Expand compare diagnostic: location filter variants + per-day timesheets fallback Adds four endpoint variants (A-D) to isolate the location filter discrepancy: report_location_id vs location_id vs no filter vs timesheets range. If the timesheets range 404s, auto-falls back to per-day /timesheets/on/{date} fetch (variant E) so we can see timesheet totals regardless of API version. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/lib/workforce.js | 98 +++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index ddbc7b5..ce9e710 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -287,43 +287,95 @@ async function fetchAndSummarise(path) { } } +// Fetch per-date timesheets for every day in [from, to] and aggregate. +// Slow (one request per day) but works when the range endpoint returns 404. +async function fetchTimesheetsByDay(from, to, locationId) { + const start = new Date(from + 'T00:00:00') + const end = new Date(to + 'T00:00:00') + let baseCost = 0, totalCost = 0, count = 0 + const byDept = {} + const sampleFields = null + + let cur = new Date(start) + while (cur <= end) { + const dateStr = cur.toISOString().slice(0, 10) + let path = `/api/v2/timesheets/on/${dateStr}?show_costs=true&include_oncosts=true` + if (locationId) path += `&location_id=${locationId}` + try { + const items = await wfFetchPaged(path) + if (Array.isArray(items)) { + for (const s of items) { + const base = parseFloat(s.cost ?? 0) + const total = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) + baseCost += base + totalCost += total + count++ + 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++ + } + } + } catch { /* ignore per-day errors */ } + cur.setDate(cur.getDate() + 1) + } + const topDepts = 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, baseCost: +baseCost.toFixed(2), totalCost: +totalCost.toFixed(2), topDepts: topDepts.slice(0, 15) } +} + // 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}` + // A: Shifts with report_location_id (current sync method) + let shiftsReportLoc = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` + if (locationId) shiftsReportLoc += `&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}` + // B: Shifts with location_id (alternative location param) + let shiftsLocId = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` + if (locationId) shiftsLocId += `&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}` + // C: Shifts with NO location filter — catches all shifts regardless of location assignment + const shiftsNoFilter = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` - // 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}` + // D: Timesheets range endpoint (may 404 on some WF versions) + let tsRange = `/api/v2/timesheets?from=${from}&to=${to}&show_costs=true&include_oncosts=true` + if (locationId) tsRange += `&location_id=${locationId}` - const [s_all, s_approved, ts_all, ts_approved] = await Promise.all([ - fetchAndSummarise(shiftsAll), - fetchAndSummarise(shiftsApproved), - fetchAndSummarise(timesheets), - fetchAndSummarise(timesheetsApproved), + const [a, b, c, d] = await Promise.all([ + fetchAndSummarise(shiftsReportLoc), + fetchAndSummarise(shiftsLocId), + fetchAndSummarise(shiftsNoFilter), + fetchAndSummarise(tsRange), ]) + // E: Per-day timesheets (slow — one request per day — only run if range endpoint 404'd) + let e = null + if (d.error) { + e = await fetchTimesheetsByDay(from, to, locationId).catch(err => ({ error: err.message })) + } + 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.', + current_method: 'A: shifts with report_location_id', + A_shifts_report_location_id: a, + B_shifts_location_id: b, + C_shifts_no_location_filter: c, + D_timesheets_range: d, + E_timesheets_per_day: e ?? { skipped: 'D succeeded — per-day fetch not needed' }, + wf_report_reference: { + note: 'Paste WF Cost by Location and Team report totals here for comparison', + scheduled_exc_leave: null, + timesheet_exc_leave: null, + leave_cost: null, + }, + note: 'baseCost = wage only; totalCost = with estimated employer on-costs. Match totalCost against the WF Timesheet Cost column to find the right endpoint.', } }