Add Directors Forecast interactive report section

Embeds a Directors Forecast section in the Reports sidebar (hard-coded
under "Directors Reports"). Worksheet tab: day-by-day OTB grid with
editable pickup rooms, dry/wet overrides, ML suggestion column and live
forecast recalculation. Report tab: forecast vs budget vs last-year
comparison, occupancy summary, weekly revenue bands and snapshot tracking.

Backend: forecast-db.js (read-only pool to forecasting_db), new tables
(forecast_sessions, day_overrides, forecast_snapshots) and full REST
routes under /api/directors-forecast. docker-compose FORECAST_DATABASE_URL
added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 10:24:47 +00:00
parent 92e2c48f48
commit 018957123f
10 changed files with 1128 additions and 5 deletions

View file

@ -17,7 +17,48 @@ export async function initDb() {
error TEXT,
ran_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS report_runs_ran_at_idx ON report_runs (ran_at DESC);
CREATE INDEX IF NOT EXISTS report_runs_ran_at_idx ON report_runs (ran_at DESC);
CREATE INDEX IF NOT EXISTS report_runs_report_id_idx ON report_runs (report_id);
CREATE TABLE IF NOT EXISTS forecast_sessions (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
pickup_avg_rate DECIMAL(10,2) NOT NULL DEFAULT 135.00,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(year, month)
);
CREATE TABLE IF NOT EXISTS day_overrides (
id SERIAL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES forecast_sessions(id) ON DELETE CASCADE,
date DATE NOT NULL,
pickup_rooms INTEGER,
pickup_avg_rate DECIMAL(10,2),
dry_override DECIMAL(12,2),
wet_override DECIMAL(12,2),
actual_accomm DECIMAL(12,2),
UNIQUE(session_id, date)
);
CREATE INDEX IF NOT EXISTS idx_day_overrides_session ON day_overrides(session_id);
CREATE INDEX IF NOT EXISTS idx_day_overrides_date ON day_overrides(date);
CREATE TABLE IF NOT EXISTS forecast_snapshots (
id SERIAL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES forecast_sessions(id) ON DELETE CASCADE,
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
label TEXT,
forecast_rooms INTEGER,
forecast_accomm DECIMAL(12,2),
forecast_dry DECIMAL(12,2),
forecast_wet DECIMAL(12,2),
forecast_total DECIMAL(12,2),
otb_rooms INTEGER,
pickup_rooms INTEGER,
occ_pct DECIMAL(5,2)
);
CREATE INDEX IF NOT EXISTS idx_forecast_snapshots_session ON forecast_snapshots(session_id);
`)
}

View file

@ -0,0 +1,13 @@
import pg from 'pg'
const { Pool } = pg
export const forecastPool = process.env.FORECAST_DATABASE_URL
? new Pool({ connectionString: process.env.FORECAST_DATABASE_URL })
: null
export async function queryForecast(sql, params) {
if (!forecastPool) throw new Error('FORECAST_DATABASE_URL not configured')
const res = await forecastPool.query(sql, params)
return res.rows
}

View file

@ -3,8 +3,10 @@ import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { reportRoutes } from './routes/reports.js'
import { directorsForecastRoutes } from './routes/directors-forecast.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
@ -12,9 +14,10 @@ await app.register(cors, {
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy' }))
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(reportRoutes)
await app.register(directorsForecastRoutes)
try {
await initDb()

View file

@ -0,0 +1,354 @@
import { requireAuth, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { queryForecast } from '../forecast-db.js'
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 getDowHistory(firstDayOfMonth) {
const lookbackDate = new Date(firstDayOfMonth)
lookbackDate.setDate(lookbackDate.getDate() - 70)
const lookbackStr = lookbackDate.toISOString().split('T')[0]
const yesterdayDate = new Date()
yesterdayDate.setDate(yesterdayDate.getDate() - 1)
const yesterday = yesterdayDate.toISOString().split('T')[0]
let rows = []
try {
rows = await queryForecast(
`SELECT date::text, dry, wet FROM newbook_net_revenue_data
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) {
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 firstDay = `${year}-${String(month).padStart(2, '0')}-01`
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)
let otbMap = {}
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 = []
for (let d = 1; d <= lastDayNum; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const isPast = dateStr < today
const isToday = dateStr === today
const dow = new Date(dateStr + 'T00:00:00').getDay()
const otb = otbMap[dateStr] || {}
const rev = revenueMap[dateStr] || {}
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 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 totalRooms = otbRooms + pickupRooms
const mlSuggestion = mlMap[dateStr] != null
? Math.max(0, Math.round(mlMap[dateStr] - otbRooms)) : null
let forecastAccomm
if (isPast && actualAccomm != null) forecastAccomm = actualAccomm
else forecastAccomm = otbNetRev + pickupRooms * dayRate
const forecastDry = ovr.dry_override != null ? parseFloat(ovr.dry_override)
: (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow))
const forecastWet = ovr.wet_override != null ? parseFloat(ovr.wet_override)
: (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow))
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: sessionRate }, 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 firstDay = `${year}-${String(month).padStart(2, '0')}-01`
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)
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 otbMap = {}; for (const r of otbRows) otbMap[r.date] = r
const revMap = {}; for (const r of revRows) revMap[r.date] = r
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
for (let d = 1; d <= lastDayNum; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const isPast = dateStr < today
const dow = new Date(dateStr + 'T00:00:00').getDay()
const otb = otbMap[dateStr] || {}; const rev = revMap[dateStr] || {}; 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 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 totalRooms = otbRooms + pickupRooms
const fAccomm = (isPast && actualAccomm != null) ? actualAccomm : otbNetRev + pickupRooms * dayRate
const fDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) : (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow))
const fWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) : (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow))
totalAvailable += available; totalOtbRooms += otbRooms; totalOtbNetRev += otbNetRev
totalPickup += pickupRooms; totalAccomm += fAccomm; totalDry += fDry; totalWet += fWet
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 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: { ...budget, total: budget.accomm + budget.dry + budget.wet },
last_year: { ...lastYear, total: lastYear.accomm + lastYear.dry + lastYear.wet },
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 /api/directors-forecast/report/:year/:month/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 /api/directors-forecast/report/:year/:month/snapshot/:id
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 }
})
}