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:
parent
92e2c48f48
commit
018957123f
10 changed files with 1128 additions and 5 deletions
354
backend/src/routes/directors-forecast.js
Normal file
354
backend/src/routes/directors-forecast.js
Normal 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 '1–7'
|
||||
if (day <= 14) return '8–14'
|
||||
if (day <= 21) return '15–21'
|
||||
if (day <= 28) return '22–28'
|
||||
return '29–end'
|
||||
}
|
||||
|
||||
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 = { '1–7': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '8–14': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '15–21': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '22–28': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '29–end': { 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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue