diff --git a/backend/src/routes/directors-forecast.js b/backend/src/routes/directors-forecast.js index 954f8c9e..02c0a507 100644 --- a/backend/src/routes/directors-forecast.js +++ b/backend/src/routes/directors-forecast.js @@ -1,6 +1,17 @@ import { requireAuth, hasCap } from '../auth.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) { if (day <= 7) return '1–7' @@ -31,39 +42,18 @@ async function getSessionAndOverrides(year, month) { 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 +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, available: roomsData.total_rooms } } export async function directorsForecastRoutes(fastify) { @@ -77,89 +67,62 @@ export async function directorsForecastRoutes(fastify) { 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 lastDayNum = new Date(year, month, 0).getDate() + const today = new Date().toISOString().split('T')[0] + const sessionRate = 135 - 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 = {} - 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 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 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 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 otbRooms = rm.otb_rooms ?? 0 + const otbNetRev = rev.accom?.otb ?? 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 + // ML pickup suggestion from the forecasting model + const mlSuggestion = rm.pickup_rooms ?? 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 + // 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 - const mlSuggestion = mlMap[dateStr] != null - ? Math.max(0, Math.round(mlMap[dateStr] - otbRooms)) : 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 - let forecastAccomm - if (isPast && actualAccomm != null) forecastAccomm = actualAccomm - else forecastAccomm = otbNetRev + pickupRooms * dayRate + // Forecast values + const forecastAccomm = isPast && actualAccomm != null + ? actualAccomm + : otbNetRev + pickupRooms * dayRate - const forecastDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) - : (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow)) + const forecastDry = ovr.dry_override != null + ? parseFloat(ovr.dry_override) + : (rev.dry?.forecast ?? 0) - const forecastWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) - : (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow)) + 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, + 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, @@ -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 @@ -222,79 +185,77 @@ export async function directorsForecastRoutes(fastify) { 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) + 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 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 } } + 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 isPast = dateStr < today - const dow = new Date(dateStr + 'T00:00:00').getDay() + const rev = revMap[dateStr] || {} + const rm = roomsMap[dateStr] || {} + const ovr = overrideMap[dateStr] || {} - 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 isPast = rev.is_past ?? false + 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 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)) + const actualAccomm = isPast ? (rev.accom?.otb ?? null) : null + const actualDry = isPast ? (rev.dry?.otb ?? null) : null + const actualWet = isPast ? (rev.wet?.otb ?? null) : null - totalAvailable += available; totalOtbRooms += otbRooms; totalOtbNetRev += otbNetRev - totalPickup += pickupRooms; totalAccomm += fAccomm; totalDry += fDry; totalWet += fWet + 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 + 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 @@ -312,8 +273,8 @@ export async function directorsForecastRoutes(fastify) { 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 }, + 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, @@ -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) => { 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] }) - // DELETE /api/directors-forecast/report/:year/:month/snapshot/:id + // 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)]) diff --git a/docker-compose.yml b/docker-compose.yml index e461f3de..b4b52bf5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,8 @@ services: - apparmor=unconfined environment: - 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} - SETTINGS_URL=${SETTINGS_URL} - SETTINGS_SECRET=${SETTINGS_SECRET}