Initial scaffold: wages app
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>
This commit is contained in:
commit
2e0592eb90
37 changed files with 3078 additions and 0 deletions
20
backend/src/lib/scheduler.js
Normal file
20
backend/src/lib/scheduler.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { runRollingSync } from './workforce.js'
|
||||
import { setConfig } from '../db.js'
|
||||
|
||||
const INTERVAL_MS = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
async function doSync() {
|
||||
try {
|
||||
const result = await runRollingSync()
|
||||
await setConfig('sync_last_at', new Date().toISOString())
|
||||
console.log(`[scheduler] sync complete — ${result.actualRows} actual rows, ${result.scheduledRows} scheduled rows`)
|
||||
} catch (err) {
|
||||
console.error('[scheduler] sync failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
// Run once at startup (allow app to be ready first)
|
||||
setTimeout(doSync, 5000)
|
||||
setInterval(doSync, INTERVAL_MS)
|
||||
}
|
||||
229
backend/src/lib/workforce.js
Normal file
229
backend/src/lib/workforce.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { pool, getConfig } from '../db.js'
|
||||
|
||||
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.116:3080'
|
||||
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||
|
||||
let _credsCache = null
|
||||
|
||||
async function getWorkforceCreds() {
|
||||
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
|
||||
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
|
||||
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error('Workforce integration not configured — add bearer token in Settings')
|
||||
const creds = await res.json()
|
||||
if (!creds.bearer_token) throw new Error('Workforce integration not configured — add bearer token in Settings')
|
||||
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
|
||||
return creds
|
||||
}
|
||||
|
||||
async function wfFetch(path) {
|
||||
const creds = await getWorkforceCreds()
|
||||
const base = creds.base_url || 'https://my.workforce.com'
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: { Authorization: `Bearer ${creds.bearer_token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Workforce API ${res.status}${body ? ': ' + body.slice(0, 200) : ''}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function wfFetchPaged(path) {
|
||||
const results = []
|
||||
let page = 1
|
||||
while (true) {
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
|
||||
const items = Array.isArray(data)
|
||||
? data
|
||||
: (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? [])
|
||||
results.push(...items)
|
||||
if (items.length < 100) break
|
||||
page++
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function fetchAllDepartments() {
|
||||
const creds = await getWorkforceCreds()
|
||||
const locationId = creds.location_id ? String(creds.location_id) : null
|
||||
const all = await wfFetchPaged('/api/v2/departments')
|
||||
const filtered = locationId ? all.filter(d => String(d.location_id) === locationId) : all
|
||||
return filtered.map(d => ({ id: String(d.id), name: d.name }))
|
||||
}
|
||||
|
||||
async function getEnabledDeptIds() {
|
||||
const val = await getConfig('departments')
|
||||
if (!val) return null // null means "all enabled"
|
||||
try {
|
||||
const depts = JSON.parse(val)
|
||||
if (!Array.isArray(depts) || depts.length === 0) return null
|
||||
const enabled = depts.filter(d => d.enabled !== false).map(d => d.id)
|
||||
return enabled.length > 0 ? enabled : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getDeptNameMap() {
|
||||
const depts = await fetchAllDepartments()
|
||||
return Object.fromEntries(depts.map(d => [d.id, d.name]))
|
||||
}
|
||||
|
||||
export async function syncActuals(from, to) {
|
||||
const creds = await getWorkforceCreds()
|
||||
const locationId = creds.location_id
|
||||
const enabledDeptIds = await getEnabledDeptIds()
|
||||
|
||||
let path = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
|
||||
if (locationId) path += `&report_location_id=${locationId}`
|
||||
|
||||
const shifts = await wfFetchPaged(path)
|
||||
const deptNameMap = await getDeptNameMap()
|
||||
|
||||
const byDateDept = {}
|
||||
for (const s of shifts) {
|
||||
const deptId = String(s.department_id)
|
||||
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
|
||||
|
||||
const date = s.date
|
||||
const key = `${date}:${deptId}`
|
||||
if (!byDateDept[key]) {
|
||||
byDateDept[key] = {
|
||||
date,
|
||||
department_id: deptId,
|
||||
department_name: deptNameMap[deptId] || s.department_name || deptId,
|
||||
base_cost: 0,
|
||||
total_cost: 0,
|
||||
shift_count: 0,
|
||||
}
|
||||
}
|
||||
byDateDept[key].base_cost += parseFloat(s.cost ?? 0)
|
||||
byDateDept[key].total_cost += parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
|
||||
byDateDept[key].shift_count += 1
|
||||
}
|
||||
|
||||
for (const row of Object.values(byDateDept)) {
|
||||
await pool.query(
|
||||
`INSERT INTO wage_actuals (date, department_id, department_name, base_cost, total_cost, shift_count, cached_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (date, department_id) DO UPDATE SET
|
||||
department_name = EXCLUDED.department_name,
|
||||
base_cost = EXCLUDED.base_cost,
|
||||
total_cost = EXCLUDED.total_cost,
|
||||
shift_count = EXCLUDED.shift_count,
|
||||
cached_at = NOW()`,
|
||||
[row.date, row.department_id, row.department_name,
|
||||
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
|
||||
)
|
||||
}
|
||||
|
||||
return Object.keys(byDateDept).length
|
||||
}
|
||||
|
||||
export async function syncScheduled(from, to) {
|
||||
const creds = await getWorkforceCreds()
|
||||
const locationId = creds.location_id
|
||||
const enabledDeptIds = await getEnabledDeptIds()
|
||||
|
||||
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
|
||||
if (locationId) path += `&location_id=${locationId}`
|
||||
|
||||
const schedules = await wfFetchPaged(path)
|
||||
const deptNameMap = await getDeptNameMap()
|
||||
|
||||
const byDateDept = {}
|
||||
for (const s of schedules) {
|
||||
const deptId = String(s.department_id)
|
||||
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
|
||||
|
||||
const date = s.date || new Date(s.start * 1000).toISOString().slice(0, 10)
|
||||
const key = `${date}:${deptId}`
|
||||
if (!byDateDept[key]) {
|
||||
byDateDept[key] = {
|
||||
date,
|
||||
department_id: deptId,
|
||||
department_name: deptNameMap[deptId] || deptId,
|
||||
base_cost: 0,
|
||||
total_cost: 0,
|
||||
shift_count: 0,
|
||||
}
|
||||
}
|
||||
byDateDept[key].base_cost += parseFloat(s.cost ?? 0)
|
||||
byDateDept[key].total_cost += parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
|
||||
byDateDept[key].shift_count += 1
|
||||
}
|
||||
|
||||
for (const row of Object.values(byDateDept)) {
|
||||
await pool.query(
|
||||
`INSERT INTO wage_scheduled (date, department_id, department_name, base_cost, total_cost, shift_count, cached_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (date, department_id) DO UPDATE SET
|
||||
department_name = EXCLUDED.department_name,
|
||||
base_cost = EXCLUDED.base_cost,
|
||||
total_cost = EXCLUDED.total_cost,
|
||||
shift_count = EXCLUDED.shift_count,
|
||||
cached_at = NOW()`,
|
||||
[row.date, row.department_id, row.department_name,
|
||||
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
|
||||
)
|
||||
}
|
||||
|
||||
return Object.keys(byDateDept).length
|
||||
}
|
||||
|
||||
export async function runRollingSync() {
|
||||
const today = new Date()
|
||||
const to = today.toISOString().slice(0, 10)
|
||||
const fromDate = new Date(today)
|
||||
fromDate.setDate(fromDate.getDate() - 35)
|
||||
const from = fromDate.toISOString().slice(0, 10)
|
||||
|
||||
const fwdDate = new Date(today)
|
||||
fwdDate.setDate(fwdDate.getDate() + 14)
|
||||
const fwd = fwdDate.toISOString().slice(0, 10)
|
||||
|
||||
const [actualRows, scheduledRows] = await Promise.all([
|
||||
syncActuals(from, to),
|
||||
syncScheduled(to, fwd),
|
||||
])
|
||||
return { actualRows, scheduledRows }
|
||||
}
|
||||
|
||||
export async function runBackfill(onProgress, signal) {
|
||||
const today = new Date()
|
||||
const endDate = new Date(today)
|
||||
endDate.setDate(endDate.getDate() - 1)
|
||||
|
||||
const startDate = new Date(today)
|
||||
startDate.setMonth(startDate.getMonth() - 13)
|
||||
|
||||
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 syncActuals(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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue