Add Weekly Actuals directors report with charts
Full weekly actuals report matching the spreadsheet layout: - Week summary table (Rooms/Dry/Wet vs Budget vs Last Year vs Last Week) - Grouped bar chart + dept split pie chart - 5-week rolling summary table - Occupancy table by day of week (rooms, occ%, ARR, RevPAR) - Month progress to date (totals + dept split) with progress bar chart - 12-month turnover trend line chart by dept - Utilities placeholder (pending meter readings app) Budget values come from the forecasting API which distributes monthly budget proportionally to daily sales — so high-demand weeks (e.g. Christmas) carry a higher budget share. Gross shown as net × 1.20. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29a119f51d
commit
b97358e9fb
9 changed files with 1433 additions and 6 deletions
253
backend/src/routes/weekly-actual.js
Normal file
253
backend/src/routes/weekly-actual.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { requireAuth } from '../auth.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 toDateStr(d) {
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function addDays(d, n) {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
function buildDayMap(data) {
|
||||
const m = {}
|
||||
for (const d of (data?.data ?? [])) m[d.date] = d
|
||||
return m
|
||||
}
|
||||
|
||||
const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
|
||||
function weekSummary(revDayMap, weekSundayDate, weekEndingDateStr) {
|
||||
let net = 0, budget = 0, ly = 0
|
||||
let net_rooms = 0, net_dry = 0, net_wet = 0
|
||||
let budget_rooms = 0, budget_dry = 0, budget_wet = 0
|
||||
let ly_rooms = 0, ly_dry = 0, ly_wet = 0
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toDateStr(addDays(weekSundayDate, i))
|
||||
const rev = revDayMap[dateStr] || {}
|
||||
const r = parseFloat(rev.accom?.otb ?? 0)
|
||||
const d = parseFloat(rev.dry?.otb ?? 0)
|
||||
const w = parseFloat(rev.wet?.otb ?? 0)
|
||||
const br = parseFloat(rev.accom?.budget ?? 0)
|
||||
const bd = parseFloat(rev.dry?.budget ?? 0)
|
||||
const bw = parseFloat(rev.wet?.budget ?? 0)
|
||||
const lr = parseFloat(rev.accom?.prior_final ?? 0)
|
||||
const ld = parseFloat(rev.dry?.prior_final ?? 0)
|
||||
const lw = parseFloat(rev.wet?.prior_final ?? 0)
|
||||
|
||||
net_rooms += r; net_dry += d; net_wet += w
|
||||
budget_rooms += br; budget_dry += bd; budget_wet += bw
|
||||
ly_rooms += lr; ly_dry += ld; ly_wet += lw
|
||||
net += r + d + w; budget += br + bd + bw; ly += lr + ld + lw
|
||||
}
|
||||
|
||||
return {
|
||||
week_ending: weekEndingDateStr,
|
||||
gross: net * 1.20,
|
||||
net,
|
||||
pct_to_budget: budget > 0 ? ((net - budget) / budget) * 100 : null,
|
||||
budget_net: budget,
|
||||
pct_to_ly: ly > 0 ? ((net - ly) / ly) * 100 : null,
|
||||
ly_net: ly,
|
||||
dept: {
|
||||
rooms: { net: net_rooms, budget: budget_rooms, ly: ly_rooms },
|
||||
dry: { net: net_dry, budget: budget_dry, ly: ly_dry },
|
||||
wet: { net: net_wet, budget: budget_wet, ly: ly_wet },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function weeklyActualRoutes(fastify) {
|
||||
fastify.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/weekly-actual/:date (date = week ending Saturday, YYYY-MM-DD)
|
||||
fastify.get('/api/weekly-actual/:date', async (request, reply) => {
|
||||
const weekEndDate = new Date(request.params.date + 'T00:00:00')
|
||||
if (isNaN(weekEndDate.getTime())) return reply.status(400).send({ error: 'Invalid date' })
|
||||
|
||||
// Week runs Sun → Sat
|
||||
const weekStartDate = addDays(weekEndDate, -6)
|
||||
const year = weekEndDate.getFullYear()
|
||||
const month = weekEndDate.getMonth() + 1
|
||||
|
||||
// 5-week window: start 4 weeks before this week's Sunday
|
||||
const fiveWeekStart = addDays(weekStartDate, -28)
|
||||
|
||||
// Month to date: 1st of month → week ending date
|
||||
const monthStart = new Date(year, month - 1, 1)
|
||||
const monthDays = Math.round((weekEndDate - monthStart) / 86400000) + 1
|
||||
|
||||
// 12-month trend months (most recent last)
|
||||
const trendMonths = []
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(year, month - 1 - i, 1)
|
||||
trendMonths.push({ year: d.getFullYear(), month: d.getMonth() + 1 })
|
||||
}
|
||||
|
||||
// All API calls in parallel
|
||||
const results = await Promise.all([
|
||||
// 35 days of revenue (5 × 7 weeks)
|
||||
fcFetch(`/forecast/revenue?start_date=${toDateStr(fiveWeekStart)}&days=35&type=all`),
|
||||
// This week rooms (for occupancy table)
|
||||
fcFetch(`/forecast/rooms?start_date=${toDateStr(weekStartDate)}&days=7`),
|
||||
// Month to date revenue
|
||||
monthDays > 0
|
||||
? fcFetch(`/forecast/revenue?start_date=${toDateStr(monthStart)}&days=${monthDays}&type=all`)
|
||||
: Promise.resolve({ data: [] }),
|
||||
// 12 monthly trend revenue calls
|
||||
...trendMonths.map(({ year: y, month: m }) => {
|
||||
const firstDay = new Date(y, m - 1, 1)
|
||||
const daysInMon = new Date(y, m, 0).getDate()
|
||||
return fcFetch(`/forecast/revenue?start_date=${toDateStr(firstDay)}&days=${daysInMon}&type=all`)
|
||||
}),
|
||||
])
|
||||
|
||||
const [fiveWeekRevRaw, thisWeekRoomsRaw, monthRevRaw, ...trendRevRaw] = results
|
||||
|
||||
const fiveWeekRevMap = buildDayMap(fiveWeekRevRaw)
|
||||
const thisWeekRoomsMap = buildDayMap(thisWeekRoomsRaw)
|
||||
const monthRevMap = buildDayMap(monthRevRaw)
|
||||
|
||||
// ── Five week summaries ────────────────────────────────────────────────────
|
||||
const fiveWeeks = [0, 1, 2, 3, 4].map(w => {
|
||||
const sundayDate = addDays(weekStartDate, -7 * w)
|
||||
const saturdayDate = addDays(sundayDate, 6)
|
||||
return weekSummary(fiveWeekRevMap, sundayDate, toDateStr(saturdayDate))
|
||||
})
|
||||
|
||||
const thisWeek = fiveWeeks[0]
|
||||
const lastWeek = fiveWeeks[1]
|
||||
|
||||
// ── Occupancy by DOW ──────────────────────────────────────────────────────
|
||||
let totalAvail = 0, totalSold = 0, totalAccomm = 0
|
||||
const daily = []
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStartDate, i)
|
||||
const dateStr = toDateStr(d)
|
||||
const rev = fiveWeekRevMap[dateStr] || {}
|
||||
const rm = thisWeekRoomsMap[dateStr] || {}
|
||||
|
||||
const available = parseInt(rm.available_rooms ?? 0)
|
||||
const rooms_sold = parseInt(rm.otb_rooms ?? 0)
|
||||
const accomm = parseFloat(rev.accom?.otb ?? 0)
|
||||
|
||||
totalAvail += available
|
||||
totalSold += rooms_sold
|
||||
totalAccomm += accomm
|
||||
|
||||
daily.push({
|
||||
date: dateStr,
|
||||
dow: DOW_NAMES[d.getDay()],
|
||||
available,
|
||||
rooms_sold,
|
||||
accomm,
|
||||
arr: rooms_sold > 0 ? accomm / rooms_sold : null,
|
||||
occ_pct: available > 0 ? (rooms_sold / available) * 100 : null,
|
||||
revpar: available > 0 ? accomm / available : null,
|
||||
})
|
||||
}
|
||||
|
||||
const occupancy = {
|
||||
daily,
|
||||
totals: {
|
||||
available: totalAvail,
|
||||
rooms_sold: totalSold,
|
||||
accomm: totalAccomm,
|
||||
arr: totalSold > 0 ? totalAccomm / totalSold : null,
|
||||
occ_pct: totalAvail > 0 ? (totalSold / totalAvail) * 100 : null,
|
||||
revpar: totalAvail > 0 ? totalAccomm / totalAvail : null,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Month progress ─────────────────────────────────────────────────────────
|
||||
let mNet = { rooms: 0, dry: 0, wet: 0 }
|
||||
let mBud = { rooms: 0, dry: 0, wet: 0 }
|
||||
let mLy = { rooms: 0, dry: 0, wet: 0 }
|
||||
|
||||
for (let i = 0; i < monthDays; i++) {
|
||||
const d = addDays(monthStart, i)
|
||||
if (d > weekEndDate) break
|
||||
const dateStr = toDateStr(d)
|
||||
const rev = monthRevMap[dateStr] || {}
|
||||
|
||||
mNet.rooms += parseFloat(rev.accom?.otb ?? 0)
|
||||
mNet.dry += parseFloat(rev.dry?.otb ?? 0)
|
||||
mNet.wet += parseFloat(rev.wet?.otb ?? 0)
|
||||
mBud.rooms += parseFloat(rev.accom?.budget ?? 0)
|
||||
mBud.dry += parseFloat(rev.dry?.budget ?? 0)
|
||||
mBud.wet += parseFloat(rev.wet?.budget ?? 0)
|
||||
mLy.rooms += parseFloat(rev.accom?.prior_final ?? 0)
|
||||
mLy.dry += parseFloat(rev.dry?.prior_final ?? 0)
|
||||
mLy.wet += parseFloat(rev.wet?.prior_final ?? 0)
|
||||
}
|
||||
|
||||
const mNetTotal = mNet.rooms + mNet.dry + mNet.wet
|
||||
const mBudTotal = mBud.rooms + mBud.dry + mBud.wet
|
||||
const mLyTotal = mLy.rooms + mLy.dry + mLy.wet
|
||||
|
||||
const month_progress = {
|
||||
year, month,
|
||||
date_to: toDateStr(weekEndDate),
|
||||
gross: mNetTotal * 1.20,
|
||||
net: mNetTotal,
|
||||
budget_net: mBudTotal,
|
||||
ly_net: mLyTotal,
|
||||
pct_to_budget: mBudTotal > 0 ? ((mNetTotal - mBudTotal) / mBudTotal) * 100 : null,
|
||||
pct_to_ly: mLyTotal > 0 ? ((mNetTotal - mLyTotal) / mLyTotal) * 100 : null,
|
||||
split: {
|
||||
rooms: { net: mNet.rooms, budget_net: mBud.rooms, ly_net: mLy.rooms,
|
||||
pct_to_budget: mBud.rooms > 0 ? ((mNet.rooms - mBud.rooms) / mBud.rooms) * 100 : null,
|
||||
pct_to_ly: mLy.rooms > 0 ? ((mNet.rooms - mLy.rooms) / mLy.rooms) * 100 : null },
|
||||
dry: { net: mNet.dry, budget_net: mBud.dry, ly_net: mLy.dry,
|
||||
pct_to_budget: mBud.dry > 0 ? ((mNet.dry - mBud.dry) / mBud.dry) * 100 : null,
|
||||
pct_to_ly: mLy.dry > 0 ? ((mNet.dry - mLy.dry) / mLy.dry) * 100 : null },
|
||||
wet: { net: mNet.wet, budget_net: mBud.wet, ly_net: mLy.wet,
|
||||
pct_to_budget: mBud.wet > 0 ? ((mNet.wet - mBud.wet) / mBud.wet) * 100 : null,
|
||||
pct_to_ly: mLy.wet > 0 ? ((mNet.wet - mLy.wet) / mLy.wet) * 100 : null },
|
||||
},
|
||||
}
|
||||
|
||||
// ── Monthly trend ─────────────────────────────────────────────────────────
|
||||
const monthly_trend = trendMonths.map((tm, i) => {
|
||||
const data = trendRevRaw[i]?.data ?? []
|
||||
let rooms = 0, dry = 0, wet = 0
|
||||
for (const d of data) {
|
||||
rooms += parseFloat(d.accom?.otb ?? 0)
|
||||
dry += parseFloat(d.dry?.otb ?? 0)
|
||||
wet += parseFloat(d.wet?.otb ?? 0)
|
||||
}
|
||||
return {
|
||||
label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`,
|
||||
rooms, dry, wet,
|
||||
total: rooms + dry + wet,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
week_ending: toDateStr(weekEndDate),
|
||||
week_start: toDateStr(weekStartDate),
|
||||
this_week: thisWeek,
|
||||
last_week: lastWeek.dept,
|
||||
five_weeks: fiveWeeks,
|
||||
occupancy,
|
||||
month_progress,
|
||||
monthly_trend,
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue