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 '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, forecast_dry, forecast_wet 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, dowAlign = true) { const startDate = `${year}-${String(month).padStart(2, '0')}-01` const days = lastDayNum const da = `&dow_align=${dowAlign}` const [revData, roomsData] = await Promise.all([ fcFetch(`/forecast/revenue?start_date=${startDate}&days=${days}&type=all${da}`), fcFetch(`/forecast/rooms?start_date=${startDate}&days=${days}${da}`), ]) 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 dowAlign = request.query.dow_align !== 'false' 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, dowAlign), ]) 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, forecast_dry, forecast_wet) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 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, forecast_dry = EXCLUDED.forecast_dry, forecast_wet = EXCLUDED.forecast_wet`, [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, o.forecast_dry ?? null, o.forecast_wet ?? 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 dowAlign = request.query.dow_align !== 'false' const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([ getSessionAndOverrides(year, month), fetchMonthFromApi(year, month, lastDayNum, dowAlign), ]) const sessionRate = parseFloat(session.pickup_avg_rate || 135) 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 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.forecast_dry != null ? parseFloat(ovr.forecast_dry) : ovr.dry_override != null ? parseFloat(ovr.dry_override) : (rev.dry?.forecast ?? 0) const fWet = ovr.forecast_wet != null ? parseFloat(ovr.forecast_wet) : 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 } }) }