Backend:
- Always fetch full calendar month (not truncated at week ending)
- Add month_daily[] to response (per-day TY/LY by dept, is_actual flag)
Frontend:
- New 'Sales History' section with:
- Monthly dept line chart: 12-month x-axis, TY vs prior year per dept
(Rooms/Dry/Wet solid, LY dashed — same colour per dept)
- Rolling 12-month average trend (moved from Month Progress)
- Month Progress section gains cumulative line chart:
- X-axis = day of month, 6 lines (3 TY solid + 3 LY dashed)
- TY lines stop at week ending; LY spans full month
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
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)
|
||
|
||
// Full calendar month (needed for daily chart — month progress filters by date)
|
||
const monthStart = new Date(year, month - 1, 1)
|
||
const daysInMonth = new Date(year, month, 0).getDate()
|
||
|
||
// 36 months of raw totals needed to compute rolling 12-month averages for
|
||
// 12 display points (indices 23–35) plus their LY equivalents (indices 0–23)
|
||
const trendMonths = []
|
||
for (let i = 35; 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`),
|
||
// Full month revenue (used for month progress totals + daily chart)
|
||
fcFetch(`/forecast/revenue?start_date=${toDateStr(monthStart)}&days=${daysInMonth}&type=all`),
|
||
// 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, fullMonthRevRaw, ...trendRevRaw] = results
|
||
|
||
const fiveWeekRevMap = buildDayMap(fiveWeekRevRaw)
|
||
const thisWeekRoomsMap = buildDayMap(thisWeekRoomsRaw)
|
||
const fullMonthRevMap = buildDayMap(fullMonthRevRaw)
|
||
|
||
// ── 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 < daysInMonth; i++) {
|
||
const d = addDays(monthStart, i)
|
||
if (d > weekEndDate) break
|
||
const dateStr = toDateStr(d)
|
||
const rev = fullMonthRevMap[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 },
|
||
},
|
||
}
|
||
|
||
// ── Month daily (for cumulative progress line chart) ──────────────────────
|
||
const weekEndStr = toDateStr(weekEndDate)
|
||
const month_daily = []
|
||
for (let i = 0; i < daysInMonth; i++) {
|
||
const d = addDays(monthStart, i)
|
||
const dateStr = toDateStr(d)
|
||
const rev = fullMonthRevMap[dateStr] || {}
|
||
month_daily.push({
|
||
day: i + 1,
|
||
date: dateStr,
|
||
ty_rooms: parseFloat(rev.accom?.otb ?? 0),
|
||
ty_dry: parseFloat(rev.dry?.otb ?? 0),
|
||
ty_wet: parseFloat(rev.wet?.otb ?? 0),
|
||
ly_rooms: parseFloat(rev.accom?.prior_final ?? 0),
|
||
ly_dry: parseFloat(rev.dry?.prior_final ?? 0),
|
||
ly_wet: parseFloat(rev.wet?.prior_final ?? 0),
|
||
is_actual: dateStr <= weekEndStr,
|
||
})
|
||
}
|
||
|
||
// ── Monthly trend (rolling 12-month average) ──────────────────────────────
|
||
// Build raw monthly totals for all 36 months
|
||
const allMonthTotals = trendMonths.map((_, i) => {
|
||
const data = trendRevRaw[i]?.data ?? []
|
||
let total = 0
|
||
for (const d of data) {
|
||
total += parseFloat(d.accom?.otb ?? 0)
|
||
+ parseFloat(d.dry?.otb ?? 0)
|
||
+ parseFloat(d.wet?.otb ?? 0)
|
||
}
|
||
return total
|
||
})
|
||
|
||
const rollingAvg = (arr, from, to) =>
|
||
arr.slice(from, to + 1).reduce((s, v) => s + v, 0) / (to - from + 1)
|
||
|
||
// Display points: indices 23–35 (the last 12 months)
|
||
// TY rolling avg at index i = average of allMonthTotals[i-11 .. i]
|
||
// LY rolling avg at index i = average of allMonthTotals[i-23 .. i-12]
|
||
const monthly_trend = []
|
||
for (let i = 23; i < 36; i++) {
|
||
const tm = trendMonths[i]
|
||
monthly_trend.push({
|
||
label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`,
|
||
this_year_avg: rollingAvg(allMonthTotals, i - 11, i),
|
||
last_year_avg: rollingAvg(allMonthTotals, i - 23, i - 12),
|
||
})
|
||
}
|
||
|
||
// Monthly dept split for last 24 months (indices 12–35)
|
||
// — previous 12 months followed by last 12 months
|
||
const monthly_split = []
|
||
for (let i = 12; i < 36; i++) {
|
||
const tm = trendMonths[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)
|
||
}
|
||
monthly_split.push({
|
||
label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`,
|
||
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,
|
||
month_daily,
|
||
monthly_trend,
|
||
monthly_split,
|
||
}
|
||
})
|
||
}
|