Add rota backfill (60 days) — separate from the 25-month actuals backfill

runRollingSync only ever syncs wage_scheduled 14 days forward, so the AI
insight's 28-day rota-vs-actual comparison had no historical rota to compare
against for a freshly-scaffolded app. Adds a dedicated backfill action that
pulls past schedules via the same throttled weekly-batch pattern as the
existing actuals backfill.
This commit is contained in:
jtricerolph 2026-07-24 21:26:02 +00:00
parent 95da5ea237
commit cabaff8d35
4 changed files with 164 additions and 10 deletions

View file

@ -613,3 +613,41 @@ export async function runBackfill(onProgress, signal) {
await new Promise(r => setTimeout(r, 250))
}
}
// Backfill wage_scheduled into the past. Unlike runRollingSync (which only ever syncs
// [today, today+14d] for schedules), rota-vs-actual comparisons need historical rota too —
// but rota loses relevance much faster than actuals, so this only reaches back 60 days
// (comfortably covers the AI insight's 28-day window) rather than runBackfill's 25 months.
export async function runScheduledBackfill(onProgress, signal, days = 60) {
const today = new Date()
const endDate = new Date(today)
endDate.setDate(endDate.getDate() - 1)
const startDate = new Date(today)
startDate.setDate(startDate.getDate() - days)
const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000))
let processedDays = 0
let current = new Date(startDate)
while (current <= endDate) {
if (signal?.aborted) break
const weekEnd = new Date(current)
weekEnd.setDate(weekEnd.getDate() + 6)
if (weekEnd > endDate) weekEnd.setTime(endDate.getTime())
const from = current.toISOString().slice(0, 10)
const to = weekEnd.toISOString().slice(0, 10)
await syncScheduled(from, to)
const daysInBatch = Math.ceil((weekEnd - current) / 86_400_000) + 1
processedDays += daysInBatch
onProgress?.({ processed: processedDays, total: totalDays, current: from })
current.setDate(current.getDate() + 7)
await new Promise(r => setTimeout(r, 250))
}
}

View file

@ -1,18 +1,21 @@
import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig } from '../db.js'
import { runRollingSync, runBackfill, compareEndpoints } from '../lib/workforce.js'
import { runRollingSync, runBackfill, runScheduledBackfill, compareEndpoints } from '../lib/workforce.js'
import { fetchAllDepartments } from '../lib/workforce.js'
let _backfillAbort = null
let _rotaBackfillAbort = null
export async function syncRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/sync/status', { preHandler: requireCap('view') }, async () => {
return {
sync_last_at: await getConfig('sync_last_at'),
backfill_last_at: await getConfig('backfill_last_at'),
backfill_running: _backfillAbort !== null,
sync_last_at: await getConfig('sync_last_at'),
backfill_last_at: await getConfig('backfill_last_at'),
backfill_running: _backfillAbort !== null,
rota_backfill_last_at: await getConfig('rota_backfill_last_at'),
rota_backfill_running: _rotaBackfillAbort !== null,
}
})
@ -66,6 +69,47 @@ export async function syncRoutes(fastify) {
return { ok: true }
})
// Rota backfill — same SSE shape as /api/sync/backfill, but only reaches back 60 days
// (via runScheduledBackfill) since rota loses relevance much faster than actuals.
fastify.post('/api/sync/backfill/rota', { preHandler: requireCap('sync') }, async (request, reply) => {
if (_rotaBackfillAbort) {
_rotaBackfillAbort.abort()
_rotaBackfillAbort = null
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const ac = new AbortController()
_rotaBackfillAbort = ac
request.raw.on('close', () => ac.abort())
try {
await runScheduledBackfill(({ processed, total, current }) => {
reply.raw.write(`data: ${JSON.stringify({ processed, total, current })}\n\n`)
}, ac.signal)
await setConfig('rota_backfill_last_at', new Date().toISOString())
reply.raw.write(`data: ${JSON.stringify({ done: true })}\n\n`)
} catch (err) {
reply.raw.write(`data: ${JSON.stringify({ error: err.message })}\n\n`)
} finally {
_rotaBackfillAbort = null
reply.raw.end()
}
})
fastify.post('/api/sync/backfill/rota/cancel', { preHandler: requireCap('sync') }, async () => {
if (_rotaBackfillAbort) {
_rotaBackfillAbort.abort()
_rotaBackfillAbort = null
}
return { ok: true }
})
// Compare cost totals from all available Workforce endpoints for a date range.
// Useful for diagnosing figure discrepancies between shifts vs timesheets.
// Usage: GET /wages/api/sync/compare?from=2025-06-01&to=2025-06-30