Fix same-day turnover: pick the most-active booking per site, not just the first

pollOnce() previously collapsed multiple bookings overlapping today for one site
down to a single candidate before computing room_state, with a weak tie-break
(prefer 'arrived' status, else whichever the API happened to return first). On a
same-day turnover — departing guest + incoming guest both returned by NewBook's
'staying' list for the same site — if neither was marked 'arrived' yet, this could
arbitrarily lock onto the incoming guest's booking (still in 'booked', prep not
started) instead of the departing guest's (which should be 'cooling_down'), even
though the departing guest was the one actually still checked into the room.

Now every candidate booking for a site is evaluated (each zone applies its own
offsets, so this only resolves once we have the zone), and the state machine picks
whichever produces the most active result: occupied > heating_up > cooling_down >
booked > vacant. Zero behaviour change for the common single-booking-per-site case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 09:55:35 +00:00
parent d10e4884b8
commit 99ec8b9747

View file

@ -16,6 +16,14 @@ const HEAT_STATES = ['heating_up', 'occupied']
const COOL_STATES = ['vacant', 'cooling_down', 'booked']
const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt'] // matches routes/override.js's mhi-control scope
// On a same-day turnover NewBook's 'staying' list returns BOTH the departing guest's
// booking and the incoming guest's booking for one site — there's no single "the"
// booking to pick upfront. Rank each candidate's resulting room_state and take the
// most active one: someone actually in-house beats any prep state; actively prepping
// for an arrival beats a static post-checkout "cooling down" state; a future stay
// that hasn't started its prep window yet is least urgent of the non-vacant states.
const STATE_PRIORITY = { occupied: 4, heating_up: 3, cooling_down: 2, booked: 1, vacant: 0 }
let timerHandle = null
let running = false
@ -230,25 +238,32 @@ export async function pollOnce() {
return
}
// Group every booking overlapping today by site — may be more than one on a
// same-day turnover, resolved per-zone below (each zone applies its own offsets,
// so the "most active" candidate can only be determined once we have the zone).
const bookingsBySite = new Map()
for (const b of bookings) {
const siteId = String(b.site_id ?? b.room_id ?? '')
if (!siteId) continue
// If multiple bookings overlap today for a site, prefer the one that's
// actually arrived, then the one arriving soonest.
const existing = bookingsBySite.get(siteId)
if (!existing) { bookingsBySite.set(siteId, b); continue }
const existingStatus = (existing.booking_status || '').toLowerCase()
const candidateStatus = (b.booking_status || '').toLowerCase()
if (candidateStatus === 'arrived' && existingStatus !== 'arrived') bookingsBySite.set(siteId, b)
if (!bookingsBySite.has(siteId)) bookingsBySite.set(siteId, [])
bookingsBySite.get(siteId).push(b)
}
for (const zone of zones) {
try {
const booking = bookingsBySite.get(String(zone.newbook_site_id)) || null
const candidates = bookingsBySite.get(String(zone.newbook_site_id)) || []
// Cache the booking on every successful fetch so a future failed fetch has
// something to fall back to (booking_cache is per-zone, TTL is implicit —
let booking = null, newState = 'vacant'
for (const b of candidates) {
const schedule = calculateHeatingSchedule(zone, b, defaults)
const st = determineRoomState(b, schedule, now)
if (!booking || STATE_PRIORITY[st] > STATE_PRIORITY[newState]) {
booking = b; newState = st
}
}
// Cache the winning booking on every successful fetch so a future failed fetch
// has something to fall back to (booking_cache is per-zone, TTL is implicit —
// the fail-safe path above never even reaches this loop on a failed fetch).
await pool.query(
`INSERT INTO booking_cache (zone_id, booking_data, fetched_at) VALUES ($1, $2, NOW())
@ -256,8 +271,6 @@ export async function pollOnce() {
[zone.id, booking ? JSON.stringify(booking) : null]
)
const schedule = booking ? calculateHeatingSchedule(zone, booking, defaults) : null
const newState = determineRoomState(booking, schedule, now)
const prev = await getZoneState(zone.id)
const oldState = prev?.room_state || 'vacant'