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>
This commit is contained in:
commit
4abae5eda1
28 changed files with 2475 additions and 0 deletions
355
backend/src/lib/newbook.js
Normal file
355
backend/src/lib/newbook.js
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue