Worksheet save now persists forecast_dry/forecast_wet per day to day_overrides so the report page always shows the same totals the user saw on the worksheet, rather than making a second independent forecasting API call that can return slightly different values. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
326 lines
14 KiB
JavaScript
326 lines
14 KiB
JavaScript
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 }
|
||
})
|
||
}
|