AI Insights: a daily Claude-generated wage cost briefing covering
month-to-date pace vs budget, prior-month/prior-year comparison,
rota-vs-actual variance by department, a rota-informed forecast to
month-end, employee-level anomalies, and wage cost as a % of revenue.
Runs on a configurable daily schedule or on demand, gated by a manual
5-minute rate limit and a daily token budget. Uses the Anthropic key
configured centrally in Portal → Settings → Integrations.
Also includes the rota-vs-repeat-pattern forecast method (published/
draft rota tiers with same-weekday fallback) already built into the
Weekly/Monthly views, and adds a .gitignore for node_modules/dist.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
syncActuals() recomputes the full date range from Workforce on every run
but only ever upserted rows, so a shift that moved department or was
edited/deleted since the last sync left its old wage_actuals_detail row
in place — showing a "ghost" employee in the dept breakdown whose cost
no longer matched the (correctly overwritten) department total.
Now deletes existing wage_actuals/wage_actuals_detail rows for the
synced date range before reinserting, so removed/reassigned shifts
don't linger.
The forecasting API's total.otb is booked-to-date only; total.forecast adds
expected pickup and equals otb for past dates anyway. Using otb-only for
future days understated expected net sales in the current-week/month
forecast sections. Relabelled "(OTB)" cards to "(Forecast)" to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The shifts endpoint omits employer NI from cost_with_oncosts for some employees.
The timesheets/on/{date}?include_oncosts=true endpoint includes NI, matching
WF's native 'Cost by Location and Team' Timesheet exc. leave figure exactly.
June 2026 verification: B.shiftLevel.oncostTotal = £51,005.72 = WF target.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fetches all active users with salary field set whose report_department_id
is in a No 4 department. Reports raw salary field shape, prorated period
cost, and combined (shifts oncostExLeave + prorated salary) to test whether
salaried staff account for the gap vs WF native report.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this parameter, nested shifts[] only return shift.cost with no
cost_with_oncosts field. The FD's PBI uses show_costs=true on timesheets
and may also require include_oncosts=true to get employer oncost fields.
This is the missing parameter that may explain the B.oncostTotal == baseCost issue.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The timesheets/on/{date} response has total_cost at the timesheet level,
not cost or cost_with_oncosts. Previous code was reading undefined and
summing zeros.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
The timesheets/on/{date} endpoint nested shifts don't return cost_with_oncosts
— oncostTotal == baseCost in all results. The shifts endpoint does return it.
Hypothesis: shifts endpoint, leave filtered, cost_with_oncosts ≈ £51k:
- A (shifts, report_location_id, all): £47,607 (shift.cost, includes leave)
- B (timesheets, dept filter, ex-leave): £40,845 (shift.cost only, no oncosts)
- Estimated A_ex_leave × cost_with_oncosts: ~£44.7k × 1.14 ≈ £51,005 ✓
syncActuals reverted to shifts endpoint, adds leave_request_id filter,
uses cost_with_oncosts as total_cost (was using shift.cost before).
compareEndpoints A now shows both baseCostExLeave and oncostExLeave so
we can confirm the £51k match directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Key findings from diagnostic runs:
- timesheets/on/{date} returns only that DATE's shifts (not the whole
week), so weekly-step fetch only samples 6 days of a 30-day month
- user.report_department_id filter too strict: only 32 staff have it
set, missing many valid No 4 employees → £15k instead of £51k
- Correct approach: per-day fetch (all 30 days), dedup by shift.id
(robust against any duplicate timesheet records), filter by
shift.department_id → location_id (matches WF "by Location and Team")
Also includes inactive users (show_inactive=true) to catch staff who
left mid-month but still have timesheets in the date range.
Compare endpoint now shows baseCost (shift.cost) and oncostTotal
(shift.cost_with_oncosts) so we can confirm which matches WF's £51k.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Weekly fetch step (not per-day): timesheets/on/{date} returns the
whole weekly period for any date in that week, so fetching 30 days
counted each weekly timesheet ~7x. Now fetches one date per 7-day
step from 'to' backwards (PBI pattern), deduplicates by timesheet.id,
and filters shifts by sh.date to the requested window.
2. Filter by user.report_department_id not shift.department_id: shared
departments (HR, Management, etc.) have a single location assignment
(No 4) but staff from all hotels clock into them. Filtering by the
employee's HOME department (report_department_id) correctly isolates
No 4 staff. Mirrors the PBI model join: timesheet → user →
report_department_id → department.location_id → hotel.
Both fixes applied to syncActuals and compareEndpoints diagnostic.
Target: B.cost = WF "Timesheet exc. leave inc. allowances" (~£51k June).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
Sequential day-by-day caused 504 on month-long ranges. Switching to
Promise.all over all dates so 30 concurrent calls complete in ~3-5s.
Route timeout bumped to 120s to give the parallel calls room to land.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds four endpoint variants (A-D) to isolate the location filter discrepancy:
report_location_id vs location_id vs no filter vs timesheets range. If the
timesheets range 404s, auto-falls back to per-day /timesheets/on/{date} fetch
(variant E) so we can see timesheet totals regardless of API version.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rota forecasting: only trust Workforce rota for the current Mon–Sun week —
beyond that use prior-week actuals to avoid incomplete next-week rotas
deflating the EOM forecast.
% vs Budget cards (Weekly + Monthly): show as coloured headline number
(green/amber/red) instead of small pct-badge chip, matching other cards.
Forecast EOM: PY sub-label shows full prior-year month total + % delta,
replacing the static "rota + prior-week actual" note once PY data exists.
Achieves this by fetching the full PY month rather than MTD-capped range,
then deriving both MTD (for Actual MTD card) and full-month (for EOM card).
Backfill depth: 13 → 25 months so past months in Rolling 12 have PY wage
data available (viewing Dec 2024 needs Dec 2023 = 19 months ago).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stores per-employee shift costs in wage_actuals_detail (APPROVED shifts
only). Sync fetches employee name map from /api/v2/users alongside dept
names. Clicking any dept row in Weekly or Monthly opens a modal showing
Employee · Shifts · Cost · % of Dept, fetched on demand.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Schedules endpoint has same 7-day limit as shifts. The 14-day forward
window was being fetched in one call — split into two 7-day batches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full wage cost reporting app — weekly/monthly views, rolling 12-week/12-month
history, budget management, Workforce API sync with SSE backfill, net sales
via forecasting public API, department filter, CSV export.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>