Switch Directors Forecast to use forecasting API instead of direct DB

Replace queryForecast/forecast-db.js with fetch() calls to the
forecasting app's public API (/forecasting/api/public/forecast/revenue
and /forecast/rooms). This removes the need for cross-DB grants and the
FORECAST_DATABASE_URL — all data comes from FORECASTING_API_KEY +
FORECASTING_URL. The DOW-history algorithm is also removed since the
covers model in the forecasting API already handles dry/wet future
forecasts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 10:36:08 +00:00
parent 018957123f
commit bd9ddbeda9
2 changed files with 119 additions and 157 deletions

View file

@ -1,6 +1,17 @@
import { requireAuth, hasCap } from '../auth.js' import { requireAuth, hasCap } from '../auth.js'
import { pool } from '../db.js' import { pool } from '../db.js'
import { queryForecast } from '../forecast-db.js'
const FORECASTING_URL = process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
async function fcFetch(path) {
const apiKey = process.env.FORECASTING_API_KEY
if (!apiKey) throw new Error('FORECASTING_API_KEY not configured')
const res = await fetch(`${FORECASTING_URL}/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) { function weekBand(day) {
if (day <= 7) return '17' if (day <= 7) return '17'
@ -31,39 +42,18 @@ async function getSessionAndOverrides(year, month) {
return { session, overrideMap } return { session, overrideMap }
} }
async function getDowHistory(firstDayOfMonth) { async function fetchMonthFromApi(year, month, lastDayNum) {
const lookbackDate = new Date(firstDayOfMonth) const startDate = `${year}-${String(month).padStart(2, '0')}-01`
lookbackDate.setDate(lookbackDate.getDate() - 70) const days = lastDayNum
const lookbackStr = lookbackDate.toISOString().split('T')[0] const [revData, roomsData] = await Promise.all([
const yesterdayDate = new Date() fcFetch(`/forecast/revenue?start_date=${startDate}&days=${days}&type=all`),
yesterdayDate.setDate(yesterdayDate.getDate() - 1) fcFetch(`/forecast/rooms?start_date=${startDate}&days=${days}`),
const yesterday = yesterdayDate.toISOString().split('T')[0] ])
const revMap = {}
let rows = [] for (const d of revData.data) revMap[d.date] = d
try { const roomsMap = {}
rows = await queryForecast( for (const d of roomsData.data) roomsMap[d.date] = d
`SELECT date::text, dry, wet FROM newbook_net_revenue_data return { revMap, roomsMap, available: roomsData.total_rooms }
WHERE date >= $1 AND date <= $2 ORDER BY date DESC`,
[lookbackStr, yesterday]
)
} catch {}
const dowHistory = { dry: {}, wet: {} }
for (const row of rows) {
const dow = new Date(row.date + 'T00:00:00').getDay()
if (!dowHistory.dry[dow]) { dowHistory.dry[dow] = []; dowHistory.wet[dow] = [] }
if (dowHistory.dry[dow].length < 7) {
dowHistory.dry[dow].push(parseFloat(row.dry || 0))
dowHistory.wet[dow].push(parseFloat(row.wet || 0))
}
}
return dowHistory
}
function dowAvg(dowHistory, field, dow) {
const vals = dowHistory[field][dow]
if (!vals || vals.length === 0) return 0
return vals.reduce((a, b) => a + b, 0) / vals.length
} }
export async function directorsForecastRoutes(fastify) { export async function directorsForecastRoutes(fastify) {
@ -77,89 +67,62 @@ export async function directorsForecastRoutes(fastify) {
return reply.status(400).send({ error: 'Invalid year/month' }) return reply.status(400).send({ error: 'Invalid year/month' })
} }
const firstDay = `${year}-${String(month).padStart(2, '0')}-01` const lastDayNum = new Date(year, month, 0).getDate()
const lastDayNum = new Date(year, month, 0).getDate() const today = new Date().toISOString().split('T')[0]
const lastDay = `${year}-${String(month).padStart(2, '0')}-${String(lastDayNum).padStart(2, '0')}` const sessionRate = 135
const today = new Date().toISOString().split('T')[0]
const { session, overrideMap } = await getSessionAndOverrides(year, month) const [{ session, overrideMap }, { revMap, roomsMap, available }] = await Promise.all([
getSessionAndOverrides(year, month),
fetchMonthFromApi(year, month, lastDayNum),
])
let otbMap = {} const rate = parseFloat(session.pickup_avg_rate || sessionRate)
try {
const rows = await queryForecast(
`SELECT date::text, rooms_count, booking_count, net_booking_rev_total
FROM newbook_bookings_stats WHERE date >= $1 AND date <= $2`,
[firstDay, lastDay]
)
for (const r of rows) otbMap[r.date] = r
} catch (e) { fastify.log.warn('OTB fetch failed:', e.message) }
let revenueMap = {}
try {
const rows = await queryForecast(
`SELECT date::text, accommodation, dry, wet
FROM newbook_net_revenue_data WHERE date >= $1 AND date <= $2`,
[firstDay, lastDay]
)
for (const r of rows) revenueMap[r.date] = r
} catch (e) { fastify.log.warn('Revenue fetch failed:', e.message) }
let mlMap = {}
try {
const rows = await queryForecast(
`SELECT DISTINCT ON (forecast_date) forecast_date::text, predicted_value
FROM forecasts
WHERE forecast_date >= $1 AND forecast_date <= $2 AND forecast_type = 'hotel_room_nights'
ORDER BY forecast_date, generated_at DESC`,
[firstDay, lastDay]
)
for (const r of rows) mlMap[r.forecast_date] = parseFloat(r.predicted_value)
} catch {}
const dowHistory = await getDowHistory(new Date(firstDay))
const sessionRate = parseFloat(session.pickup_avg_rate || 135)
const days = [] const days = []
for (let d = 1; d <= lastDayNum; d++) { for (let d = 1; d <= lastDayNum; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}` const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const isPast = dateStr < today const rev = revMap[dateStr] || {}
const rm = roomsMap[dateStr] || {}
const ovr = overrideMap[dateStr] || {}
const isPast = rev.is_past ?? (dateStr < today)
const isToday = dateStr === today const isToday = dateStr === today
const dow = new Date(dateStr + 'T00:00:00').getDay() const dow = new Date(dateStr + 'T00:00:00').getDay()
const otb = otbMap[dateStr] || {} const otbRooms = rm.otb_rooms ?? 0
const rev = revenueMap[dateStr] || {} const otbNetRev = rev.accom?.otb ?? 0
const ovr = overrideMap[dateStr] || {}
const available = parseInt(otb.rooms_count || 0)
const otbRooms = parseInt(otb.booking_count || 0)
const otbNetRev = parseFloat(otb.net_booking_rev_total || 0)
const avgRateNet = otbRooms > 0 ? otbNetRev / otbRooms : null const avgRateNet = otbRooms > 0 ? otbNetRev / otbRooms : null
const actualAccomm = ovr.actual_accomm != null ? parseFloat(ovr.actual_accomm) // ML pickup suggestion from the forecasting model
: (rev.accommodation != null ? parseFloat(rev.accommodation) : null) const mlSuggestion = rm.pickup_rooms ?? null
const actualDry = rev.dry != null ? parseFloat(rev.dry) : null
const actualWet = rev.wet != null ? parseFloat(rev.wet) : null
const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0 // Actuals (from API — otb == actual for past dates)
const dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : sessionRate const actualAccomm = isPast ? (rev.accom?.otb ?? null) : null
const totalRooms = otbRooms + pickupRooms const actualDry = isPast ? (rev.dry?.otb ?? null) : null
const actualWet = isPast ? (rev.wet?.otb ?? null) : null
const mlSuggestion = mlMap[dateStr] != null // User overrides
? Math.max(0, Math.round(mlMap[dateStr] - otbRooms)) : null const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0
const dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : rate
let forecastAccomm // Forecast values
if (isPast && actualAccomm != null) forecastAccomm = actualAccomm const forecastAccomm = isPast && actualAccomm != null
else forecastAccomm = otbNetRev + pickupRooms * dayRate ? actualAccomm
: otbNetRev + pickupRooms * dayRate
const forecastDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) const forecastDry = ovr.dry_override != null
: (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow)) ? parseFloat(ovr.dry_override)
: (rev.dry?.forecast ?? 0)
const forecastWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) const forecastWet = ovr.wet_override != null
: (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow)) ? parseFloat(ovr.wet_override)
: (rev.wet?.forecast ?? 0)
const totalRooms = otbRooms + pickupRooms
days.push({ days.push({
date: dateStr, dow, available, otb_rooms: otbRooms, date: dateStr, dow, available,
otb_net_rev: otbNetRev, avg_rate_net: avgRateNet, otb_rooms: otbRooms, otb_net_rev: otbNetRev, avg_rate_net: avgRateNet,
is_past: isPast, is_today: isToday, is_past: isPast, is_today: isToday,
actual_accomm: actualAccomm, actual_dry: actualDry, actual_wet: actualWet, actual_accomm: actualAccomm, actual_dry: actualDry, actual_wet: actualWet,
ml_suggestion: mlSuggestion, ml_suggestion: mlSuggestion,
@ -175,7 +138,7 @@ export async function directorsForecastRoutes(fastify) {
}) })
} }
return { session: { ...session, pickup_avg_rate: sessionRate }, days } return { session: { ...session, pickup_avg_rate: rate }, days }
}) })
// PUT /api/directors-forecast/worksheet/:year/:month — save overrides // PUT /api/directors-forecast/worksheet/:year/:month — save overrides
@ -222,79 +185,77 @@ export async function directorsForecastRoutes(fastify) {
fastify.get('/api/directors-forecast/report/:year/:month', async (request, reply) => { fastify.get('/api/directors-forecast/report/:year/:month', async (request, reply) => {
const year = parseInt(request.params.year) const year = parseInt(request.params.year)
const month = parseInt(request.params.month) const month = parseInt(request.params.month)
const firstDay = `${year}-${String(month).padStart(2, '0')}-01`
const lastDayNum = new Date(year, month, 0).getDate() const lastDayNum = new Date(year, month, 0).getDate()
const lastDay = `${year}-${String(month).padStart(2, '0')}-${String(lastDayNum).padStart(2, '0')}`
const today = new Date().toISOString().split('T')[0]
const { session, overrideMap } = await getSessionAndOverrides(year, month) const [{ session, overrideMap }, { revMap, roomsMap, available }] = await Promise.all([
getSessionAndOverrides(year, month),
fetchMonthFromApi(year, month, lastDayNum),
])
let otbRows = [], revRows = []
try { otbRows = await queryForecast(`SELECT date::text, rooms_count, booking_count, net_booking_rev_total FROM newbook_bookings_stats WHERE date >= $1 AND date <= $2`, [firstDay, lastDay]) } catch {}
try { revRows = await queryForecast(`SELECT date::text, accommodation, dry, wet FROM newbook_net_revenue_data WHERE date >= $1 AND date <= $2`, [firstDay, lastDay]) } catch {}
let budget = { accomm: 0, dry: 0, wet: 0 }
try {
const rows = await queryForecast(`SELECT budget_type, budget_value FROM monthly_budgets WHERE year = $1 AND month = $2`, [year, month])
for (const b of rows) {
if (b.budget_type === 'net_accom') budget.accomm = parseFloat(b.budget_value || 0)
if (b.budget_type === 'net_dry') budget.dry = parseFloat(b.budget_value || 0)
if (b.budget_type === 'net_wet') budget.wet = parseFloat(b.budget_value || 0)
}
} catch {}
let lastYear = { accomm: 0, dry: 0, wet: 0, rooms: 0 }
try {
const lyFirst = `${year - 1}-${String(month).padStart(2, '0')}-01`
const lyLastNum = new Date(year - 1, month, 0).getDate()
const lyLast = `${year - 1}-${String(month).padStart(2, '0')}-${String(lyLastNum).padStart(2, '0')}`
const [lyRev] = await queryForecast(`SELECT SUM(accommodation) as accomm, SUM(dry) as dry, SUM(wet) as wet FROM newbook_net_revenue_data WHERE date >= $1 AND date <= $2`, [lyFirst, lyLast])
if (lyRev) { lastYear.accomm = parseFloat(lyRev.accomm || 0); lastYear.dry = parseFloat(lyRev.dry || 0); lastYear.wet = parseFloat(lyRev.wet || 0) }
const [lyOtb] = await queryForecast(`SELECT SUM(booking_count) as rooms FROM newbook_bookings_stats WHERE date >= $1 AND date <= $2`, [lyFirst, lyLast])
if (lyOtb) lastYear.rooms = parseInt(lyOtb.rooms || 0)
} catch {}
const dowHistory = await getDowHistory(new Date(firstDay))
const sessionRate = parseFloat(session.pickup_avg_rate || 135) const sessionRate = parseFloat(session.pickup_avg_rate || 135)
const otbMap = {}; for (const r of otbRows) otbMap[r.date] = r const weekBands = {
const revMap = {}; for (const r of revRows) revMap[r.date] = r '17': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
'814': { accomm: 0, dry: 0, wet: 0, rooms: 0 },
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 } } '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 totalAvailable = 0, totalOtbRooms = 0, totalOtbNetRev = 0, totalPickup = 0
let totalAccomm = 0, totalDry = 0, totalWet = 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++) { for (let d = 1; d <= lastDayNum; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}` const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const isPast = dateStr < today const rev = revMap[dateStr] || {}
const dow = new Date(dateStr + 'T00:00:00').getDay() const rm = roomsMap[dateStr] || {}
const ovr = overrideMap[dateStr] || {}
const otb = otbMap[dateStr] || {}; const rev = revMap[dateStr] || {}; const ovr = overrideMap[dateStr] || {} const isPast = rev.is_past ?? false
const available = parseInt(otb.rooms_count || 0) const otbRooms = rm.otb_rooms ?? 0
const otbRooms = parseInt(otb.booking_count || 0) const otbNetRev = rev.accom?.otb ?? 0
const otbNetRev = parseFloat(otb.net_booking_rev_total || 0) const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0
const actualAccomm = ovr.actual_accomm != null ? parseFloat(ovr.actual_accomm) : (rev.accommodation != null ? parseFloat(rev.accommodation) : null)
const actualDry = rev.dry != null ? parseFloat(rev.dry) : null
const actualWet = rev.wet != null ? parseFloat(rev.wet) : null
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 dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : sessionRate
const totalRooms = otbRooms + pickupRooms const totalRooms = otbRooms + pickupRooms
const fAccomm = (isPast && actualAccomm != null) ? actualAccomm : otbNetRev + pickupRooms * dayRate const actualAccomm = isPast ? (rev.accom?.otb ?? null) : null
const fDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) : (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow)) const actualDry = isPast ? (rev.dry?.otb ?? null) : null
const fWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) : (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow)) const actualWet = isPast ? (rev.wet?.otb ?? null) : null
totalAvailable += available; totalOtbRooms += otbRooms; totalOtbNetRev += otbNetRev const fAccomm = (isPast && actualAccomm != null) ? actualAccomm : otbNetRev + pickupRooms * dayRate
totalPickup += pickupRooms; totalAccomm += fAccomm; totalDry += fDry; totalWet += fWet 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) const band = weekBand(d)
weekBands[band].accomm += fAccomm; weekBands[band].dry += fDry weekBands[band].accomm += fAccomm
weekBands[band].wet += fWet; weekBands[band].rooms += totalRooms weekBands[band].dry += fDry
weekBands[band].wet += fWet
weekBands[band].rooms += totalRooms
} }
const totalForecastRooms = totalOtbRooms + totalPickup const totalForecastRooms = totalOtbRooms + totalPickup
const totalBudget = totalBudgetAccomm + totalBudgetDry + totalBudgetWet
const snapshotsRes = await pool.query( 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 `SELECT id, snapshot_at, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct
@ -312,8 +273,8 @@ export async function directorsForecastRoutes(fastify) {
revpar: totalAvailable > 0 ? totalAccomm / totalAvailable : null, revpar: totalAvailable > 0 ? totalAccomm / totalAvailable : null,
available: totalAvailable, available: totalAvailable,
}, },
budget: { ...budget, total: budget.accomm + budget.dry + budget.wet }, budget: { accomm: totalBudgetAccomm, dry: totalBudgetDry, wet: totalBudgetWet, total: totalBudget },
last_year: { ...lastYear, total: lastYear.accomm + lastYear.dry + lastYear.wet }, last_year: { accomm: totalLyAccomm, dry: totalLyDry, wet: totalLyWet, rooms: totalLyRooms, total: totalLyAccomm + totalLyDry + totalLyWet },
otb: { otb: {
rooms: totalOtbRooms, accomm: totalOtbNetRev, available: totalAvailable, rooms: totalOtbRooms, accomm: totalOtbNetRev, available: totalAvailable,
occ_pct: totalAvailable > 0 ? (totalOtbRooms / totalAvailable) * 100 : null, occ_pct: totalAvailable > 0 ? (totalOtbRooms / totalAvailable) * 100 : null,
@ -326,7 +287,7 @@ export async function directorsForecastRoutes(fastify) {
} }
}) })
// POST /api/directors-forecast/report/:year/:month/snapshot // POST snapshot
fastify.post('/api/directors-forecast/report/:year/:month/snapshot', async (request, reply) => { 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' }) if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' })
@ -345,7 +306,7 @@ export async function directorsForecastRoutes(fastify) {
return res.rows[0] return res.rows[0]
}) })
// DELETE /api/directors-forecast/report/:year/:month/snapshot/:id // DELETE snapshot
fastify.delete('/api/directors-forecast/report/:year/:month/snapshot/:id', async (request, reply) => { 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' }) 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)]) await pool.query(`DELETE FROM forecast_snapshots WHERE id = $1`, [parseInt(request.params.id)])

View file

@ -5,7 +5,8 @@ services:
- apparmor=unconfined - apparmor=unconfined
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- FORECAST_DATABASE_URL=${FORECAST_DATABASE_URL:-} - FORECASTING_URL=${FORECASTING_URL:-http://10.10.10.113:3080}
- FORECASTING_API_KEY=${FORECASTING_API_KEY:-}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- SETTINGS_URL=${SETTINGS_URL} - SETTINGS_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET} - SETTINGS_SECRET=${SETTINGS_SECRET}