2 Commits

Author SHA1 Message Date
Jonas Linter
2c1bdf6840 Lots of refactoring and simplification in conversions_service 2025-12-02 15:45:40 +01:00
Jonas Linter
d458f4f2c0 Removed some unused fields 2025-12-02 15:38:39 +01:00

View File

@@ -2,7 +2,8 @@
import asyncio import asyncio
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import UTC, datetime from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -29,6 +30,42 @@ _LOGGER = get_logger(__name__)
MAX_CONCURRENT_RESERVATIONS = 10 MAX_CONCURRENT_RESERVATIONS = 10
@dataclass(slots=True)
class ParsedRoomReservation:
"""Typed representation of a single <roomReservation> entry."""
pms_hotel_reservation_id: str
room_number: str | None
arrival_date: date | None
departure_date: date | None
room_status: str | None
room_type: str | None
num_adults: int | None
rate_plan_code: str | None
connected_room_type: str | None
daily_sales: list[dict[str, str]] = field(default_factory=list)
total_revenue: Decimal | None = None
daily_sales_count: int = 0
@dataclass(slots=True)
class ParsedReservationData:
"""Typed representation of reservation metadata and rooms."""
hotel_id: str | None
pms_reservation_id: int
guest_id: int | None
reservation_number: str | None
reservation_date: date | None
creation_time: datetime | None
reservation_type: str | None
booking_channel: str | None
advertising_medium: str | None
advertising_partner: str | None
advertising_campagne: str | None
room_reservations: list[ParsedRoomReservation] = field(default_factory=list)
class ConversionService: class ConversionService:
"""Service for processing and storing conversion/daily sales data. """Service for processing and storing conversion/daily sales data.
@@ -83,6 +120,200 @@ class ConversionService:
f"session must be AsyncSession or SessionMaker, got {type(session)}" f"session must be AsyncSession or SessionMaker, got {type(session)}"
) )
@staticmethod
def _parse_required_int(value: str | None, field_name: str) -> int:
"""Parse an integer attribute that must be present."""
if value in (None, ""):
raise ValueError(f"{field_name} is required")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be an integer (value={value})") from exc
@staticmethod
def _parse_optional_int(value: str | None, field_name: str) -> int | None:
"""Parse an optional integer attribute, logging on failure."""
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
_LOGGER.warning("Invalid %s value: %s", field_name, value)
return None
@staticmethod
def _parse_date(value: str | None, field_name: str) -> date | None:
"""Parse a YYYY-MM-DD formatted date string."""
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
_LOGGER.warning("Invalid %s format: %s", field_name, value)
return None
@staticmethod
def _parse_datetime(value: str | None, field_name: str) -> datetime | None:
"""Parse an ISO timestamp string."""
if not value:
return None
try:
normalized = value.replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
except ValueError:
_LOGGER.warning("Invalid %s format: %s", field_name, value)
return None
def _parse_daily_sales(
self, daily_sales_elem: ET.Element | None
) -> tuple[list[dict[str, str]], Decimal | None, int]:
"""Extract the list of sale dictionaries and aggregate revenue information."""
if daily_sales_elem is None:
return [], None, 0
daily_sales_list: list[dict[str, str]] = []
total_revenue = Decimal(0)
sale_count = 0
for daily_sale in daily_sales_elem.findall("dailySale"):
sale_count += 1
sale_data: dict[str, str] = {}
sale_date_str = daily_sale.get("date")
if sale_date_str:
sale_data["date"] = sale_date_str
revenue_total_str = daily_sale.get("revenueTotal")
if revenue_total_str:
sale_data["revenueTotal"] = revenue_total_str
try:
total_revenue += Decimal(revenue_total_str)
except (ValueError, TypeError):
_LOGGER.warning(
"Invalid revenueTotal value: %s", revenue_total_str
)
# Copy the remaining optional revenue buckets if present
for field_name in (
"revenueLogis",
"revenueBoard",
"revenueFB",
"revenueSpa",
"revenueOther",
):
value = daily_sale.get(field_name)
if value:
sale_data[field_name] = value
if sale_data:
daily_sales_list.append(sale_data)
total_revenue_value = total_revenue if total_revenue > 0 else None
return daily_sales_list, total_revenue_value, sale_count
def _parse_room_reservation(
self, room_elem: ET.Element, pms_reservation_id: int, room_index: int
) -> ParsedRoomReservation:
"""Convert a <roomReservation> element into ParsedRoomReservation."""
arrival_date = self._parse_date(room_elem.get("arrival"), "arrival date")
departure_date = self._parse_date(
room_elem.get("departure"), "departure date"
)
num_adults = self._parse_optional_int(room_elem.get("adults"), "adults")
room_number = room_elem.get("roomNumber")
if room_number is None:
_LOGGER.debug(
"Room reservation %s #%d has no roomNumber", pms_reservation_id, room_index
)
daily_sales, total_revenue, sale_count = self._parse_daily_sales(
room_elem.find("dailySales")
)
return ParsedRoomReservation(
pms_hotel_reservation_id=f"{pms_reservation_id}_{room_number}",
room_number=room_number,
arrival_date=arrival_date,
departure_date=departure_date,
room_status=room_elem.get("status"),
room_type=room_elem.get("roomType"),
num_adults=num_adults,
rate_plan_code=room_elem.get("ratePlanCode"),
connected_room_type=room_elem.get("connectedRoomType"),
daily_sales=daily_sales,
total_revenue=total_revenue,
daily_sales_count=sale_count,
)
def _parse_reservation_element(
self, reservation_elem: ET.Element
) -> ParsedReservationData | None:
"""Convert a <reservation> element into a structured representation."""
try:
pms_reservation_id = self._parse_required_int(
reservation_elem.get("id"), "reservation id"
)
except ValueError as exc:
_LOGGER.error(
"Invalid reservation metadata in reservation element: %s", exc
)
return None
room_reservations_elem = reservation_elem.find("roomReservations")
if room_reservations_elem is None:
_LOGGER.debug(
"No roomReservations found for reservation %s", pms_reservation_id
)
return None
room_reservations = [
self._parse_room_reservation(room_elem, pms_reservation_id, idx)
for idx, room_elem in enumerate(
room_reservations_elem.findall("roomReservation")
)
]
if not room_reservations:
_LOGGER.debug(
"Reservation %s has no roomReservation entries", pms_reservation_id
)
return None
guest_elem = reservation_elem.find("guest")
guest_id = None
if guest_elem is not None:
guest_id = self._parse_optional_int(guest_elem.get("id"), "guest id")
return ParsedReservationData(
hotel_id=reservation_elem.get("hotelID"),
pms_reservation_id=pms_reservation_id,
guest_id=guest_id,
reservation_number=reservation_elem.get("number"),
reservation_date=self._parse_date(
reservation_elem.get("date"), "reservation date"
),
creation_time=self._parse_datetime(
reservation_elem.get("creationTime"), "creation time"
),
reservation_type=reservation_elem.get("type"),
booking_channel=reservation_elem.get("bookingChannel"),
advertising_medium=reservation_elem.get("advertisingMedium"),
advertising_partner=reservation_elem.get("advertisingPartner"),
advertising_campagne=reservation_elem.get("advertisingCampagne"),
room_reservations=room_reservations,
)
async def _extract_unique_guests_from_xml( async def _extract_unique_guests_from_xml(
self, reservations: list self, reservations: list
) -> dict[tuple[str, int], ConversionGuestData]: ) -> dict[tuple[str, int], ConversionGuestData]:
@@ -651,78 +882,12 @@ class ConversionService:
stats = { stats = {
"daily_sales_count": 0, "daily_sales_count": 0,
} }
parsed_reservation = self._parse_reservation_element(reservation_elem)
hotel_id = reservation_elem.get("hotelID") if not parsed_reservation:
try:
# Extract reservation metadata
pms_reservation_id = int(reservation_elem.get("id"))
except ValueError as e:
_LOGGER.error("Invalid reservation metadata in reservation element: %s", e)
return stats return stats
reservation_number = reservation_elem.get("number") hotel_id = parsed_reservation.hotel_id
reservation_date_str = reservation_elem.get("date") pms_reservation_id = parsed_reservation.pms_reservation_id
creation_time_str = reservation_elem.get("creationTime")
reservation_type = reservation_elem.get("type")
booking_channel = reservation_elem.get("bookingChannel")
# Extract guest information from guest element
guest_elem = reservation_elem.find("guest")
guest_first_name = None
guest_last_name = None
guest_email = None
guest_country_code = None
guest_birth_date_str = None
guest_id = None
if guest_elem is not None:
guest_first_name = guest_elem.get("firstName")
guest_last_name = guest_elem.get("lastName")
guest_email = guest_elem.get("email", None)
guest_country_code = guest_elem.get("countryCode", None)
guest_birth_date_str = guest_elem.get("dateOfBirth", None)
guest_id = guest_elem.get("id")
guest_birth_date = (
datetime.strptime(guest_birth_date_str, "%Y-%m-%d").date()
if guest_birth_date_str
else None
)
# Advertising/tracking data
advertising_medium = reservation_elem.get("advertisingMedium")
advertising_partner = reservation_elem.get("advertisingPartner")
advertising_campagne = reservation_elem.get("advertisingCampagne")
# Parse dates
reservation_date = None
if reservation_date_str:
try:
reservation_date = datetime.strptime(
reservation_date_str, "%Y-%m-%d"
).date()
except ValueError:
_LOGGER.warning(
"Invalid reservation date format: %s", reservation_date_str
)
creation_time = None
if creation_time_str:
try:
creation_time = datetime.fromisoformat(
creation_time_str.replace("Z", "+00:00")
)
except ValueError:
_LOGGER.warning("Invalid creation time format: %s", creation_time_str)
# Process all room reservations
room_reservations = reservation_elem.find("roomReservations")
if room_reservations is None:
_LOGGER.debug(
"No roomReservations found for reservation %s", pms_reservation_id
)
return stats
# ConversionGuests have already been bulk-upserted in Phase 1, # ConversionGuests have already been bulk-upserted in Phase 1,
# so we can safely create/update conversions now # so we can safely create/update conversions now
@@ -739,14 +904,22 @@ class ConversionService:
# Update existing conversion - only update reservation metadata and advertising data # Update existing conversion - only update reservation metadata and advertising data
# Guest info is stored in ConversionGuest table, not here # Guest info is stored in ConversionGuest table, not here
# Don't clear reservation/customer links (matching logic will update if needed) # Don't clear reservation/customer links (matching logic will update if needed)
existing_conversion.reservation_number = reservation_number existing_conversion.reservation_number = (
existing_conversion.reservation_date = reservation_date parsed_reservation.reservation_number
existing_conversion.creation_time = creation_time )
existing_conversion.reservation_type = reservation_type existing_conversion.reservation_date = parsed_reservation.reservation_date
existing_conversion.booking_channel = booking_channel existing_conversion.creation_time = parsed_reservation.creation_time
existing_conversion.advertising_medium = advertising_medium existing_conversion.reservation_type = parsed_reservation.reservation_type
existing_conversion.advertising_partner = advertising_partner existing_conversion.booking_channel = parsed_reservation.booking_channel
existing_conversion.advertising_campagne = advertising_campagne existing_conversion.advertising_medium = (
parsed_reservation.advertising_medium
)
existing_conversion.advertising_partner = (
parsed_reservation.advertising_partner
)
existing_conversion.advertising_campagne = (
parsed_reservation.advertising_campagne
)
existing_conversion.updated_at = datetime.now() existing_conversion.updated_at = datetime.now()
conversion = existing_conversion conversion = existing_conversion
_LOGGER.info( _LOGGER.info(
@@ -761,17 +934,17 @@ class ConversionService:
# Links to existing entities (nullable, will be filled in after matching) # Links to existing entities (nullable, will be filled in after matching)
# Reservation metadata # Reservation metadata
hotel_id=hotel_id, hotel_id=hotel_id,
guest_id=guest_id, # Links to ConversionGuest guest_id=parsed_reservation.guest_id, # Links to ConversionGuest
pms_reservation_id=pms_reservation_id, pms_reservation_id=pms_reservation_id,
reservation_number=reservation_number, reservation_number=parsed_reservation.reservation_number,
reservation_date=reservation_date, reservation_date=parsed_reservation.reservation_date,
creation_time=creation_time, creation_time=parsed_reservation.creation_time,
reservation_type=reservation_type, reservation_type=parsed_reservation.reservation_type,
booking_channel=booking_channel, booking_channel=parsed_reservation.booking_channel,
# Advertising data # Advertising data
advertising_medium=advertising_medium, advertising_medium=parsed_reservation.advertising_medium,
advertising_partner=advertising_partner, advertising_partner=parsed_reservation.advertising_partner,
advertising_campagne=advertising_campagne, advertising_campagne=parsed_reservation.advertising_campagne,
# Metadata # Metadata
) )
conversion = Conversion(**conversion_data.model_dump()) conversion = Conversion(**conversion_data.model_dump())
@@ -797,127 +970,70 @@ class ConversionService:
current_pms_hotel_reservation_ids = set() current_pms_hotel_reservation_ids = set()
# Process room reservations # Process room reservations
for room_reservation in room_reservations.findall("roomReservation"): for room_reservation in parsed_reservation.room_reservations:
# Extract room reservation details current_pms_hotel_reservation_ids.add(
arrival_str = room_reservation.get("arrival") room_reservation.pms_hotel_reservation_id
departure_str = room_reservation.get("departure")
room_status = room_reservation.get("status")
room_type = room_reservation.get("roomType")
room_number = room_reservation.get("roomNumber")
adults_str = room_reservation.get("adults")
rate_plan_code = room_reservation.get("ratePlanCode")
connected_room_type = room_reservation.get("connectedRoomType")
arrival_date = None
if arrival_str:
try:
arrival_date = datetime.strptime(arrival_str, "%Y-%m-%d").date()
except ValueError:
_LOGGER.warning("Invalid arrival date format: %s", arrival_str)
departure_date = None
if departure_str:
try:
departure_date = datetime.strptime(departure_str, "%Y-%m-%d").date()
except ValueError:
_LOGGER.warning("Invalid departure date format: %s", departure_str)
num_adults = None
if adults_str:
try:
num_adults = int(adults_str)
except ValueError:
_LOGGER.warning("Invalid adults value: %s", adults_str)
# Create composite ID for upsert: pms_reservation_id + room_number
# This allows updating the same room reservation if it appears again
pms_hotel_reservation_id = f"{pms_reservation_id}_{room_number}"
# Track this room as present in current XML
current_pms_hotel_reservation_ids.add(pms_hotel_reservation_id)
# Process daily sales and extract total revenue
daily_sales_elem = room_reservation.find("dailySales")
daily_sales_list = []
total_revenue = Decimal(0)
if daily_sales_elem is not None:
for daily_sale in daily_sales_elem.findall("dailySale"):
stats["daily_sales_count"] += 1
# Extract daily sale data
sale_date_str = daily_sale.get("date")
daily_sale_obj = {}
if sale_date_str:
daily_sale_obj["date"] = sale_date_str
# Extract all revenue fields
revenue_total_str = daily_sale.get("revenueTotal")
if revenue_total_str:
daily_sale_obj["revenueTotal"] = revenue_total_str
try:
total_revenue += Decimal(revenue_total_str)
except (ValueError, TypeError):
_LOGGER.warning(
"Invalid revenueTotal value: %s", revenue_total_str
) )
stats["daily_sales_count"] += room_reservation.daily_sales_count
# Add other revenue fields if present
if daily_sale.get("revenueLogis"):
daily_sale_obj["revenueLogis"] = daily_sale.get("revenueLogis")
if daily_sale.get("revenueBoard"):
daily_sale_obj["revenueBoard"] = daily_sale.get("revenueBoard")
if daily_sale.get("revenueFB"):
daily_sale_obj["revenueFB"] = daily_sale.get("revenueFB")
if daily_sale.get("revenueSpa"):
daily_sale_obj["revenueSpa"] = daily_sale.get("revenueSpa")
if daily_sale.get("revenueOther"):
daily_sale_obj["revenueOther"] = daily_sale.get("revenueOther")
if daily_sale_obj: # Only add if has data
daily_sales_list.append(daily_sale_obj)
# Check if room reservation already exists using batch-loaded data # Check if room reservation already exists using batch-loaded data
existing_room_reservation = existing_rooms.get(pms_hotel_reservation_id) existing_room_reservation = existing_rooms.get(
room_reservation.pms_hotel_reservation_id
)
if existing_room_reservation: if existing_room_reservation:
# Update existing room reservation with all fields # Update existing room reservation with all fields
existing_room_reservation.arrival_date = arrival_date existing_room_reservation.arrival_date = (
existing_room_reservation.departure_date = departure_date room_reservation.arrival_date
existing_room_reservation.room_status = room_status )
existing_room_reservation.room_type = room_type existing_room_reservation.departure_date = (
existing_room_reservation.num_adults = num_adults room_reservation.departure_date
existing_room_reservation.rate_plan_code = rate_plan_code )
existing_room_reservation.connected_room_type = connected_room_type existing_room_reservation.room_status = (
room_reservation.room_status
)
existing_room_reservation.room_type = room_reservation.room_type
existing_room_reservation.num_adults = room_reservation.num_adults
existing_room_reservation.rate_plan_code = (
room_reservation.rate_plan_code
)
existing_room_reservation.connected_room_type = (
room_reservation.connected_room_type
)
existing_room_reservation.daily_sales = ( existing_room_reservation.daily_sales = (
daily_sales_list if daily_sales_list else None room_reservation.daily_sales
if room_reservation.daily_sales
else None
) )
existing_room_reservation.total_revenue = ( existing_room_reservation.total_revenue = (
total_revenue if total_revenue > 0 else None room_reservation.total_revenue
) )
existing_room_reservation.updated_at = datetime.now() existing_room_reservation.updated_at = datetime.now()
_LOGGER.debug( _LOGGER.debug(
"Updated room reservation %s (pms_id=%s, room=%s)", "Updated room reservation %s (pms_id=%s, room=%s)",
existing_room_reservation.id, existing_room_reservation.id,
pms_reservation_id, pms_reservation_id,
room_number, room_reservation.room_number,
) )
else: else:
# Create new room reservation # Create new room reservation
room_reservation_record = ConversionRoom( room_reservation_record = ConversionRoom(
conversion_id=conversion.id, conversion_id=conversion.id,
pms_hotel_reservation_id=pms_hotel_reservation_id, pms_hotel_reservation_id=room_reservation.pms_hotel_reservation_id,
arrival_date=arrival_date, arrival_date=room_reservation.arrival_date,
departure_date=departure_date, departure_date=room_reservation.departure_date,
room_status=room_status, room_status=room_reservation.room_status,
room_type=room_type, room_type=room_reservation.room_type,
room_number=room_number, room_number=room_reservation.room_number,
num_adults=num_adults, num_adults=room_reservation.num_adults,
rate_plan_code=rate_plan_code, rate_plan_code=room_reservation.rate_plan_code,
connected_room_type=connected_room_type, connected_room_type=room_reservation.connected_room_type,
daily_sales=daily_sales_list if daily_sales_list else None, daily_sales=(
total_revenue=total_revenue if total_revenue > 0 else None, room_reservation.daily_sales
if room_reservation.daily_sales
else None
),
total_revenue=room_reservation.total_revenue,
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
) )
@@ -925,8 +1041,8 @@ class ConversionService:
_LOGGER.debug( _LOGGER.debug(
"Created room reservation (pms_id=%s, room=%s, adults=%s)", "Created room reservation (pms_id=%s, room=%s, adults=%s)",
pms_reservation_id, pms_reservation_id,
room_number, room_reservation.room_number,
num_adults, room_reservation.num_adults,
) )
# Delete room entries that are no longer present in the current XML # Delete room entries that are no longer present in the current XML