hk-planner/backend/src/lib/newbook.js
jtricerolph 4abae5eda1 Initial commit: HK Planner app
Housekeeping workload and hours planning app — port of the WP hotel
housekeeping hours calculator plugin. 7-day occupancy planner with
Newbook PMS integration, staff rota, time requirements, and pickup tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 09:24:14 +00:00

355 lines
13 KiB
JavaScript

const API_BASE = 'https://api.newbook.cloud/rest/'
// In-process bookings cache: key → { data, expiry }
const bookingsCache = new Map()
// ── Date helpers ───────────────────────────────────────────────────────────
export function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function offsetDate(dateStr, days) {
const [y, m, d] = dateStr.split('-').map(Number)
const dt = new Date(y, m - 1, d)
dt.setDate(dt.getDate() + days)
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
}
function dateToMs(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number)
return new Date(y, m - 1, d).getTime()
}
// ── API client ─────────────────────────────────────────────────────────────
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status}`)
const s = await res.json()
return {
username: s.username || '',
password: s.password || '',
apiKey: s.api_key || '',
region: s.region || 'eu',
}
}
async function callApi(endpoint, data = {}) {
const creds = await getCredentials()
if (!creds.username || !creds.password || !creds.apiKey) {
throw new Error('Newbook API credentials not configured')
}
const body = { ...data, region: creds.region, api_key: creds.apiKey }
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 30000)
try {
const res = await fetch(API_BASE + endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: { 'Content-Type': 'application/json', Authorization: `Basic ${auth}` },
body: JSON.stringify(body),
})
clearTimeout(timer)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Newbook API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
} catch (err) {
clearTimeout(timer)
throw err
}
}
export async function fetchBookingsRange(startDate, endDate) {
return callApi('bookings_list', {
period_from: startDate + ' 00:00:00',
period_to: endDate + ' 23:59:59',
list_type: 'staying',
data_offset: 0,
data_limit: 2000,
})
}
export async function fetchBookingsDelta(startDate, endDate) {
return callApi('bookings_list', {
period_from: startDate + ' 00:00:00',
period_to: endDate + ' 23:59:59',
list_type: 'all',
data_offset: 0,
data_limit: 2000,
})
}
export async function fetchSitesList() {
return callApi('sites_list', {})
}
export async function testConnection() {
try {
const resp = await fetchSitesList()
if (resp.data) {
return { ok: true, message: `Connected. Found ${resp.data.length} site(s).` }
}
return { ok: false, error: resp.error || 'No data returned' }
} catch (err) {
return { ok: false, error: err.message }
}
}
// ── Booking data algorithm (port of PHP get_bookings_data) ─────────────────
const ACTIVE_STATUSES = ['Confirmed', 'Unconfirmed', 'Arrived', 'Departed']
const CANCELLED_STATUSES = ['Cancelled', 'No Show']
export async function getBookingsData(weekStart, lastViewed, forceRefresh, getConfig) {
const cacheKey = `bookings_${weekStart}_${lastViewed}`
if (!forceRefresh) {
const cached = bookingsCache.get(cacheKey)
if (cached && Date.now() < cached.expiry) return cached.data
}
const today = todayStr()
const todayMs = dateToMs(today)
const fetchFrom = offsetDate(weekStart, -8)
const endDate = offsetDate(weekStart, 6)
const lastViewedMs = dateToMs(lastViewed)
const [bookingsResp, deltaResp, sitesResp] = await Promise.all([
fetchBookingsRange(fetchFrom, endDate),
fetchBookingsDelta(lastViewed, endDate),
fetchSitesList(),
])
if (!bookingsResp.data) throw new Error(bookingsResp.error || 'No booking data returned')
const sites = sitesResp.data || []
// Build category_map and site → catKey index
const categoryMap = {} // catKey → {name, total_rooms}
const siteToKey = {} // siteId → catKey
for (const site of sites) {
const siteId = site.site_id || ''
const catName = (site.category_name || 'Unknown').trim()
const catKey = catName.toLowerCase()
if (siteId) siteToKey[siteId] = catKey
if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 }
categoryMap[catKey].total_rooms++
}
// Build 7-day date window
const dates = []
for (let i = 0; i < 7; i++) dates.push(offsetDate(weekStart, i))
// day_data[date][catKey] = {departs, stays, arrivals, rooms:{}}
const dayData = {}
for (const d of dates) dayData[d] = {}
// delta_data[date][catKey] = {new:0, cancelled:0}
const deltaData = {}
for (const d of dates) deltaData[d] = {}
// prior_arrivals[catKey][dow] = [{arrival_date, placed_date}]
const priorArrivals = {}
// prior_occ[catKey][date] = {rooms:{siteId:true}}
const priorOcc = {}
// ── Process main bookings ──────────────────────────────────────────────
for (const booking of bookingsResp.data) {
const siteId = booking.site_id || ''
if (!siteId) continue
let catName
if (booking.category_name) {
catName = booking.category_name.trim()
} else if (siteToKey[siteId]) {
catName = categoryMap[siteToKey[siteId]].name
} else {
catName = 'Unknown'
}
const catKey = catName.toLowerCase()
if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 }
const arrivalDate = (booking.booking_arrival || '').slice(0, 10)
const departureDate = (booking.booking_departure || '').slice(0, 10)
if (!arrivalDate || !departureDate) continue
const arrivalMs = dateToMs(arrivalDate)
const departureMs = dateToMs(departureDate)
// This-week occupancy
for (const date of dates) {
const isArriving = arrivalDate === date
const isDeparting = departureDate === date
const isStaying = arrivalDate < date && departureDate > date
if (!isArriving && !isDeparting && !isStaying) continue
if (!dayData[date][catKey]) {
dayData[date][catKey] = { departs: 0, stays: 0, arrivals: 0, rooms: {} }
}
if (isDeparting) dayData[date][catKey].departs++
if (isStaying) dayData[date][catKey].stays++
if (isArriving) dayData[date][catKey].arrivals++
dayData[date][catKey].rooms[siteId] = true
}
// Skip future arrivals for prior-week data
if (arrivalMs >= todayMs) continue
// Prior-week occupancy (7 days before each display date)
for (let i = 0; i < 7; i++) {
const priorDate = offsetDate(today, -(7 - i))
const priorDateMs = dateToMs(priorDate)
if (arrivalMs <= priorDateMs && departureMs > priorDateMs) {
if (!priorOcc[catKey]) priorOcc[catKey] = {}
if (!priorOcc[catKey][priorDate]) priorOcc[catKey][priorDate] = { rooms: {} }
priorOcc[catKey][priorDate].rooms[siteId] = true
}
}
// Prior-week arrival pickup hints (keyed by weekday)
const placedDate = (booking.booking_placed || '').slice(0, 10)
if (!placedDate) continue
const dow = new Date(arrivalDate + 'T00:00:00').getDay() // 0=Sun…6=Sat
if (!priorArrivals[catKey]) priorArrivals[catKey] = {}
if (!priorArrivals[catKey][dow]) priorArrivals[catKey][dow] = []
priorArrivals[catKey][dow].push({ arrival_date: arrivalDate, placed_date: placedDate })
}
// ── Process delta bookings ─────────────────────────────────────────────
const deltaBookings = deltaResp.data || []
for (const booking of deltaBookings) {
const siteId = booking.site_id || ''
if (!siteId) continue
const catName = (booking.category_name || 'Unknown').trim()
const catKey = catName.toLowerCase()
const arrivalDate = (booking.booking_arrival || '').slice(0, 10)
const departureDate = (booking.booking_departure || '').slice(0, 10)
if (!arrivalDate || !departureDate) continue
const status = (booking.booking_status || '').trim()
const isActive = ACTIVE_STATUSES.includes(status)
const isCancelled = CANCELLED_STATUSES.includes(status)
const placedDateStr = (booking.booking_placed || '').slice(0, 10)
const cancelledDateStr = (booking.booking_cancelled || '').slice(0, 10)
const placedMs = placedDateStr ? dateToMs(placedDateStr) : 0
const cancelledMs = cancelledDateStr ? dateToMs(cancelledDateStr) : 0
const isNewSince = isActive && placedMs && placedMs >= lastViewedMs
const isCancelledSince = isCancelled && cancelledMs && cancelledMs >= lastViewedMs
if (!isNewSince && !isCancelledSince) continue
for (const date of dates) {
const isArriving = arrivalDate === date
const isStaying = arrivalDate < date && departureDate > date
if (!isArriving && !isStaying) continue
if (!deltaData[date][catKey]) deltaData[date][catKey] = { new: 0, cancelled: 0 }
if (isNewSince) deltaData[date][catKey].new++
else if (isCancelledSince) deltaData[date][catKey].cancelled++
}
}
// ── Apply saved category order ─────────────────────────────────────────
const savedOrder = (await getConfig('category_order', [])) || []
const excluded = (await getConfig('excluded_categories', [])) || []
const orderedKeys = []
for (const key of savedOrder) {
if (categoryMap[key]) orderedKeys.push(key)
}
for (const key of Object.keys(categoryMap)) {
if (!orderedKeys.includes(key)) orderedKeys.push(key)
}
// ── Build output ───────────────────────────────────────────────────────
const categoriesOut = []
for (const catKey of orderedKeys) {
if (!categoryMap[catKey]) continue
if (excluded.includes(catKey)) continue
const catDays = {}
for (let di = 0; di < dates.length; di++) {
const date = dates[di]
const dateMs = dateToMs(date)
const leadDays = Math.round((dateMs - todayMs) / 86400000)
const dow = new Date(date + 'T00:00:00').getDay()
// Pickup hint — how many last-week same-weekday arrivals were booked within leadDays
let hintCount = 0
if (priorArrivals[catKey]?.[dow]) {
for (const pa of priorArrivals[catKey][dow]) {
const arrMs = dateToMs(pa.arrival_date)
const plMs = dateToMs(pa.placed_date)
const daysBefore = Math.round((arrMs - plMs) / 86400000)
if (daysBefore <= leadDays) hintCount++
}
}
const priorDate = offsetDate(date, -7)
const priorOccCount = priorOcc[catKey]?.[priorDate]
? Object.keys(priorOcc[catKey][priorDate].rooms).length
: 0
const totalRooms = categoryMap[catKey].total_rooms
const priorVacCount = Math.max(0, totalRooms - priorOccCount)
const delta = deltaData[date]?.[catKey] || {}
const dd = dayData[date]?.[catKey]
catDays[date] = dd ? {
total_servicing: Object.keys(dd.rooms).length,
departs: dd.departs,
stays: dd.stays,
arrivals: dd.arrivals,
pickup_hint: hintCount,
pickup_lead: leadDays,
prior_occ: priorOccCount,
prior_vac: priorVacCount,
delta_new: delta.new || 0,
delta_cancelled: delta.cancelled || 0,
} : {
total_servicing: 0, departs: 0, stays: 0, arrivals: 0,
pickup_hint: hintCount, pickup_lead: leadDays,
prior_occ: priorOccCount, prior_vac: priorVacCount,
delta_new: delta.new || 0, delta_cancelled: delta.cancelled || 0,
}
}
categoriesOut.push({
id: catKey,
name: categoryMap[catKey].name,
total_rooms: categoryMap[catKey].total_rooms,
days: catDays,
})
}
const result = { dates, categories: categoriesOut, last_viewed: lastViewed }
bookingsCache.set(cacheKey, { data: result, expiry: Date.now() + 5 * 60 * 1000 })
return result
}