Inspect cost field names on timesheets + shifts for compare diagnostic

Timesheet per-day fetch returns 3,269 records with £0 — cost fields are
named differently. Adds tsBaseCost()/tsTotalCost() helpers that try multiple
field names (cost, timesheet_cost, base_cost, employee_cost, etc.). Adds
sampleFields to E result so we can see the actual field names on a live
record. Also samples a shift record (variant A) for confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 13:56:22 +00:00
parent 265df96a63
commit 74024f554f

View file

@ -259,18 +259,19 @@ export async function runRollingSync() {
} }
// Fetch a raw item list from a path and return a cost summary — used for endpoint comparison // Fetch a raw item list from a path and return a cost summary — used for endpoint comparison
async function fetchAndSummarise(path) { async function fetchAndSummarise(path, includeSample = false) {
try { try {
const items = await wfFetchPaged(path) const items = await wfFetchPaged(path)
if (!Array.isArray(items) || items.length === 0) return { count: 0, baseCost: 0, totalCost: 0, byDept: {} } if (!Array.isArray(items) || items.length === 0) return { count: 0, baseCost: 0, totalCost: 0, topDepts: [] }
let baseCost = 0, totalCost = 0 let baseCost = 0, totalCost = 0
const byDept = {} const byDept = {}
let sampleRecord = null
for (const s of items) { 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 base = parseFloat(s.cost ?? 0)
const total = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) const total = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
baseCost += base baseCost += base
totalCost += total totalCost += total
if (includeSample && !sampleRecord && base > 0) sampleRecord = s
const deptId = String(s.department_id ?? 'unknown') const deptId = String(s.department_id ?? 'unknown')
const deptName = s.department_name || deptId const deptName = s.department_name || deptId
if (!byDept[deptId]) byDept[deptId] = { name: deptName, baseCost: 0, totalCost: 0, count: 0 } if (!byDept[deptId]) byDept[deptId] = { name: deptName, baseCost: 0, totalCost: 0, count: 0 }
@ -281,19 +282,39 @@ async function fetchAndSummarise(path) {
const deptList = Object.entries(byDept) 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 })) .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) .sort((a, b) => b.totalCost - a.totalCost)
return { count: items.length, baseCost: +baseCost.toFixed(2), totalCost: +totalCost.toFixed(2), topDepts: deptList.slice(0, 15) } 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 !== ''))
}
return result
} catch (err) { } catch (err) {
return { error: err.message } 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 and aggregate. // 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. // Uses /api/v2/timesheets/on/{date} when the range endpoint returns 404.
async function fetchTimesheetsByDay(from, to, locationId) { async function fetchTimesheetsByDay(from, to, locationId) {
const start = new Date(from + 'T00:00:00') const start = new Date(from + 'T00:00:00')
const end = new Date(to + 'T00:00:00') const end = new Date(to + 'T00:00:00')
// Build list of all dates in range
const dates = [] const dates = []
let cur = new Date(start) let cur = new Date(start)
while (cur <= end) { while (cur <= end) {
@ -301,7 +322,6 @@ async function fetchTimesheetsByDay(from, to, locationId) {
cur.setDate(cur.getDate() + 1) cur.setDate(cur.getDate() + 1)
} }
// Fire all days in parallel — ~30 concurrent requests, completes in ~3-5s
const perDay = await Promise.all(dates.map(async dateStr => { const perDay = await Promise.all(dates.map(async dateStr => {
let path = `/api/v2/timesheets/on/${dateStr}?show_costs=true&include_oncosts=true` let path = `/api/v2/timesheets/on/${dateStr}?show_costs=true&include_oncosts=true`
if (locationId) path += `&location_id=${locationId}` if (locationId) path += `&location_id=${locationId}`
@ -310,12 +330,15 @@ async function fetchTimesheetsByDay(from, to, locationId) {
let baseCost = 0, totalCost = 0, count = 0 let baseCost = 0, totalCost = 0, count = 0
const byDept = {} const byDept = {}
let sampleRecord = null // first record with cost > 0 for field inspection
for (const items of perDay) { for (const items of perDay) {
if (!Array.isArray(items)) continue if (!Array.isArray(items)) continue
for (const s of items) { for (const s of items) {
const base = parseFloat(s.cost ?? 0) const base = tsBaseCost(s)
const total = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) const total = tsTotalCost(s, base)
baseCost += base; totalCost += total; count++ baseCost += base; totalCost += total; count++
if (!sampleRecord && base > 0) sampleRecord = s
const deptId = String(s.department_id ?? 'unknown') const deptId = String(s.department_id ?? 'unknown')
if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, baseCost: 0, totalCost: 0, count: 0 } if (!byDept[deptId]) byDept[deptId] = { name: s.department_name || deptId, baseCost: 0, totalCost: 0, count: 0 }
byDept[deptId].baseCost += base byDept[deptId].baseCost += base
@ -326,7 +349,13 @@ async function fetchTimesheetsByDay(from, to, locationId) {
const topDepts = Object.entries(byDept) 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 })) .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) .sort((a, b) => b.totalCost - a.totalCost)
return { count, baseCost: +baseCost.toFixed(2), totalCost: +totalCost.toFixed(2), topDepts: topDepts.slice(0, 15) }
// 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 }
} }
// Compare cost totals from all available endpoints for a given date range. // Compare cost totals from all available endpoints for a given date range.
@ -351,7 +380,7 @@ export async function compareEndpoints(from, to) {
if (locationId) tsRange += `&location_id=${locationId}` if (locationId) tsRange += `&location_id=${locationId}`
const [a, b, c, d] = await Promise.all([ const [a, b, c, d] = await Promise.all([
fetchAndSummarise(shiftsReportLoc), fetchAndSummarise(shiftsReportLoc, true), // sample fields from shift object
fetchAndSummarise(shiftsLocId), fetchAndSummarise(shiftsLocId),
fetchAndSummarise(shiftsNoFilter), fetchAndSummarise(shiftsNoFilter),
fetchAndSummarise(tsRange), fetchAndSummarise(tsRange),