Compare: add timesheet-level cost fields + A2/A3 filter variants to diagnose £4.7k gap

B now also sums cost/cost_with_oncosts at the timesheet parent object level (not just
nested shifts[]) — reveals whether PBI is using a timesheet-level field we're missing.

compareEndpoints now returns:
- A  (existing): shifts API + report_location_id + client dept filter by location_id
- A2 (new): same shifts, client filter broadened to location_id OR report_location_id
- A3 (new): same shifts, no client-side dept filter (trust API filter only)
- extra_depts_via_report_location_id: departments in A2 but not A
- B: per-day timesheets with both shiftLevel and timesheetLevel cost sums

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 15:03:17 +00:00
parent a2b46178a5
commit 241524c9ce

View file

@ -339,21 +339,47 @@ async function fetchShiftsAndSummarise(path, locationDeptIds) {
} }
// Fetch all timesheets for [from, to] per day, dedup by shift.id, filter by shift.department_id. // Fetch all timesheets for [from, to] per day, dedup by shift.id, filter by shift.department_id.
// This is the "WF by Location and Team" method — costs by which dept the shift was worked in. // Also records timesheet-level cost fields (cost, cost_with_oncosts on the parent object).
// Returns both shift.cost (base wages+allowances) and cost_with_oncosts (+ employer on-costs).
async function fetchTimesheetsByDay(from, to, locationDeptIds) { async function fetchTimesheetsByDay(from, to, locationDeptIds) {
const allDates = buildDateRange(from, to) const allDates = buildDateRange(from, to)
const perDay = await Promise.all(allDates.map(fetchTimesheetsForDate)) const perDay = await Promise.all(allDates.map(fetchTimesheetsForDate))
const seenShiftIds = new Set() const seenShiftIds = new Set()
const seenTimesheetIds = new Set()
let totalShifts = 0, leaveShifts = 0, filteredShifts = 0 let totalShifts = 0, leaveShifts = 0, filteredShifts = 0
let baseCost = 0, oncostTotal = 0 let baseCost = 0, oncostTotal = 0
// Timesheet-level sums — if timesheet object has cost/cost_with_oncosts at root level
let tsBaseCost = 0, tsOncostTotal = 0, tsLeaveBaseCost = 0, tsLeaveOncostTotal = 0
const byDept = {} const byDept = {}
let sampleShift = null let sampleShift = null, sampleTimesheet = null
for (const timesheets of perDay) { for (const timesheets of perDay) {
for (const t of timesheets) { for (const t of timesheets) {
if (!Array.isArray(t.shifts)) continue if (!Array.isArray(t.shifts)) continue
// --- Timesheet-level cost fields (deduplicated per timesheet) ---
if (!seenTimesheetIds.has(String(t.id))) {
seenTimesheetIds.add(String(t.id))
// Check if any shift in this timesheet falls in our location/date window
const hasLocationShift = t.shifts.some(sh => {
if (!sh.date || sh.date < from || sh.date > to) return false
if (!locationDeptIds) return true
return locationDeptIds.has(String(sh.department_id ?? 'unknown'))
})
if (hasLocationShift) {
const tCost = parseFloat(t.cost ?? 0)
const tOncost = parseFloat(t.cost_with_oncosts ?? t.cost ?? 0)
const isLeaveTs = t.leave_request_id != null
if (isLeaveTs) { tsLeaveBaseCost += tCost; tsLeaveOncostTotal += tOncost }
else { tsBaseCost += tCost; tsOncostTotal += tOncost }
if (!sampleTimesheet) {
const { shifts: _s, ...rest } = t
sampleTimesheet = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== null && v !== undefined && v !== ''))
}
}
}
// --- Shift-level cost fields (deduplicated per shift) ---
for (const sh of t.shifts) { for (const sh of t.shifts) {
const shiftId = String(sh.id) const shiftId = String(sh.id)
if (seenShiftIds.has(shiftId)) continue if (seenShiftIds.has(shiftId)) continue
@ -387,16 +413,32 @@ async function fetchTimesheetsByDay(from, to, locationDeptIds) {
return { return {
dates: allDates.length, dates: allDates.length,
uniqueShifts: seenShiftIds.size, uniqueShifts: seenShiftIds.size,
uniqueTimesheets: seenTimesheetIds.size,
totalShifts, totalShifts,
leaveShifts, leaveShifts,
filteredShifts, filteredShifts,
// Shift-level sums (shift.cost and shift.cost_with_oncosts from nested shifts[])
shiftLevel: {
baseCost: +baseCost.toFixed(2), baseCost: +baseCost.toFixed(2),
oncostTotal: +oncostTotal.toFixed(2), oncostTotal: +oncostTotal.toFixed(2),
note: 'shift.cost (wages+allowances) and shift.cost_with_oncosts — timesheets endpoint nested shifts do NOT include cost_with_oncosts',
},
// Timesheet-level sums (cost/cost_with_oncosts on the parent timesheet object, if present)
timesheetLevel: {
baseCost: +tsBaseCost.toFixed(2),
oncostTotal: +tsOncostTotal.toFixed(2),
leaveBaseCost: +tsLeaveBaseCost.toFixed(2),
leaveOncostTotal: +tsLeaveOncostTotal.toFixed(2),
baseCostExLeave: +tsBaseCost.toFixed(2),
oncostExLeave: +tsOncostTotal.toFixed(2),
note: 'cost/cost_with_oncosts on the timesheet parent object — may differ from summing nested shifts[]',
},
topDepts: topDepts.slice(0, 15), topDepts: topDepts.slice(0, 15),
sampleShiftFields: sampleShift sampleShiftFields: sampleShift
? Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== '')) ? Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== ''))
: null, : null,
note: 'baseCost = shift.cost (wages+allowances). oncostTotal = shift.cost_with_oncosts (+ employer pension + leave accrual). Target: WF Timesheet exc.leave inc.allowances', sampleTimesheetFields: sampleTimesheet,
note: 'B: timesheets/on/{date} per day. shiftLevel = sum of nested shifts[]. timesheetLevel = cost on the timesheet parent object.',
} }
} }
@ -407,30 +449,51 @@ export async function compareEndpoints(from, to) {
const locationId = creds.location_id ? String(creds.location_id) : null const locationId = creds.location_id ? String(creds.location_id) : null
const allDepts = await wfFetchPaged('/api/v2/departments') const allDepts = await wfFetchPaged('/api/v2/departments')
// Primary filter: departments where location_id matches the hotel
const locationDeptIds = locationId const locationDeptIds = locationId
? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id))) ? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id)))
: null : null
// Broader filter: departments where location_id OR report_location_id matches
const reportLocationDeptIds = locationId
? new Set(allDepts.filter(d =>
String(d.location_id) === locationId || String(d.report_location_id) === locationId
).map(d => String(d.id)))
: null
// A: Shifts endpoint with report_location_id (old method) // A: Shifts endpoint with report_location_id (API-filtered) — also client-side filtered by locationDeptIds
const shiftsReportLoc = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true` const shiftsReportLoc = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
+ (locationId ? `&report_location_id=${locationId}` : '') + (locationId ? `&report_location_id=${locationId}` : '')
// B: Per-day timesheets, dedup by shift.id, filter by shift.department_id → location. // A2: Same shifts, but client-side filter broadened to report_location_id depts
// Both base cost and cost_with_oncosts reported. // A3: Same shifts, no client-side dept filter (trust the API filter entirely)
// Target: match WF "Cost by Location and Team" Timesheet figure.
const [a, b] = await Promise.all([ // B: Per-day timesheets + shift.id dedup, filtered by locationDeptIds
const [a, a2, a3, b] = await Promise.all([
fetchShiftsAndSummarise(shiftsReportLoc, locationDeptIds), fetchShiftsAndSummarise(shiftsReportLoc, locationDeptIds),
fetchShiftsAndSummarise(shiftsReportLoc, reportLocationDeptIds),
fetchShiftsAndSummarise(shiftsReportLoc, null), // trust API filter entirely
fetchTimesheetsByDay(from, to, locationDeptIds), fetchTimesheetsByDay(from, to, locationDeptIds),
]) ])
// Dept breakdown for report_location_id depts — to see which extra depts A2 picks up vs A
const extraDepts = reportLocationDeptIds && locationDeptIds
? allDepts
.filter(d => reportLocationDeptIds.has(String(d.id)) && !locationDeptIds.has(String(d.id)))
.map(d => ({ id: String(d.id), name: d.name, location_id: d.location_id, report_location_id: d.report_location_id }))
: []
return { return {
from, to, from, to,
location_id: locationId, location_id: locationId,
location_dept_count: locationDeptIds ? locationDeptIds.size : 'all', location_dept_count: locationDeptIds ? locationDeptIds.size : 'all',
A_shifts_report_location_id: { ...a, note: 'Old method — shifts endpoint with report_location_id filter' }, report_location_dept_count: reportLocationDeptIds ? reportLocationDeptIds.size : 'all',
extra_depts_via_report_location_id: extraDepts,
A_shifts_api_filtered_dept_filtered: { ...a, note: 'shifts + report_location_id API filter + client dept filter (location_id match)' },
A2_shifts_api_filtered_report_dept_filter: { ...a2, note: 'shifts + report_location_id API filter + client dept filter (location_id OR report_location_id match)' },
A3_shifts_api_filtered_no_client_filter: { ...a3, note: 'shifts + report_location_id API filter only — no client-side dept filter' },
B_timesheets_per_day_dept_filter: b, B_timesheets_per_day_dept_filter: b,
wf_report_reference: { wf_report_reference: {
note: 'Compare B.baseCost or B.oncostTotal against WF "Timesheet exc. leave inc. allowances"', note: 'Target: WF "Cost by Location and Team" — Timesheet exc. leave inc. allowances',
timesheet_exc_leave: 51005.72, timesheet_exc_leave: 51005.72,
leave_accrual: 8737.28, leave_accrual: 8737.28,
}, },