diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index a2f5ddc..01e204b 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -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 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, - baseCost: +baseCost.toFixed(2), - oncostTotal: +oncostTotal.toFixed(2), + // 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' }, + location_id: locationId, + location_dept_count: locationDeptIds ? locationDeptIds.size : 'all', + 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, },