Switch syncActuals to timesheets endpoint — PBI-matched method
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 <noreply@anthropic.com>
This commit is contained in:
parent
74024f554f
commit
2690ee149f
1 changed files with 160 additions and 164 deletions
|
|
@ -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,59 +76,91 @@ 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
|
||||
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] || 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,
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of Object.values(byDateDept)) {
|
||||
await pool.query(
|
||||
|
|
@ -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
|
||||
let sampleShift = null
|
||||
for (const s of shifts) {
|
||||
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
|
||||
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
|
||||
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.',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue