From d61897b929acaf35507105a120102dfe28d77234 Mon Sep 17 00:00:00 2001 From: Jonas Linter <{email_address}> Date: Wed, 3 Dec 2025 17:59:30 +0100 Subject: [PATCH] Added is_regular and awarness detection --- src/alpine_bits_python/api.py | 2 + src/alpine_bits_python/conversion_service.py | 162 ++++++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index c6ba9e2..5a053db 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -1199,6 +1199,8 @@ async def _process_conversion_xml_background( conversion_service = ConversionService(session_maker, hotel.hotel_id) processing_stats = await conversion_service.process_conversion_xml(xml_content, run_full_guest_matching=True) + await conversion_service.classify_regular_guests(24) + _LOGGER.info( "Conversion processing complete for %s: %s", filename, processing_stats ) diff --git a/src/alpine_bits_python/conversion_service.py b/src/alpine_bits_python/conversion_service.py index 4130ebc..526a19f 100644 --- a/src/alpine_bits_python/conversion_service.py +++ b/src/alpine_bits_python/conversion_service.py @@ -3,8 +3,8 @@ import asyncio import xml.etree.ElementTree as ET from dataclasses import dataclass, field -from datetime import UTC, date, datetime -from decimal import Decimal +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal, InvalidOperation from typing import Any from sqlalchemy import or_, select @@ -1510,6 +1510,164 @@ class ConversionService: return matched_reservation, matched_customer + async def classify_regular_guests( + self, updated_within_hours: int | None = 24 + ) -> dict[str, int]: + """Classify recently updated guests as regular/awareness guests. + + Args: + updated_within_hours: Only evaluate guests whose last_seen timestamp + is within this many hours. Use None to scan all guests for this hotel. + + Returns: + Dictionary with counts of processed guests and updates performed. + + """ + if not self.hotel_id: + _LOGGER.warning("Cannot classify regular guests without hotel_id context") + return { + "processed": 0, + "updated": 0, + "regular": 0, + "awareness": 0, + } + + cutoff: datetime | None = None + if updated_within_hours is not None and updated_within_hours > 0: + cutoff = datetime.now(UTC) - timedelta(hours=updated_within_hours) + + if self.session_maker: + session = await self.session_maker.create_session() + close_session = True + else: + session = self.session + close_session = False + + if not session: + _LOGGER.warning("Cannot classify regular guests without an active session") + return { + "processed": 0, + "updated": 0, + "regular": 0, + "awareness": 0, + } + + stats = { + "processed": 0, + "updated": 0, + "regular": 0, + "awareness": 0, + } + + try: + query = ( + select(ConversionGuest) + .where(ConversionGuest.hotel_id == self.hotel_id) + .options( + selectinload(ConversionGuest.conversions).selectinload( + Conversion.conversion_rooms + ) + ) + ) + if cutoff is not None: + query = query.where(ConversionGuest.last_seen >= cutoff) + + result = await session.execute(query) + guests = result.scalars().all() + + for guest in guests: + stats["processed"] += 1 + is_regular, is_awareness = self._evaluate_guest_regularity(guest) + + if guest.is_regular == is_regular and ( + guest.is_awareness_guest or False + ) == is_awareness: + if is_regular: + stats["regular"] += 1 + if is_awareness: + stats["awareness"] += 1 + continue + + guest.is_regular = is_regular + guest.is_awareness_guest = is_awareness + stats["updated"] += 1 + if is_regular: + stats["regular"] += 1 + if is_awareness: + stats["awareness"] += 1 + + await session.commit() + except Exception as exc: + await session.rollback() + _LOGGER.exception("Failed to classify regular guests: %s", exc) + finally: + if close_session: + await session.close() + + return stats + + def _evaluate_guest_regularity( + self, guest: ConversionGuest + ) -> tuple[bool, bool]: + """Determine whether a guest qualifies as regular/awareness.""" + paying_stays: list[tuple[date, bool]] = [] + + for conversion in guest.conversions: + if not conversion.conversion_rooms: + continue + + for room in conversion.conversion_rooms: + if not self._is_paying_room(room): + continue + + stay_date = self._stay_reference_date(conversion, room) + paying_stays.append( + ( + stay_date, + bool(conversion.directly_attributable), + ) + ) + + if not paying_stays: + return False, False + + paying_stays.sort(key=lambda item: item[0]) + earliest_date, earliest_direct = paying_stays[0] + + if not earliest_direct: + return True, False + + if len(paying_stays) > 1: + return True, True + + return False, False + + @staticmethod + def _is_paying_room(room: ConversionRoom) -> bool: + """Return True if the room entry represents a paying, completed stay.""" + if not room.room_status or room.room_status.lower() != "departed": + return False + if room.total_revenue in (None, 0): + return False + try: + revenue_value = Decimal(str(room.total_revenue)) + except (InvalidOperation, ValueError, TypeError): + return False + return revenue_value > 0 + + @staticmethod + def _stay_reference_date(conversion: Conversion, room: ConversionRoom) -> date: + """Pick the best available date for ordering stays chronologically.""" + if conversion.reservation_date: + return conversion.reservation_date + if room.arrival_date: + return room.arrival_date + if room.departure_date: + return room.departure_date + if conversion.creation_time: + return conversion.creation_time.date() + return date.min + async def _find_customer_for_guest( self, guest: ConversionGuest, session: AsyncSession ) -> Customer | None: