reports/backend/src/routes/directors-forecast.js
jtricerolph 1c411e402e Add settings page for managing forecasting API key and URL via UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-20 14:37:36 +00:00

316 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { requireAuth, hasCap } from '../auth.js'
import { pool, getSetting } from '../db.js'
async function fcFetch(path) {
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
const baseUrl = await getSetting('forecasting_url') || process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
if (!apiKey) throw new Error('FORECASTING_API_KEY not configured')
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
headers: { 'X-API-Key': apiKey },
})
if (!res.ok) throw new Error(`Forecasting API ${res.status}${path}`)
return res.json()
}
function weekBand(day) {
if (day <= 7) return '17'
if (day <= 14) return '814'
if (day <= 21) return '1521'
if (day <= 28) return '2228'
return '29end'
}
async function getSessionAndOverrides(year, month) {
const sessionRes = await pool.query(
`INSERT INTO forecast_sessions (year, month)
VALUES ($1, $2)
ON CONFLICT (year, month) DO UPDATE SET updated_at = NOW()
RETURNING *`,
[year, month]
)
const session = sessionRes.rows[0]
const overridesRes = await pool.query(
`SELECT date::text, pickup_rooms, pickup_avg_rate, dry_override, wet_override, actual_accomm
FROM day_overrides WHERE session_id = $1`,
[session.id]
)
const overrideMap = {}
for (const row of overridesRes.rows) overrideMap[row.date] = row
return { session, overrideMap }
}
async function fetchMonthFromApi(year, month, lastDayNum) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`
const days = lastDayNum
const [revData, roomsData] = await Promise.all([
fcFetch(`/forecast/revenue?start_date=${startDate}&days=${days}&type=all`),
fcFetch(`/forecast/rooms?start_date=${startDate}&days=${days}`),
])
const revMap = {}
for (const d of revData.data) revMap[d.date] = d
const roomsMap = {}
for (const d of roomsData.data) roomsMap[d.date] = d
return { revMap, roomsMap }
}
export async function directorsForecastRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
// GET /api/directors-forecast/worksheet/:year/:month
fastify.get('/api/directors-forecast/worksheet/:year/:month', async (request, reply) => {
const year = parseInt(request.params.year)
const month = parseInt(request.params.month)
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
return reply.status(400).send({ error: 'Invalid year/month' })
}
const lastDayNum = new Date(year, month, 0).getDate()
const today = new Date().toISOString().split('T')[0]
const sessionRate = 135
const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([
getSessionAndOverrides(year, month),
fetchMonthFromApi(year, month, lastDayNum),
])
const rate = parseFloat(session.pickup_avg_rate || sessionRate)
const days = []
for (let d = 1; d <= lastDayNum; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const rev = revMap[dateStr] || {}
const rm = roomsMap[dateStr] || {}
const ovr = overrideMap[dateStr] || {}
const isPast = rev.is_past ?? (dateStr < today)
const isToday = dateStr === today
const dow = new Date(dateStr + 'T00:00:00').getDay()
const available = rm.available_rooms ?? 0
const otbRooms = rm.otb_rooms ?? 0
const otbNetRev = rev.accom?.otb ?? 0
const avgRateNet = otbRooms > 0 ? otbNetRev / otbRooms : null
// ML pickup suggestion from the forecasting model
const mlSuggestion = rm.pickup_rooms ?? null
// Actuals (from API — otb == actual for past dates)
const actualAccomm = isPast ? (rev.accom?.otb ?? null) : null
const actualDry = isPast ? (rev.dry?.otb ?? null) : null
const actualWet = isPast ? (rev.wet?.otb ?? null) : null
// User overrides
const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0
const dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : rate
// Forecast values
const forecastAccomm = isPast && actualAccomm != null
? actualAccomm
: otbNetRev + pickupRooms * dayRate
const forecastDry = ovr.dry_override != null
? parseFloat(ovr.dry_override)
: (rev.dry?.forecast ?? 0)
const forecastWet = ovr.wet_override != null
? parseFloat(ovr.wet_override)
: (rev.wet?.forecast ?? 0)
const totalRooms = otbRooms + pickupRooms
days.push({
date: dateStr, dow, available,
otb_rooms: otbRooms, otb_net_rev: otbNetRev, avg_rate_net: avgRateNet,
is_past: isPast, is_today: isToday,
actual_accomm: actualAccomm, actual_dry: actualDry, actual_wet: actualWet,
ml_suggestion: mlSuggestion,
pickup_rooms: pickupRooms,
pickup_avg_rate: ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : null,
dry_override: ovr.dry_override != null ? parseFloat(ovr.dry_override) : null,
wet_override: ovr.wet_override != null ? parseFloat(ovr.wet_override) : null,
total_rooms: totalRooms,
forecast_accomm: forecastAccomm,
forecast_occ_pct: available > 0 ? (totalRooms / available) * 100 : null,
forecast_dry: forecastDry,
forecast_wet: forecastWet,
})
}
return { session: { ...session, pickup_avg_rate: rate }, days }
})
// PUT /api/directors-forecast/worksheet/:year/:month — save overrides
fastify.put('/api/directors-forecast/worksheet/:year/:month', async (request, reply) => {
if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' })
const year = parseInt(request.params.year)
const month = parseInt(request.params.month)
const { pickup_avg_rate, overrides } = request.body
const sessionRes = await pool.query(
`INSERT INTO forecast_sessions (year, month, pickup_avg_rate, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (year, month) DO UPDATE
SET pickup_avg_rate = EXCLUDED.pickup_avg_rate, updated_at = NOW()
RETURNING *`,
[year, month, pickup_avg_rate ?? 135]
)
const session = sessionRes.rows[0]
if (Array.isArray(overrides)) {
for (const o of overrides) {
if (!o.date) continue
await pool.query(
`INSERT INTO day_overrides (session_id, date, pickup_rooms, pickup_avg_rate, dry_override, wet_override, actual_accomm)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (session_id, date) DO UPDATE
SET pickup_rooms = EXCLUDED.pickup_rooms,
pickup_avg_rate = EXCLUDED.pickup_avg_rate,
dry_override = EXCLUDED.dry_override,
wet_override = EXCLUDED.wet_override,
actual_accomm = EXCLUDED.actual_accomm`,
[session.id, o.date,
o.pickup_rooms ?? null, o.pickup_avg_rate ?? null,
o.dry_override ?? null, o.wet_override ?? null, o.actual_accomm ?? null]
)
}
}
return { ok: true }
})
// GET /api/directors-forecast/report/:year/:month
fastify.get('/api/directors-forecast/report/:year/:month', async (request, reply) => {
const year = parseInt(request.params.year)
const month = parseInt(request.params.month)
const lastDayNum = new Date(year, month, 0).getDate()
const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([
getSessionAndOverrides(year, month),
fetchMonthFromApi(year, month, lastDayNum),
])
const sessionRate = parseFloat(session.pickup_avg_rate || 135)
const weekBands = {
'17': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
'814': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
'1521': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
'2228': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
'29end': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
}
let totalAvailable = 0, totalOtbRooms = 0, totalOtbNetRev = 0, totalPickup = 0
let totalAccomm = 0, totalDry = 0, totalWet = 0
let totalBudgetAccomm = 0, totalBudgetDry = 0, totalBudgetWet = 0
let totalLyAccomm = 0, totalLyDry = 0, totalLyWet = 0, totalLyRooms = 0
for (let d = 1; d <= lastDayNum; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const rev = revMap[dateStr] || {}
const rm = roomsMap[dateStr] || {}
const ovr = overrideMap[dateStr] || {}
const isPast = rev.is_past ?? false
const available = rm.available_rooms ?? 0
const otbRooms = rm.otb_rooms ?? 0
const otbNetRev = rev.accom?.otb ?? 0
const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0
const dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : sessionRate
const totalRooms = otbRooms + pickupRooms
const actualAccomm = isPast ? (rev.accom?.otb ?? null) : null
const actualDry = isPast ? (rev.dry?.otb ?? null) : null
const actualWet = isPast ? (rev.wet?.otb ?? null) : null
const fAccomm = (isPast && actualAccomm != null) ? actualAccomm : otbNetRev + pickupRooms * dayRate
const fDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) : (rev.dry?.forecast ?? 0)
const fWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) : (rev.wet?.forecast ?? 0)
totalAvailable += available
totalOtbRooms += otbRooms
totalOtbNetRev += otbNetRev
totalPickup += pickupRooms
totalAccomm += fAccomm
totalDry += fDry
totalWet += fWet
// Budget from API per day
totalBudgetAccomm += rev.accom?.budget ?? 0
totalBudgetDry += rev.dry?.budget ?? 0
totalBudgetWet += rev.wet?.budget ?? 0
// Prior year from API
totalLyAccomm += rev.accom?.prior_final ?? 0
totalLyDry += rev.dry?.prior_final ?? 0
totalLyWet += rev.wet?.prior_final ?? 0
totalLyRooms += rm.prior_year_final ?? 0
const band = weekBand(d)
weekBands[band].accomm += fAccomm
weekBands[band].dry += fDry
weekBands[band].wet += fWet
weekBands[band].rooms += totalRooms
}
const totalForecastRooms = totalOtbRooms + totalPickup
const totalBudget = totalBudgetAccomm + totalBudgetDry + totalBudgetWet
const snapshotsRes = await pool.query(
`SELECT id, snapshot_at, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct
FROM forecast_snapshots WHERE session_id = $1 ORDER BY snapshot_at ASC`,
[session.id]
)
return {
year, month,
forecast: {
rooms: totalForecastRooms, accomm: totalAccomm, dry: totalDry, wet: totalWet,
total: totalAccomm + totalDry + totalWet,
occ_pct: totalAvailable > 0 ? (totalForecastRooms / totalAvailable) * 100 : null,
arr: totalForecastRooms > 0 ? totalAccomm / totalForecastRooms : null,
revpar: totalAvailable > 0 ? totalAccomm / totalAvailable : null,
available: totalAvailable,
},
budget: { accomm: totalBudgetAccomm, dry: totalBudgetDry, wet: totalBudgetWet, total: totalBudget },
last_year: { accomm: totalLyAccomm, dry: totalLyDry, wet: totalLyWet, rooms: totalLyRooms, total: totalLyAccomm + totalLyDry + totalLyWet },
otb: {
rooms: totalOtbRooms, accomm: totalOtbNetRev, available: totalAvailable,
occ_pct: totalAvailable > 0 ? (totalOtbRooms / totalAvailable) * 100 : null,
arr: totalOtbRooms > 0 ? totalOtbNetRev / totalOtbRooms : null,
revpar: totalAvailable > 0 ? totalOtbNetRev / totalAvailable : null,
},
pickup: { rooms: totalPickup, accomm: totalPickup * sessionRate, avg_rate: sessionRate },
weekly: Object.entries(weekBands).map(([label, v]) => ({ label, ...v })),
snapshots: snapshotsRes.rows,
}
})
// POST snapshot
fastify.post('/api/directors-forecast/report/:year/:month/snapshot', async (request, reply) => {
if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' })
const year = parseInt(request.params.year)
const month = parseInt(request.params.month)
const { label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct } = request.body
const sessionRes = await pool.query(`SELECT id FROM forecast_sessions WHERE year = $1 AND month = $2`, [year, month])
if (sessionRes.rows.length === 0) return reply.status(404).send({ error: 'Session not found' })
const res = await pool.query(
`INSERT INTO forecast_snapshots (session_id, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[sessionRes.rows[0].id, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct]
)
return res.rows[0]
})
// DELETE snapshot
fastify.delete('/api/directors-forecast/report/:year/:month/snapshot/:id', async (request, reply) => {
if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' })
await pool.query(`DELETE FROM forecast_snapshots WHERE id = $1`, [parseInt(request.params.id)])
return { ok: true }
})
}