Migration to guest_table for conversion works

This commit is contained in:
Jonas Linter
2025-11-19 12:05:38 +01:00
parent 55c4b0b9de
commit a087a312a7
4 changed files with 43096 additions and 1 deletions

View File

@@ -2,7 +2,7 @@
import asyncio
import xml.etree.ElementTree as ET
from datetime import datetime
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
from .db import (
Conversion,
ConversionGuest,
ConversionRoom,
Customer,
HashedCustomer,
@@ -73,6 +74,70 @@ class ConversionService:
f"session must be AsyncSession or SessionMaker, got {type(session)}"
)
async def _get_or_create_conversion_guest(
self,
hotel_id: str,
guest_id: str | None,
guest_first_name: str | None,
guest_last_name: str | None,
guest_email: str | None,
guest_country_code: str | None,
guest_birth_date,
session: AsyncSession,
) -> ConversionGuest | None:
"""Get or create a ConversionGuest record for the given guest data.
Uses (hotel_id, guest_id) as the natural key to identify a guest.
If a guest with this key exists, updates it with new data.
If not, creates a new guest record.
Returns the ConversionGuest record, or None if no guest data provided.
"""
# Don't create a ConversionGuest if we have no guest information
if not any(
[guest_first_name, guest_last_name, guest_email, guest_country_code, guest_birth_date]
):
return None
now = datetime.now(UTC)
# Try to find existing guest by (hotel_id, guest_id)
if guest_id:
result = await session.execute(
select(ConversionGuest).where(
(ConversionGuest.hotel_id == hotel_id)
& (ConversionGuest.guest_id == guest_id)
)
)
existing_guest = result.scalar_one_or_none()
if existing_guest:
# Update with new data
existing_guest.update_from_conversion_data(
guest_first_name,
guest_last_name,
guest_email,
guest_country_code,
guest_birth_date,
now,
)
return existing_guest
# Create new ConversionGuest
new_guest = ConversionGuest.create_from_conversion_data(
hotel_id=hotel_id,
guest_id=guest_id,
guest_first_name=guest_first_name,
guest_last_name=guest_last_name,
guest_email=guest_email,
guest_country_code=guest_country_code,
guest_birth_date=guest_birth_date,
now=now,
)
session.add(new_guest)
await session.flush() # Ensure the guest has an ID
return new_guest
async def process_conversion_xml(self, xml_content: str) -> dict[str, Any]:
"""Parse conversion XML and save daily sales data to database.
@@ -525,6 +590,20 @@ class ConversionService:
# Flush to ensure conversion has an ID before creating room reservations
await session.flush()
# Create or update ConversionGuest and link it to the conversion
conversion_guest = await self._get_or_create_conversion_guest(
hotel_id=hotel_id,
guest_id=guest_id,
guest_first_name=guest_first_name,
guest_last_name=guest_last_name,
guest_email=guest_email,
guest_country_code=guest_country_code,
guest_birth_date=guest_birth_date,
session=session,
)
if conversion_guest:
conversion.conversion_guest_id = conversion_guest.id
# Update stats for the conversion record itself
if matched_reservation:
stats["matched_to_reservation"] += 1

View File

@@ -364,6 +364,116 @@ class HashedCustomer(Base):
customer = relationship("Customer", backref="hashed_version")
class ConversionGuest(Base):
"""Guest information from hotel PMS conversions, with hashed fields for privacy.
Stores both unhashed (for reference during transition) and hashed (SHA256 per Meta API)
versions of guest PII. Multiple conversions can reference the same guest if they have
the same hotel_id and guest_id (PMS guest identifier).
When multiple conversions for the same guest arrive with different guest info,
the most recent (by creation_time) data is kept as the canonical version.
"""
__tablename__ = "conversion_guests"
id = Column(Integer, primary_key=True)
# Natural keys from PMS (composite unique constraint)
hotel_id = Column(String, nullable=False, index=True)
guest_id = Column(String, index=True) # PMS guest ID (nullable for unidentified guests)
# Unhashed guest information (for reference/transition period)
guest_first_name = Column(String)
guest_last_name = Column(String)
guest_email = Column(String)
guest_country_code = Column(String)
guest_birth_date = Column(Date)
# Hashed guest information (SHA256, for privacy compliance)
hashed_first_name = Column(String(64), index=True)
hashed_last_name = Column(String(64), index=True)
hashed_email = Column(String(64), index=True)
hashed_country_code = Column(String(64))
hashed_birth_date = Column(String(64))
# Metadata
first_seen = Column(DateTime(timezone=True))
last_seen = Column(DateTime(timezone=True))
# Relationships
conversions = relationship("Conversion", back_populates="guest")
@staticmethod
def _normalize_and_hash(value):
"""Normalize and hash a value according to Meta Conversion API requirements."""
if not value:
return None
# Normalize: lowercase, strip whitespace
normalized = str(value).lower().strip()
# SHA256 hash
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
@classmethod
def create_from_conversion_data(
cls,
hotel_id: str,
guest_id: str | None,
guest_first_name: str | None,
guest_last_name: str | None,
guest_email: str | None,
guest_country_code: str | None,
guest_birth_date: Date | None,
now: DateTime,
):
"""Create a ConversionGuest from conversion guest data."""
return cls(
hotel_id=hotel_id,
guest_id=guest_id,
guest_first_name=guest_first_name,
guest_last_name=guest_last_name,
guest_email=guest_email,
guest_country_code=guest_country_code,
guest_birth_date=guest_birth_date,
hashed_first_name=cls._normalize_and_hash(guest_first_name),
hashed_last_name=cls._normalize_and_hash(guest_last_name),
hashed_email=cls._normalize_and_hash(guest_email),
hashed_country_code=cls._normalize_and_hash(guest_country_code),
hashed_birth_date=cls._normalize_and_hash(
guest_birth_date.isoformat() if guest_birth_date else None
),
first_seen=now,
last_seen=now,
)
def update_from_conversion_data(
self,
guest_first_name: str | None,
guest_last_name: str | None,
guest_email: str | None,
guest_country_code: str | None,
guest_birth_date: Date | None,
now: DateTime,
):
"""Update ConversionGuest with newer guest data, preferring non-null values."""
# Only update if new data is provided (not null)
if guest_first_name:
self.guest_first_name = guest_first_name
self.hashed_first_name = self._normalize_and_hash(guest_first_name)
if guest_last_name:
self.guest_last_name = guest_last_name
self.hashed_last_name = self._normalize_and_hash(guest_last_name)
if guest_email:
self.guest_email = guest_email
self.hashed_email = self._normalize_and_hash(guest_email)
if guest_country_code:
self.guest_country_code = guest_country_code
self.hashed_country_code = self._normalize_and_hash(guest_country_code)
if guest_birth_date:
self.guest_birth_date = guest_birth_date
self.hashed_birth_date = self._normalize_and_hash(guest_birth_date.isoformat())
self.last_seen = now
class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True)
@@ -445,6 +555,9 @@ class Conversion(Base):
hashed_customer_id = Column(
Integer, ForeignKey("hashed_customers.id"), nullable=True, index=True
)
conversion_guest_id = Column(
Integer, ForeignKey("conversion_guests.id"), nullable=True, index=True
)
# Reservation metadata from XML
hotel_id = Column(String, index=True) # hotelID attribute
@@ -482,6 +595,7 @@ class Conversion(Base):
reservation = relationship("Reservation", backref="conversions")
customer = relationship("Customer", backref="conversions")
hashed_customer = relationship("HashedCustomer", backref="conversions")
guest = relationship("ConversionGuest", back_populates="conversions")
conversion_rooms = relationship(
"ConversionRoom", back_populates="conversion", cascade="all, delete-orphan"
)