merge_db_fixes_to_main #16

Merged
jonas merged 40 commits from merge_db_fixes_to_main into main 2025-12-09 11:37:21 +00:00
2 changed files with 162 additions and 2 deletions
Showing only changes of commit d61897b929 - Show all commits

View File

@@ -1199,6 +1199,8 @@ async def _process_conversion_xml_background(
conversion_service = ConversionService(session_maker, hotel.hotel_id) conversion_service = ConversionService(session_maker, hotel.hotel_id)
processing_stats = await conversion_service.process_conversion_xml(xml_content, run_full_guest_matching=True) processing_stats = await conversion_service.process_conversion_xml(xml_content, run_full_guest_matching=True)
await conversion_service.classify_regular_guests(24)
_LOGGER.info( _LOGGER.info(
"Conversion processing complete for %s: %s", filename, processing_stats "Conversion processing complete for %s: %s", filename, processing_stats
) )

View File

@@ -3,8 +3,8 @@
import asyncio import asyncio
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, date, datetime from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal, InvalidOperation
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import or_, select
@@ -1510,6 +1510,164 @@ class ConversionService:
return matched_reservation, matched_customer 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( async def _find_customer_for_guest(
self, guest: ConversionGuest, session: AsyncSession self, guest: ConversionGuest, session: AsyncSession
) -> Customer | None: ) -> Customer | None: