Added is_regular and awarness detection
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user