From 2690ee149f81690294fd42e4e2f170b8e296a642 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 14:03:43 +0000 Subject: [PATCH] =?UTF-8?q?Switch=20syncActuals=20to=20timesheets=20endpoi?= =?UTF-8?q?nt=20=E2=80=94=20PBI-matched=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses /api/v2/timesheets/on/{date} per day (no location param), expands nested shifts[], skips leave (leave_request_id!=null), and post-filters to this hotel's departments via location_id match on the departments list. This mirrors exactly what the FD's Power BI query does and should give the correct £51k timesheet figure for June. Also simplifies compareEndpoints to two variants: A (old shifts method) vs B (new timesheets method) so the discrepancy is immediately visible. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/lib/workforce.js | 324 +++++++++++++++++------------------ 1 file changed, 160 insertions(+), 164 deletions(-) diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index eefe092..cb0bc05 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -69,11 +69,6 @@ async function getEnabledDeptIds() { } } -async function getDeptNameMap() { - const depts = await fetchAllDepartments() - return Object.fromEntries(depts.map(d => [d.id, d.name])) -} - async function getUserNameMap() { const all = await wfFetchPaged('/api/v2/users') return Object.fromEntries( @@ -81,57 +76,89 @@ async function getUserNameMap() { ) } +// Build list of YYYY-MM-DD strings between from and to (inclusive) +function dateRange(from, to) { + const dates = [] + const cur = new Date(from + 'T00:00:00') + const end = new Date(to + 'T00:00:00') + while (cur <= end) { dates.push(cur.toISOString().slice(0, 10)); cur.setDate(cur.getDate() + 1) } + return dates +} + +// Fetch timesheets for a single date — no location param (mirrors PBI pattern). +// Each record is { user_id, shifts: [{ department_id, date, cost, leave_request_id, ... }] } +async function fetchTimesheetsForDate(dateStr) { + try { return await wfFetchPaged(`/api/v2/timesheets/on/${dateStr}?show_costs=true`) } + catch { return [] } +} + +// Expand an array of timesheet objects into flat shift rows, filtering out leave and +// optionally filtering by location department IDs and enabled department IDs. +function expandShifts(timesheets, locationDeptIds, enabledDeptIds) { + const rows = [] + for (const t of timesheets) { + if (!Array.isArray(t.shifts)) continue + const userId = String(t.user_id) + for (const sh of t.shifts) { + if (sh.leave_request_id != null) continue // exclude leave accrual shifts + const deptId = String(sh.department_id ?? 'unknown') + if (locationDeptIds && !locationDeptIds.has(deptId)) continue // filter to this hotel + if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue + rows.push({ date: sh.date, userId, deptId, cost: parseFloat(sh.cost ?? 0) }) + } + } + return rows +} + export async function syncActuals(from, to) { const creds = await getWorkforceCreds() - const locationId = creds.location_id + const locationId = creds.location_id ? String(creds.location_id) : null const enabledDeptIds = await getEnabledDeptIds() - let path = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` - if (locationId) path += `&report_location_id=${locationId}` + // Fetch departments + users in parallel — departments used to filter to this hotel's depts + const [allDepts, userNameMap] = await Promise.all([ + wfFetchPaged('/api/v2/departments'), + getUserNameMap(), + ]) + const deptNameMap = Object.fromEntries(allDepts.map(d => [String(d.id), d.name])) + const locationDeptIds = locationId + ? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id))) + : null - const shifts = await wfFetchPaged(path) - const [deptNameMap, userNameMap] = await Promise.all([getDeptNameMap(), getUserNameMap()]) + // Fetch all dates in parallel — same pattern as PBI (no location param on API) + const dates = dateRange(from, to) + const perDay = await Promise.all(dates.map(fetchTimesheetsForDate)) const byDateDept = {} const byDateDeptEmp = {} - for (const s of shifts) { - const deptId = String(s.department_id) - if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue - const date = s.date - const baseCost = parseFloat(s.cost ?? 0) - const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) - - // aggregate by date+dept - const key = `${date}:${deptId}` - if (!byDateDept[key]) { - byDateDept[key] = { - date, - 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 - - // detail by date+dept+employee — include any shift that contributed cost to the aggregate - 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, + for (const timesheets of perDay) { + for (const { date, userId, deptId, cost } of expandShifts(timesheets, locationDeptIds, enabledDeptIds)) { + const key = `${date}:${deptId}` + if (!byDateDept[key]) { + byDateDept[key] = { + date, department_id: deptId, + department_name: deptNameMap[deptId] || deptId, base_cost: 0, total_cost: 0, shift_count: 0, } } - byDateDeptEmp[empKey].base_cost += baseCost - byDateDeptEmp[empKey].total_cost += totalCost - byDateDeptEmp[empKey].shift_count += 1 + byDateDept[key].base_cost += cost + byDateDept[key].total_cost += cost + byDateDept[key].shift_count += 1 + + if (cost > 0) { + const empKey = `${date}:${deptId}:${userId}` + const empName = userNameMap[userId] || `Employee ${userId}` + if (!byDateDeptEmp[empKey]) { + byDateDeptEmp[empKey] = { + 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 + } } } @@ -258,155 +285,124 @@ 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, includeSample = false) { +// Fetch a raw shifts list from a path and return a cost summary — used for endpoint comparison +async function fetchShiftsAndSummarise(path, locationDeptIds) { try { - const items = await wfFetchPaged(path) - if (!Array.isArray(items) || items.length === 0) return { count: 0, baseCost: 0, totalCost: 0, topDepts: [] } - let baseCost = 0, totalCost = 0 + const shifts = await wfFetchPaged(path) + if (!Array.isArray(shifts) || shifts.length === 0) return { count: 0, baseCost: 0, topDepts: [] } + let baseCost = 0 const byDept = {} - let sampleRecord = null - 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 - if (includeSample && !sampleRecord && base > 0) sampleRecord = s - 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 + let sampleShift = null + for (const s of shifts) { + const deptId = String(s.department_id ?? 'unknown') + if (locationDeptIds && !locationDeptIds.has(deptId)) continue + const cost = parseFloat(s.cost ?? 0) + baseCost += cost + if (!sampleShift && cost > 0) sampleShift = s + if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, cost: 0, count: 0 } + byDept[deptId].cost += cost 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) - const result = { count: items.length, baseCost: +baseCost.toFixed(2), totalCost: +totalCost.toFixed(2), topDepts: deptList.slice(0, 15) } - if (includeSample && sampleRecord) { - result.sampleFields = Object.fromEntries(Object.entries(sampleRecord).filter(([, v]) => v !== null && v !== undefined && v !== '')) - } + const topDepts = Object.entries(byDept) + .map(([id, v]) => ({ id, name: v.name, cost: +v.cost.toFixed(2), count: v.count })) + .sort((a, b) => b.cost - a.cost) + 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) } + if (sampleShift) result.sampleShiftFields = Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== '')) return result } catch (err) { return { error: err.message } } } -// Extract cost from a timesheet record — tries multiple field name conventions -function tsBaseCost(s) { - // Try all known field names in order of likelihood - for (const k of ['cost', 'timesheet_cost', 'base_cost', 'employee_cost', 'wage_cost', 'total_cost']) { - const v = parseFloat(s[k]) - if (!isNaN(v) && v > 0) return v - } - return 0 -} -function tsTotalCost(s, base) { - for (const k of ['cost_with_oncosts', 'total_cost_with_oncosts', 'oncost', 'cost_with_on_costs', 'total_oncost']) { - const v = parseFloat(s[k]) - if (!isNaN(v) && v > 0) return v - } - return base // fallback: same as base -} +// Fetch per-date timesheets for every day in [from, to] in parallel. +// Expands nested shifts[], filters leave, optionally filters by location dept IDs. +// This is the method used by PBI — no location param on the API call. +async function fetchTimesheetsByDay(from, to, locationDeptIds) { + const dates = dateRange(from, to) + const perDay = await Promise.all(dates.map(fetchTimesheetsForDate)) -// Fetch per-date timesheets for every day in [from, to] in parallel and aggregate. -// Uses /api/v2/timesheets/on/{date} 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') - - const dates = [] - let cur = new Date(start) - while (cur <= end) { - dates.push(cur.toISOString().slice(0, 10)) - cur.setDate(cur.getDate() + 1) - } - - const perDay = await Promise.all(dates.map(async dateStr => { - let path = `/api/v2/timesheets/on/${dateStr}?show_costs=true&include_oncosts=true` - if (locationId) path += `&location_id=${locationId}` - try { return await wfFetchPaged(path) } catch { return [] } - })) - - let baseCost = 0, totalCost = 0, count = 0 + let totalShifts = 0, filteredShifts = 0, cost = 0 const byDept = {} - let sampleRecord = null // first record with cost > 0 for field inspection + let sampleShift = null - for (const items of perDay) { - if (!Array.isArray(items)) continue - for (const s of items) { - const base = tsBaseCost(s) - const total = tsTotalCost(s, base) - baseCost += base; totalCost += total; count++ - if (!sampleRecord && base > 0) sampleRecord = s - const deptId = String(s.department_id ?? 'unknown') - if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, baseCost: 0, totalCost: 0, count: 0 } - byDept[deptId].baseCost += base - byDept[deptId].totalCost += total - byDept[deptId].count++ + for (const timesheets of perDay) { + if (!Array.isArray(timesheets)) continue + for (const t of timesheets) { + if (!Array.isArray(t.shifts)) continue + for (const sh of t.shifts) { + totalShifts++ + const isLeave = sh.leave_request_id != null + const deptId = String(sh.department_id ?? 'unknown') + const inLoc = !locationDeptIds || locationDeptIds.has(deptId) + if (isLeave || !inLoc) continue + filteredShifts++ + const shCost = parseFloat(sh.cost ?? 0) + cost += shCost + if (!sampleShift && shCost > 0) sampleShift = sh + if (!byDept[deptId]) byDept[deptId] = { cost: 0, count: 0 } + byDept[deptId].cost += shCost + byDept[deptId].count++ + } } } + 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) + .map(([id, v]) => ({ id, cost: +v.cost.toFixed(2), count: v.count })) + .sort((a, b) => b.cost - a.cost) - // Expose all keys+values from a sample record so we can identify the correct cost fields - const sampleFields = sampleRecord - ? Object.fromEntries(Object.entries(sampleRecord).filter(([, v]) => v !== null && v !== undefined && v !== '')) - : null - - return { count, baseCost: +baseCost.toFixed(2), totalCost: +totalCost.toFixed(2), topDepts: topDepts.slice(0, 15), sampleFields } + return { + timesheetRecords: perDay.reduce((s, d) => s + (Array.isArray(d) ? d.length : 0), 0), + totalShifts, + filteredShifts, + cost: +cost.toFixed(2), + topDepts: topDepts.slice(0, 15), + sampleShiftFields: sampleShift + ? Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== '')) + : null, + } } -// 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. +// Compare cost totals from available endpoints for a given date range. +// GET /api/sync/compare?from=YYYY-MM-DD&to=YYYY-MM-DD export async function compareEndpoints(from, to) { const creds = await getWorkforceCreds() - const locationId = creds.location_id + const locationId = creds.location_id ? String(creds.location_id) : null - // 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}` + // Build location dept IDs set for post-fetch filtering (PBI pattern) + const allDepts = await wfFetchPaged('/api/v2/departments') + const locationDeptIds = locationId + ? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id))) + : null - // 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}` + // A: Shifts with report_location_id (old sync method) + const shiftsReportLoc = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` + + (locationId ? `&report_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` - - // 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 [a, b, c, d] = await Promise.all([ - fetchAndSummarise(shiftsReportLoc, true), // sample fields from shift object - fetchAndSummarise(shiftsLocId), - fetchAndSummarise(shiftsNoFilter), - fetchAndSummarise(tsRange), + // B: Timesheets per-day, no API location filter, post-filtered to this hotel's depts + // This is the PBI method and should match WF "Timesheet exc. leave inc. allowances" + const [a, b] = await Promise.all([ + fetchShiftsAndSummarise(shiftsReportLoc, locationDeptIds), + fetchTimesheetsByDay(from, to, locationDeptIds), ]) - // 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: '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, + location_id: locationId, + location_dept_count: locationDeptIds ? locationDeptIds.size : 'all', + A_shifts_report_location_id: { + ...a, + note: 'Old sync method — shifts endpoint with report_location_id filter', + }, + B_timesheets_per_day_no_leave: { + ...b, + note: 'PBI method — timesheets/on/{date}, shifts expanded, leave excluded (leave_request_id!=null), filtered to this hotel\'s dept IDs. Target: WF Timesheet exc.leave inc.allowances', + }, + wf_report_reference: { + note: 'Compare B.cost against WF "Timesheet exc. leave inc. allowances" figure', + scheduled_exc_leave: null, + timesheet_exc_leave_inc_all: null, + leave_accrual: null, }, - note: 'baseCost = wage only; totalCost = with estimated employer on-costs. Match totalCost against the WF Timesheet Cost column to find the right endpoint.', } }