Add AI cost insights dashboard; rota-informed forecast method
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>
This commit is contained in:
parent
11e5a2b78a
commit
95da5ea237
26 changed files with 8914 additions and 78 deletions
1134
backend/package-lock.json
generated
Normal file
1134
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
|
|
|
|||
|
|
@ -26,14 +26,17 @@ export async function initDb() {
|
|||
CREATE INDEX IF NOT EXISTS wage_actuals_date_idx ON wage_actuals(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wage_scheduled (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
department_id TEXT NOT NULL,
|
||||
department_name TEXT NOT NULL,
|
||||
base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
shift_count INTEGER NOT NULL DEFAULT 0,
|
||||
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
department_id TEXT NOT NULL,
|
||||
department_name TEXT NOT NULL,
|
||||
published_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
published_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
published_shift_count INTEGER NOT NULL DEFAULT 0,
|
||||
unpublished_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unpublished_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unpublished_shift_count INTEGER NOT NULL DEFAULT 0,
|
||||
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(date, department_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS wage_scheduled_date_idx ON wage_scheduled(date);
|
||||
|
|
@ -57,16 +60,58 @@ export async function initDb() {
|
|||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_insights (
|
||||
id SERIAL PRIMARY KEY,
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
insight_type TEXT NOT NULL DEFAULT 'daily_summary',
|
||||
content TEXT NOT NULL,
|
||||
model TEXT,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
data_snapshot JSONB,
|
||||
triggered_by TEXT NOT NULL DEFAULT 'scheduler'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ai_insights_generated_idx ON ai_insights(generated_at DESC);
|
||||
`)
|
||||
|
||||
// One-time migration for pre-existing wage_scheduled tables: split the old combined
|
||||
// base_cost/total_cost/shift_count into published/unpublished columns. wage_scheduled
|
||||
// is a rolling 14-day cache repopulated by runRollingSync() within an hour of startup,
|
||||
// so truncating it is safe — no fuzzy backfill of the old combined costs needed.
|
||||
const oldCol = await pool.query(`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'wage_scheduled' AND column_name = 'base_cost'
|
||||
`)
|
||||
if (oldCol.rows.length > 0) {
|
||||
await pool.query(`TRUNCATE wage_scheduled`)
|
||||
await pool.query(`
|
||||
ALTER TABLE wage_scheduled
|
||||
ADD COLUMN IF NOT EXISTS published_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS published_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS published_shift_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS unpublished_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS unpublished_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS unpublished_shift_count INTEGER NOT NULL DEFAULT 0,
|
||||
DROP COLUMN base_cost,
|
||||
DROP COLUMN total_cost,
|
||||
DROP COLUMN shift_count
|
||||
`)
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO wages_config (key, value) VALUES
|
||||
('forecasting_url', ''),
|
||||
('forecasting_api_key', ''),
|
||||
('show_oncosts', 'true'),
|
||||
('departments', ''),
|
||||
('forecast_method', 'repeat'),
|
||||
('sync_last_at', ''),
|
||||
('backfill_last_at', '')
|
||||
('backfill_last_at', ''),
|
||||
('ai_insights_enabled', 'false'),
|
||||
('ai_insights_model', 'claude-haiku-4-5-20251001'),
|
||||
('ai_insights_schedule_time', '07:15'),
|
||||
('ai_insights_daily_token_budget', '5000')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { budgetsRoutes } from './routes/budgets.js'
|
|||
import { exportRoutes } from './routes/export.js'
|
||||
import { syncRoutes } from './routes/sync.js'
|
||||
import { settingsRoutes } from './routes/settings.js'
|
||||
import { aiInsightsRoutes } from './routes/ai-insights.js'
|
||||
import { startScheduler } from './lib/scheduler.js'
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
|
@ -29,6 +30,7 @@ await app.register(budgetsRoutes)
|
|||
await app.register(exportRoutes)
|
||||
await app.register(syncRoutes)
|
||||
await app.register(settingsRoutes)
|
||||
await app.register(aiInsightsRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
|
|
|
|||
485
backend/src/jobs/ai-insights.js
Normal file
485
backend/src/jobs/ai-insights.js
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { pool, getConfig } from '../db.js'
|
||||
import { getAnthropicApiKey } from '../lib/central-settings.js'
|
||||
import { fcFetch } from '../lib/forecasting-client.js'
|
||||
import { forecastDayCost } from '../lib/forecast.js'
|
||||
|
||||
export const DEFAULT_MODEL = 'claude-haiku-4-5-20251001'
|
||||
export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
|
||||
const MAX_OUTPUT_TOKENS = 600
|
||||
export const MANUAL_RATE_LIMIT_MINUTES = 5
|
||||
|
||||
function toISODate(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fmtDateUK(isoDate) {
|
||||
const [y, m, d] = String(isoDate).split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function fmtMoney(n) {
|
||||
return n == null ? '-' : `£${Number(n).toLocaleString('en-GB', { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
function fmtPct(n) {
|
||||
return n == null ? '-' : `${(n * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
async function getCostCol() {
|
||||
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
||||
return showOncosts ? 'total_cost' : 'base_cost'
|
||||
}
|
||||
|
||||
export async function getAiInsightsConfig() {
|
||||
const [enabled, model, scheduleTime, dailyTokenBudget] = await Promise.all([
|
||||
getConfig('ai_insights_enabled'),
|
||||
getConfig('ai_insights_model'),
|
||||
getConfig('ai_insights_schedule_time'),
|
||||
getConfig('ai_insights_daily_token_budget'),
|
||||
])
|
||||
return {
|
||||
enabled: enabled === 'true',
|
||||
model: model || DEFAULT_MODEL,
|
||||
scheduleTime: scheduleTime || '07:15',
|
||||
dailyTokenBudget: parseInt(dailyTokenBudget, 10) || DEFAULT_DAILY_TOKEN_BUDGET,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDailyBudget(budgetTokens) {
|
||||
const res = await pool.query(
|
||||
`SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
|
||||
FROM ai_insights WHERE generated_at >= CURRENT_DATE`
|
||||
)
|
||||
const usedToday = parseInt(res.rows[0].total, 10)
|
||||
return { withinBudget: usedToday < budgetTokens, usedToday }
|
||||
}
|
||||
|
||||
// Month-to-date total wages cost (company-wide) vs the total month budget.
|
||||
// No per-department budget split — that split is a rough estimate and skews comparisons.
|
||||
export async function gatherMonthProgressData() {
|
||||
const costCol = await getCostCol()
|
||||
const today = new Date()
|
||||
const monthStart = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const yesterdayStr = toISODate(new Date(today.getTime() - 86_400_000))
|
||||
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
|
||||
const daysElapsed = Math.max(0, today.getDate() - 1)
|
||||
|
||||
const [actualRes, budgetRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT COALESCE(SUM(${costCol}), 0) AS mtd_cost
|
||||
FROM wage_actuals WHERE date >= $1 AND date <= $2`,
|
||||
[monthStart, yesterdayStr]
|
||||
),
|
||||
pool.query(`SELECT budget_amount FROM wage_budgets WHERE month = $1`, [monthStart]),
|
||||
])
|
||||
|
||||
const mtdCost = parseFloat(actualRes.rows[0].mtd_cost)
|
||||
const budgetAmount = budgetRes.rows[0] ? parseFloat(budgetRes.rows[0].budget_amount) : null
|
||||
|
||||
return {
|
||||
monthStart,
|
||||
monthLabel: monthStart.slice(0, 7),
|
||||
yesterdayStr,
|
||||
daysElapsed,
|
||||
daysInMonth,
|
||||
pctMonthElapsed: daysElapsed / daysInMonth,
|
||||
mtdCost,
|
||||
budgetAmount,
|
||||
pctBudgetUsed: budgetAmount ? mtdCost / budgetAmount : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Full previous month and full same-month-last-year totals (complete datasets, not
|
||||
// sliced to a matching to-date range) — the model draws its own pace comparison.
|
||||
export async function gatherPriorPeriodData() {
|
||||
const costCol = await getCostCol()
|
||||
const today = new Date()
|
||||
|
||||
const prevMonthStart = toISODate(new Date(today.getFullYear(), today.getMonth() - 1, 1))
|
||||
const prevMonthEnd = toISODate(new Date(today.getFullYear(), today.getMonth(), 0))
|
||||
const lastYearStart = toISODate(new Date(today.getFullYear() - 1, today.getMonth(), 1))
|
||||
const lastYearEnd = toISODate(new Date(today.getFullYear() - 1, today.getMonth() + 1, 0))
|
||||
|
||||
const shapeDepts = rows => rows.map(r => ({
|
||||
department_id: r.department_id,
|
||||
department_name: r.department_name,
|
||||
cost: parseFloat(r.cost),
|
||||
}))
|
||||
|
||||
const deptQuery = (from, to) => pool.query(
|
||||
`SELECT department_id, department_name, SUM(${costCol}) AS cost
|
||||
FROM wage_actuals WHERE date >= $1 AND date <= $2
|
||||
GROUP BY department_id, department_name ORDER BY SUM(${costCol}) DESC`,
|
||||
[from, to]
|
||||
)
|
||||
|
||||
const [prevMonthRes, lastYearRes] = await Promise.all([
|
||||
deptQuery(prevMonthStart, prevMonthEnd),
|
||||
deptQuery(lastYearStart, lastYearEnd),
|
||||
])
|
||||
|
||||
const prevMonthDepts = shapeDepts(prevMonthRes.rows)
|
||||
const lastYearDepts = shapeDepts(lastYearRes.rows)
|
||||
|
||||
return {
|
||||
prevMonthLabel: prevMonthStart.slice(0, 7),
|
||||
prevMonthTotal: prevMonthDepts.reduce((s, d) => s + d.cost, 0),
|
||||
prevMonthDepts,
|
||||
lastYearLabel: lastYearStart.slice(0, 7),
|
||||
lastYearTotal: lastYearDepts.reduce((s, d) => s + d.cost, 0),
|
||||
lastYearDepts,
|
||||
}
|
||||
}
|
||||
|
||||
// Department-level variance between scheduled (rota) and actual cost, over the trailing
|
||||
// window. Rota rows for past dates aren't deleted once synced, so this covers real history.
|
||||
export async function gatherRotaVsActualData(days = 28) {
|
||||
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
||||
const actualCostCol = showOncosts ? 'total_cost' : 'base_cost'
|
||||
const schedCostExpr = showOncosts
|
||||
? '(published_total_cost + unpublished_total_cost)'
|
||||
: '(published_base_cost + unpublished_base_cost)'
|
||||
|
||||
const today = new Date()
|
||||
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
|
||||
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
|
||||
|
||||
const [actualRes, schedRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT date, department_id, department_name, ${actualCostCol} AS cost
|
||||
FROM wage_actuals WHERE date >= $1 AND date <= $2`,
|
||||
[fromStr, toStr]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT date, department_id, ${schedCostExpr} AS cost
|
||||
FROM wage_scheduled WHERE date >= $1 AND date <= $2`,
|
||||
[fromStr, toStr]
|
||||
),
|
||||
])
|
||||
|
||||
const schedMap = {}
|
||||
for (const r of schedRes.rows) {
|
||||
schedMap[`${r.date.toISOString().slice(0, 10)}:${r.department_id}`] = parseFloat(r.cost)
|
||||
}
|
||||
|
||||
const byDept = {}
|
||||
for (const r of actualRes.rows) {
|
||||
const dateStr = r.date.toISOString().slice(0, 10)
|
||||
const dep = r.department_id
|
||||
byDept[dep] ??= { department_id: dep, department_name: r.department_name, actualTotal: 0, schedTotal: 0, daysWithRota: 0 }
|
||||
const actualCost = parseFloat(r.cost)
|
||||
const schedCost = schedMap[`${dateStr}:${dep}`]
|
||||
byDept[dep].actualTotal += actualCost
|
||||
if (schedCost != null) {
|
||||
byDept[dep].schedTotal += schedCost
|
||||
byDept[dep].daysWithRota++
|
||||
}
|
||||
}
|
||||
|
||||
const depts = Object.values(byDept)
|
||||
.map(d => ({
|
||||
...d,
|
||||
variance: d.actualTotal - d.schedTotal,
|
||||
variancePct: d.schedTotal > 0 ? (d.actualTotal - d.schedTotal) / d.schedTotal : null,
|
||||
}))
|
||||
.sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance))
|
||||
|
||||
return { fromStr, toStr, depts }
|
||||
}
|
||||
|
||||
// Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic
|
||||
// and the forecast_method setting already used by the Monthly/Weekly pages, so this never
|
||||
// disagrees with what those pages show. Not related to forecasting app's revenue model.
|
||||
export async function gatherForecastData(monthProgress) {
|
||||
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
||||
const forecastMethodRaw = await getConfig('forecast_method')
|
||||
const forecastMethod = forecastMethodRaw === 'rota' ? 'rota' : 'repeat'
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = toISODate(today)
|
||||
const monthEndDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
const monthEndStr = toISODate(monthEndDate)
|
||||
const actualsFrom = toISODate(new Date(today.getTime() - 42 * 86_400_000))
|
||||
|
||||
const [actualRes, schedRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT date, department_id, department_name, ${showOncosts ? 'total_cost' : 'base_cost'} AS cost
|
||||
FROM wage_actuals WHERE date >= $1 AND date < $2`,
|
||||
[actualsFrom, todayStr]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT date, department_id, department_name,
|
||||
published_base_cost, published_total_cost, published_shift_count,
|
||||
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count
|
||||
FROM wage_scheduled WHERE date >= $1 AND date <= $2`,
|
||||
[todayStr, monthEndStr]
|
||||
),
|
||||
])
|
||||
|
||||
const deptNames = {}
|
||||
const actualByDept = {}
|
||||
for (const r of actualRes.rows) {
|
||||
const dep = r.department_id
|
||||
deptNames[dep] = r.department_name
|
||||
actualByDept[dep] ??= {}
|
||||
actualByDept[dep][r.date.toISOString().slice(0, 10)] = { cost: parseFloat(r.cost) }
|
||||
}
|
||||
|
||||
const schedByDept = {}
|
||||
for (const r of schedRes.rows) {
|
||||
const dep = r.department_id
|
||||
deptNames[dep] = r.department_name
|
||||
schedByDept[dep] ??= {}
|
||||
schedByDept[dep][r.date.toISOString().slice(0, 10)] = {
|
||||
published_cost: parseFloat(showOncosts ? r.published_total_cost : r.published_base_cost),
|
||||
unpublished_cost: parseFloat(showOncosts ? r.unpublished_total_cost : r.unpublished_base_cost),
|
||||
published_shift_count: r.published_shift_count,
|
||||
unpublished_shift_count: r.unpublished_shift_count,
|
||||
}
|
||||
}
|
||||
|
||||
const remainingDays = []
|
||||
for (let d = new Date(today); d <= monthEndDate; d.setDate(d.getDate() + 1)) {
|
||||
remainingDays.push(toISODate(d))
|
||||
}
|
||||
|
||||
let remainingTotal = 0
|
||||
const byDept = []
|
||||
for (const dep of Object.keys(deptNames)) {
|
||||
const schedDep = forecastMethod === 'rota' ? schedByDept[dep] : undefined
|
||||
let depRemaining = 0
|
||||
for (const dateStr of remainingDays) {
|
||||
depRemaining += forecastDayCost(dateStr, actualByDept[dep], schedDep, false).cost
|
||||
}
|
||||
remainingTotal += depRemaining
|
||||
byDept.push({ department_id: dep, department_name: deptNames[dep], remaining: depRemaining })
|
||||
}
|
||||
|
||||
const projectedTotal = (monthProgress?.mtdCost ?? 0) + remainingTotal
|
||||
return {
|
||||
forecastMethod,
|
||||
remainingDaysCount: remainingDays.length,
|
||||
remainingTotal,
|
||||
projectedTotal,
|
||||
byDept: byDept.sort((a, b) => b.remaining - a.remaining),
|
||||
}
|
||||
}
|
||||
|
||||
// Individual shifts/wages worth a look — top-cost employees over the trailing week.
|
||||
export async function gatherEmployeeAnomalies(days = 7) {
|
||||
const costCol = await getCostCol()
|
||||
const today = new Date()
|
||||
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
|
||||
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT employee_id, employee_name, department_id,
|
||||
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
|
||||
FROM wage_actuals_detail
|
||||
WHERE date >= $1 AND date <= $2
|
||||
GROUP BY employee_id, employee_name, department_id
|
||||
ORDER BY SUM(${costCol}) DESC
|
||||
LIMIT 15`,
|
||||
[fromStr, toStr]
|
||||
)
|
||||
|
||||
return {
|
||||
fromStr,
|
||||
toStr,
|
||||
employees: res.rows.map(r => ({
|
||||
employee_id: r.employee_id,
|
||||
employee_name: r.employee_name,
|
||||
department_id: r.department_id,
|
||||
cost: parseFloat(r.cost),
|
||||
shift_count: r.shift_count,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Wage cost as a % of net sales, month-to-date — reuses the same cross-app call net-sales.js
|
||||
// already makes to the forecasting app's public revenue API.
|
||||
export async function gatherRevenueCorrelation(monthProgress) {
|
||||
try {
|
||||
const days = Math.max(1, monthProgress.daysElapsed)
|
||||
const data = await fcFetch(`/forecast/revenue?start_date=${monthProgress.monthStart}&days=${days}&type=all&dow_align=true`)
|
||||
const rows = data?.data ?? []
|
||||
const totalRevenue = rows.reduce((s, d) => s + parseFloat(d.total?.forecast ?? d.total?.otb ?? 0), 0)
|
||||
return {
|
||||
totalRevenue,
|
||||
wagePct: totalRevenue > 0 ? (monthProgress.mtdCost / totalRevenue) * 100 : null,
|
||||
daysCovered: rows.length,
|
||||
}
|
||||
} catch (e) {
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation) {
|
||||
const systemMsg =
|
||||
"You are an AI assistant for a wage cost controller in a UK hospitality business. Analyze the data below and " +
|
||||
"provide a concise daily briefing (3-5 bullet points). Focus on: how this month's wage cost is tracking " +
|
||||
"against budget, the month-end forecast, notable variance between rota and actual cost by department and " +
|
||||
"what might be causing it, individual pay anomalies worth a look, and wage cost as a percentage of revenue. " +
|
||||
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. Be specific with department/employee " +
|
||||
"names and numbers. Keep it actionable — no fluff or generic advice."
|
||||
|
||||
const lines = []
|
||||
|
||||
lines.push(`## Month to Date (${monthProgress.monthLabel})`)
|
||||
lines.push(
|
||||
`Day ${monthProgress.daysElapsed} of ${monthProgress.daysInMonth} (${fmtPct(monthProgress.pctMonthElapsed)} of month elapsed)`
|
||||
)
|
||||
lines.push(
|
||||
`MTD cost: ${fmtMoney(monthProgress.mtdCost)} | Month budget: ${fmtMoney(monthProgress.budgetAmount)}` +
|
||||
(monthProgress.pctBudgetUsed != null ? ` (${fmtPct(monthProgress.pctBudgetUsed)} used)` : '')
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
lines.push('## Prior Period Comparison (full-month totals, for context — not a budget split)')
|
||||
lines.push(`Previous month (${priorPeriod.prevMonthLabel}) total: ${fmtMoney(priorPeriod.prevMonthTotal)}`)
|
||||
for (const d of priorPeriod.prevMonthDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
|
||||
lines.push(`Same month last year (${priorPeriod.lastYearLabel}) total: ${fmtMoney(priorPeriod.lastYearTotal)}`)
|
||||
for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
|
||||
lines.push('')
|
||||
|
||||
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)})`)
|
||||
lines.push('Department | Actual | Rota | Variance | Variance %')
|
||||
for (const d of rotaVsActual.depts) {
|
||||
lines.push(
|
||||
`${d.department_name} | ${fmtMoney(d.actualTotal)} | ${fmtMoney(d.schedTotal)} | ` +
|
||||
`${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)}`
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
lines.push(`## Forecast (method: ${forecast.forecastMethod}, ${forecast.remainingDaysCount} days remaining)`)
|
||||
lines.push(
|
||||
`Projected month-end total: ${fmtMoney(forecast.projectedTotal)} vs budget ${fmtMoney(monthProgress.budgetAmount)}` +
|
||||
(monthProgress.budgetAmount != null
|
||||
? ` (${forecast.projectedTotal > monthProgress.budgetAmount ? 'over' : 'under'} by ${fmtMoney(Math.abs(forecast.projectedTotal - monthProgress.budgetAmount))})`
|
||||
: '')
|
||||
)
|
||||
for (const d of forecast.byDept) lines.push(` ${d.department_name} remaining: ${fmtMoney(d.remaining)}`)
|
||||
lines.push('')
|
||||
|
||||
lines.push(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`)
|
||||
lines.push('Employee | Department | Cost | Shifts')
|
||||
for (const e of anomalies.employees) {
|
||||
lines.push(`${e.employee_name} | ${e.department_id} | ${fmtMoney(e.cost)} | ${e.shift_count}`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
lines.push('## Wage Cost vs Revenue (month to date)')
|
||||
if (revenueCorrelation.error) {
|
||||
lines.push(`Unavailable: ${revenueCorrelation.error}`)
|
||||
} else {
|
||||
lines.push(
|
||||
`Revenue: ${fmtMoney(revenueCorrelation.totalRevenue)} | Wage cost: ${fmtMoney(monthProgress.mtdCost)} | ` +
|
||||
`Wage %: ${revenueCorrelation.wagePct != null ? revenueCorrelation.wagePct.toFixed(1) + '%' : '-'}`
|
||||
)
|
||||
}
|
||||
|
||||
return { systemMsg, userMsg: lines.join('\n') }
|
||||
}
|
||||
|
||||
export async function callLlm(apiKey, systemMsg, userMsg, model) {
|
||||
const client = new Anthropic({ apiKey })
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
temperature: 0.2,
|
||||
system: systemMsg,
|
||||
messages: [{ role: 'user', content: userMsg }],
|
||||
})
|
||||
const content = response.content?.[0]?.text ?? ''
|
||||
return {
|
||||
content,
|
||||
input_tokens: response.usage.input_tokens,
|
||||
output_tokens: response.usage.output_tokens,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveInsight({ content, model, input_tokens, output_tokens, data_snapshot, triggered_by }) {
|
||||
await pool.query(
|
||||
`INSERT INTO ai_insights (content, model, input_tokens, output_tokens, data_snapshot, triggered_by)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
|
||||
[content, model, input_tokens, output_tokens, JSON.stringify(data_snapshot), triggered_by]
|
||||
)
|
||||
}
|
||||
|
||||
export async function cleanupOldInsights(keepDays = 90) {
|
||||
await pool.query(`DELETE FROM ai_insights WHERE generated_at < NOW() - ($1 || ' days')::interval`, [keepDays])
|
||||
}
|
||||
|
||||
export async function generateInsight(triggeredBy = 'scheduler') {
|
||||
const config = await getAiInsightsConfig()
|
||||
if (!config.enabled) return { success: false, error: 'AI insights disabled' }
|
||||
|
||||
let apiKey
|
||||
try {
|
||||
apiKey = await getAnthropicApiKey()
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
|
||||
const { withinBudget, usedToday } = await checkDailyBudget(config.dailyTokenBudget)
|
||||
if (!withinBudget) {
|
||||
return { success: false, error: `Daily token budget exceeded (${usedToday}/${config.dailyTokenBudget} tokens used today)` }
|
||||
}
|
||||
|
||||
const monthProgress = await gatherMonthProgressData()
|
||||
const [priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation] = await Promise.all([
|
||||
gatherPriorPeriodData(),
|
||||
gatherRotaVsActualData(),
|
||||
gatherForecastData(monthProgress),
|
||||
gatherEmployeeAnomalies(),
|
||||
gatherRevenueCorrelation(monthProgress),
|
||||
])
|
||||
|
||||
const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation)
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await callLlm(apiKey, systemMsg, userMsg, config.model)
|
||||
} catch (e) {
|
||||
return { success: false, error: `LLM call failed: ${e.message}` }
|
||||
}
|
||||
|
||||
await saveInsight({
|
||||
content: result.content,
|
||||
model: result.model,
|
||||
input_tokens: result.input_tokens,
|
||||
output_tokens: result.output_tokens,
|
||||
data_snapshot: { monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation },
|
||||
triggered_by: triggeredBy,
|
||||
})
|
||||
|
||||
try {
|
||||
await cleanupOldInsights(90)
|
||||
} catch (e) {
|
||||
console.warn('[ai-insights] cleanup failed:', e.message)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: result.content,
|
||||
input_tokens: result.input_tokens,
|
||||
output_tokens: result.output_tokens,
|
||||
model: result.model,
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAiInsightsGeneration() {
|
||||
try {
|
||||
const result = await generateInsight('scheduler')
|
||||
if (result.success) {
|
||||
console.log('[ai-insights] scheduled generation completed')
|
||||
} else {
|
||||
console.log('[ai-insights] scheduled generation skipped:', result.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ai-insights] scheduled generation failed:', e.message)
|
||||
}
|
||||
}
|
||||
17
backend/src/lib/central-settings.js
Normal file
17
backend/src/lib/central-settings.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.116:3080'
|
||||
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||
|
||||
let _cache = null
|
||||
|
||||
export async function getAnthropicApiKey() {
|
||||
if (_cache && Date.now() < _cache.expires_at) return _cache.api_key
|
||||
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/anthropic`, {
|
||||
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error('Anthropic integration not configured — add API key in Portal → Settings → Integrations')
|
||||
const { api_key } = await res.json()
|
||||
if (!api_key) throw new Error('Anthropic integration not configured — add API key in Portal → Settings → Integrations')
|
||||
_cache = { api_key, expires_at: Date.now() + 5 * 60_000 }
|
||||
return api_key
|
||||
}
|
||||
29
backend/src/lib/forecast.js
Normal file
29
backend/src/lib/forecast.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Backend port of wages/frontend/src/lib/forecast.ts's forecastDayCost — keep both in sync.
|
||||
const MAX_HOPS = 6 // 6 * 7 = 42 days back, comfortably within the 35-day actuals sync window
|
||||
|
||||
function addDaysStr(dateStr, n) {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
d.setDate(d.getDate() + n)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Tiered forecast cost for a single department + date: actual (if present) → published
|
||||
// rota → draft rota (if includeUnpublished) → same-weekday recurring fallback (7-day hops).
|
||||
export function forecastDayCost(dateStr, actualDays, scheduledDays, includeUnpublished) {
|
||||
let probe = dateStr
|
||||
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
||||
const actual = actualDays?.[probe]
|
||||
if (actual != null) return { cost: actual.cost, tier: 'actual' }
|
||||
|
||||
const sched = scheduledDays?.[probe]
|
||||
const hasPublished = (sched?.published_shift_count ?? 0) > 0
|
||||
const hasUnpublished = (sched?.unpublished_shift_count ?? 0) > 0
|
||||
if (hasPublished || (includeUnpublished && hasUnpublished)) {
|
||||
const cost = (sched?.published_cost ?? 0) + (includeUnpublished ? (sched?.unpublished_cost ?? 0) : 0)
|
||||
return { cost, tier: hasPublished ? 'rota-published' : 'rota-draft' }
|
||||
}
|
||||
|
||||
probe = addDaysStr(probe, -7)
|
||||
}
|
||||
return { cost: 0, tier: 'none' }
|
||||
}
|
||||
13
backend/src/lib/forecasting-client.js
Normal file
13
backend/src/lib/forecasting-client.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { getConfig } from '../db.js'
|
||||
|
||||
export async function fcFetch(path) {
|
||||
const apiKey = await getConfig('forecasting_api_key')
|
||||
const baseUrl = (await getConfig('forecasting_url')) || 'http://10.10.10.113:3080'
|
||||
if (!apiKey) throw new Error('Forecasting API key not configured — add it in Settings')
|
||||
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Forecasting API ${res.status} — ${path}`)
|
||||
return res.json()
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { runRollingSync } from './workforce.js'
|
||||
import { setConfig } from '../db.js'
|
||||
import { getConfig, setConfig } from '../db.js'
|
||||
import { runAiInsightsGeneration } from '../jobs/ai-insights.js'
|
||||
|
||||
const INTERVAL_MS = 60 * 60 * 1000 // 1 hour
|
||||
const AI_INSIGHTS_CHECK_MS = 60 * 1000 // 1 minute — no cron library in this app, poll wall-clock instead
|
||||
|
||||
let _lastAiInsightsRunDate = null
|
||||
|
||||
async function doSync() {
|
||||
try {
|
||||
|
|
@ -13,8 +17,21 @@ async function doSync() {
|
|||
}
|
||||
}
|
||||
|
||||
async function maybeRunAiInsights() {
|
||||
const scheduleTime = (await getConfig('ai_insights_schedule_time')) || '07:15'
|
||||
const [hour, minute] = scheduleTime.split(':').map(Number)
|
||||
const now = new Date()
|
||||
const todayKey = now.toISOString().slice(0, 10)
|
||||
if (_lastAiInsightsRunDate === todayKey) return
|
||||
if (now.getHours() === hour && now.getMinutes() === minute) {
|
||||
_lastAiInsightsRunDate = todayKey
|
||||
await runAiInsightsGeneration()
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
// Run once at startup (allow app to be ready first)
|
||||
setTimeout(doSync, 5000)
|
||||
setInterval(doSync, INTERVAL_MS)
|
||||
setInterval(maybeRunAiInsights, AI_INSIGHTS_CHECK_MS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,9 @@ export async function syncScheduled(from, to) {
|
|||
|
||||
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
|
||||
if (locationId) path += `&location_id=${locationId}`
|
||||
// No published_only param: this endpoint defaults to published_only=false, returning
|
||||
// both published and draft schedules in one call. Each schedule's own last_published_at
|
||||
// (null until it's been published to its employee) tells us which bucket it belongs to.
|
||||
|
||||
const schedules = await wfFetchPaged(path)
|
||||
const allDepts = await wfFetchPaged('/api/v2/departments')
|
||||
|
|
@ -230,30 +233,58 @@ export async function syncScheduled(from, to) {
|
|||
if (!byDateDept[key]) {
|
||||
byDateDept[key] = {
|
||||
date,
|
||||
department_id: deptId,
|
||||
department_name: deptNameMap[deptId] || deptId,
|
||||
base_cost: 0,
|
||||
total_cost: 0,
|
||||
shift_count: 0,
|
||||
department_id: deptId,
|
||||
department_name: deptNameMap[deptId] || deptId,
|
||||
published_base_cost: 0,
|
||||
published_total_cost: 0,
|
||||
published_shift_count: 0,
|
||||
unpublished_base_cost: 0,
|
||||
unpublished_total_cost: 0,
|
||||
unpublished_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
|
||||
const baseCost = parseFloat(s.cost ?? 0)
|
||||
const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
|
||||
const row = byDateDept[key]
|
||||
if (s.last_published_at != null) {
|
||||
row.published_base_cost += baseCost
|
||||
row.published_total_cost += totalCost
|
||||
row.published_shift_count += 1
|
||||
} else {
|
||||
row.unpublished_base_cost += baseCost
|
||||
row.unpublished_total_cost += totalCost
|
||||
row.unpublished_shift_count += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror syncActuals: recompute the whole [from,to] range from the API on every call,
|
||||
// so delete first — otherwise a shift that got published, edited, or removed since the
|
||||
// last sync (very likely, since a rota is actively being built) leaves a stale row behind.
|
||||
// The write itself is still an upsert (not a plain insert): the 14-day rota window overlaps
|
||||
// between the scheduler's hourly auto-sync and a manager's manual "Sync Now" click, so two
|
||||
// syncScheduled calls can race on the same date range — a plain insert after a shared delete
|
||||
// would throw a duplicate-key error if both land between each other's delete and insert.
|
||||
await pool.query(`DELETE FROM wage_scheduled WHERE date >= $1 AND date <= $2`, [from, to])
|
||||
|
||||
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())
|
||||
`INSERT INTO wage_scheduled
|
||||
(date, department_id, department_name,
|
||||
published_base_cost, published_total_cost, published_shift_count,
|
||||
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count, cached_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 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()`,
|
||||
department_name = EXCLUDED.department_name,
|
||||
published_base_cost = EXCLUDED.published_base_cost,
|
||||
published_total_cost = EXCLUDED.published_total_cost,
|
||||
published_shift_count = EXCLUDED.published_shift_count,
|
||||
unpublished_base_cost = EXCLUDED.unpublished_base_cost,
|
||||
unpublished_total_cost = EXCLUDED.unpublished_total_cost,
|
||||
unpublished_shift_count = EXCLUDED.unpublished_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]
|
||||
row.published_base_cost.toFixed(2), row.published_total_cost.toFixed(2), row.published_shift_count,
|
||||
row.unpublished_base_cost.toFixed(2), row.unpublished_total_cost.toFixed(2), row.unpublished_shift_count]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ export async function actualsRoutes(fastify) {
|
|||
const { from, to } = request.query
|
||||
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
|
||||
|
||||
const [showOncostsRaw, pctsRaw, dbRes] = await Promise.all([
|
||||
const [showOncostsRaw, pctsRaw, forecastMethodRaw, dbRes] = await Promise.all([
|
||||
getConfig('show_oncosts'),
|
||||
getConfig('dept_budget_pcts'),
|
||||
getConfig('forecast_method'),
|
||||
pool.query(
|
||||
`SELECT date, department_id, department_name,
|
||||
base_cost, total_cost, shift_count
|
||||
|
|
@ -46,7 +47,12 @@ export async function actualsRoutes(fastify) {
|
|||
try { for (const item of JSON.parse(pctsRaw)) dept_pcts[item.id] = item.pct } catch {}
|
||||
}
|
||||
|
||||
return { departments: Object.values(deptMap), show_oncosts: showOncosts, dept_pcts }
|
||||
return {
|
||||
departments: Object.values(deptMap),
|
||||
show_oncosts: showOncosts,
|
||||
dept_pcts,
|
||||
forecast_method: forecastMethodRaw === 'rota' ? 'rota' : 'repeat',
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/actuals/dept-detail', { preHandler: requireCap('view') }, async (request, reply) => {
|
||||
|
|
|
|||
80
backend/src/routes/ai-insights.js
Normal file
80
backend/src/routes/ai-insights.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, getConfig } from '../db.js'
|
||||
import { getAnthropicApiKey } from '../lib/central-settings.js'
|
||||
import { generateInsight, MANUAL_RATE_LIMIT_MINUTES, DEFAULT_MODEL } from '../jobs/ai-insights.js'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
export async function aiInsightsRoutes(fastify) {
|
||||
fastify.addHook('preHandler', requireAuth)
|
||||
|
||||
fastify.get('/api/ai-insights/latest', { preHandler: requireCap('view') }, async () => {
|
||||
const res = await pool.query(
|
||||
`SELECT id, generated_at, content, model, input_tokens, output_tokens, triggered_by
|
||||
FROM ai_insights ORDER BY generated_at DESC LIMIT 1`
|
||||
)
|
||||
return res.rows[0] || null
|
||||
})
|
||||
|
||||
fastify.get('/api/ai-insights/history', { preHandler: requireCap('view') }, async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit, 10) || 20, 100)
|
||||
const offset = parseInt(request.query.offset, 10) || 0
|
||||
const [rowsRes, countRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, generated_at, content, model, input_tokens, output_tokens, triggered_by
|
||||
FROM ai_insights ORDER BY generated_at DESC LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
),
|
||||
pool.query(`SELECT COUNT(*)::int AS count FROM ai_insights`),
|
||||
])
|
||||
return { insights: rowsRes.rows, total: countRes.rows[0].count }
|
||||
})
|
||||
|
||||
fastify.get('/api/ai-insights/usage', { preHandler: requireCap('view') }, async () => {
|
||||
const res = await pool.query(
|
||||
`SELECT COALESCE(SUM(input_tokens), 0)::int AS input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0)::int AS output_tokens
|
||||
FROM ai_insights WHERE generated_at >= CURRENT_DATE`
|
||||
)
|
||||
return res.rows[0]
|
||||
})
|
||||
|
||||
fastify.post('/api/ai-insights/generate', { preHandler: requireCap('settings') }, async (_, reply) => {
|
||||
const lastManual = await pool.query(
|
||||
`SELECT generated_at FROM ai_insights WHERE triggered_by = 'manual' ORDER BY generated_at DESC LIMIT 1`
|
||||
)
|
||||
if (lastManual.rows[0]) {
|
||||
const elapsedMs = Date.now() - new Date(lastManual.rows[0].generated_at).getTime()
|
||||
const remainingMin = MANUAL_RATE_LIMIT_MINUTES - Math.floor(elapsedMs / 60_000)
|
||||
if (remainingMin > 0) {
|
||||
return reply.status(429).send({ error: `Please wait ${remainingMin}m more before generating again.` })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await generateInsight('manual')
|
||||
if (!result.success) return reply.status(400).send({ error: result.error })
|
||||
return result
|
||||
})
|
||||
|
||||
fastify.post('/api/ai-insights/test', { preHandler: requireCap('settings') }, async (_, reply) => {
|
||||
let apiKey
|
||||
try {
|
||||
apiKey = await getAnthropicApiKey()
|
||||
} catch (e) {
|
||||
return reply.status(400).send({ error: e.message })
|
||||
}
|
||||
|
||||
const model = (await getConfig('ai_insights_model')) || DEFAULT_MODEL
|
||||
try {
|
||||
const client = new Anthropic({ apiKey })
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: "Say 'connected' in one word." }],
|
||||
})
|
||||
return { status: 'connected', message: `Successfully connected to ${model}`, response: response.content?.[0]?.text ?? '' }
|
||||
} catch (e) {
|
||||
if (e.status === 401) return reply.status(401).send({ error: 'Invalid API key' })
|
||||
return reply.status(500).send({ error: `Connection failed: ${e.message}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { getConfig } from '../db.js'
|
||||
|
||||
async function fcFetch(path) {
|
||||
const apiKey = await getConfig('forecasting_api_key')
|
||||
const baseUrl = (await getConfig('forecasting_url')) || 'http://10.10.10.113:3080'
|
||||
if (!apiKey) throw new Error('Forecasting API key not configured — add it in Settings')
|
||||
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Forecasting API ${res.status} — ${path}`)
|
||||
return res.json()
|
||||
}
|
||||
import { fcFetch } from '../lib/forecasting-client.js'
|
||||
|
||||
function daysBetween(from, to) {
|
||||
const a = new Date(from + 'T00:00:00')
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export async function scheduledRoutes(fastify) {
|
|||
|
||||
const res = await pool.query(
|
||||
`SELECT date, department_id, department_name,
|
||||
base_cost, total_cost, shift_count
|
||||
published_base_cost, published_total_cost, published_shift_count,
|
||||
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count
|
||||
FROM wage_scheduled
|
||||
WHERE date >= $1 AND date <= $2
|
||||
ORDER BY date, department_name`,
|
||||
|
|
@ -30,8 +31,10 @@ export async function scheduledRoutes(fastify) {
|
|||
}
|
||||
}
|
||||
deptMap[row.department_id].days[d] = {
|
||||
cost: parseFloat(showOncosts ? row.total_cost : row.base_cost),
|
||||
shift_count: row.shift_count,
|
||||
published_cost: parseFloat(showOncosts ? row.published_total_cost : row.published_base_cost),
|
||||
unpublished_cost: parseFloat(showOncosts ? row.unpublished_total_cost : row.unpublished_base_cost),
|
||||
published_shift_count: row.published_shift_count,
|
||||
unpublished_shift_count: row.unpublished_shift_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { requireAuth, requireCap } from '../auth.js'
|
|||
import { pool, getConfig, setConfig } from '../db.js'
|
||||
|
||||
const ALLOWED_KEYS = new Set([
|
||||
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts',
|
||||
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts', 'forecast_method',
|
||||
'ai_insights_enabled', 'ai_insights_model', 'ai_insights_schedule_time', 'ai_insights_daily_token_budget',
|
||||
])
|
||||
|
||||
export async function settingsRoutes(fastify) {
|
||||
|
|
@ -11,7 +12,11 @@ export async function settingsRoutes(fastify) {
|
|||
fastify.get('/api/settings', { preHandler: requireCap('settings') }, async () => {
|
||||
const res = await pool.query(
|
||||
`SELECT key, value, updated_at FROM wages_config
|
||||
WHERE key IN ('forecasting_url','forecasting_api_key','show_oncosts','departments','sync_last_at','backfill_last_at')
|
||||
WHERE key IN (
|
||||
'forecasting_url','forecasting_api_key','show_oncosts','departments','forecast_method',
|
||||
'sync_last_at','backfill_last_at',
|
||||
'ai_insights_enabled','ai_insights_model','ai_insights_schedule_time','ai_insights_daily_token_budget'
|
||||
)
|
||||
ORDER BY key`
|
||||
)
|
||||
return { settings: res.rows }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue