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:
parent
a2b46178a5
commit
241524c9ce
1 changed files with 79 additions and 16 deletions
|
|
@ -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.
|
||||
// This is the "WF by Location and Team" method — costs by which dept the shift was worked in.
|
||||
// Returns both shift.cost (base wages+allowances) and cost_with_oncosts (+ employer on-costs).
|
||||
// Also records timesheet-level cost fields (cost, cost_with_oncosts on the parent object).
|
||||
async function fetchTimesheetsByDay(from, to, locationDeptIds) {
|
||||
const allDates = buildDateRange(from, to)
|
||||
const perDay = await Promise.all(allDates.map(fetchTimesheetsForDate))
|
||||
|
||||
const seenShiftIds = new Set()
|
||||
const seenTimesheetIds = new Set()
|
||||
let totalShifts = 0, leaveShifts = 0, filteredShifts = 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 = {}
|
||||
let sampleShift = null
|
||||
let sampleShift = null, sampleTimesheet = null
|
||||
|
||||
for (const timesheets of perDay) {
|
||||
for (const t of timesheets) {
|
||||
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) {
|
||||
const shiftId = String(sh.id)
|
||||
if (seenShiftIds.has(shiftId)) continue
|
||||
|
|
@ -387,16 +413,32 @@ async function fetchTimesheetsByDay(from, to, locationDeptIds) {
|
|||
return {
|
||||
dates: allDates.length,
|
||||
uniqueShifts: seenShiftIds.size,
|
||||
uniqueTimesheets: seenTimesheetIds.size,
|
||||
totalShifts,
|
||||
leaveShifts,
|
||||
filteredShifts,
|
||||
// Shift-level sums (shift.cost and shift.cost_with_oncosts from nested shifts[])
|
||||
shiftLevel: {
|
||||
baseCost: +baseCost.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),
|
||||
sampleShiftFields: sampleShift
|
||||
? Object.fromEntries(Object.entries(sampleShift).filter(([, v]) => v !== null && v !== undefined && v !== ''))
|
||||
: 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 allDepts = await wfFetchPaged('/api/v2/departments')
|
||||
// Primary filter: departments where location_id matches the hotel
|
||||
const locationDeptIds = locationId
|
||||
? new Set(allDepts.filter(d => String(d.location_id) === locationId).map(d => String(d.id)))
|
||||
: 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`
|
||||
+ (locationId ? `&report_location_id=${locationId}` : '')
|
||||
|
||||
// B: Per-day timesheets, dedup by shift.id, filter by shift.department_id → location.
|
||||
// Both base cost and cost_with_oncosts reported.
|
||||
// Target: match WF "Cost by Location and Team" Timesheet figure.
|
||||
const [a, b] = await Promise.all([
|
||||
// A2: Same shifts, but client-side filter broadened to report_location_id depts
|
||||
// A3: Same shifts, no client-side dept filter (trust the API filter entirely)
|
||||
|
||||
// B: Per-day timesheets + shift.id dedup, filtered by locationDeptIds
|
||||
const [a, a2, a3, b] = await Promise.all([
|
||||
fetchShiftsAndSummarise(shiftsReportLoc, locationDeptIds),
|
||||
fetchShiftsAndSummarise(shiftsReportLoc, reportLocationDeptIds),
|
||||
fetchShiftsAndSummarise(shiftsReportLoc, null), // trust API filter entirely
|
||||
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 {
|
||||
from, to,
|
||||
location_id: locationId,
|
||||
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,
|
||||
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,
|
||||
leave_accrual: 8737.28,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue