|
|
|
|
@@ -177,7 +177,6 @@ class ConversionService:
|
|
|
|
|
"hashed_birth_date": ConversionGuest._normalize_and_hash(
|
|
|
|
|
guest_data["guest_birth_date"].isoformat() if guest_data["guest_birth_date"] else None
|
|
|
|
|
),
|
|
|
|
|
"is_regular": False,
|
|
|
|
|
"first_seen": now,
|
|
|
|
|
"last_seen": now,
|
|
|
|
|
})
|
|
|
|
|
@@ -883,103 +882,6 @@ class ConversionService:
|
|
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
async def _match_conversion(
|
|
|
|
|
self,
|
|
|
|
|
conversion: Conversion,
|
|
|
|
|
guest_first_name: str | None,
|
|
|
|
|
guest_last_name: str | None,
|
|
|
|
|
guest_email: str | None,
|
|
|
|
|
advertising_campagne: str | None,
|
|
|
|
|
advertising_partner: str | None,
|
|
|
|
|
hotel_id: str | None,
|
|
|
|
|
reservation_date: Any,
|
|
|
|
|
session: AsyncSession | None = None,
|
|
|
|
|
) -> dict[str, int]:
|
|
|
|
|
"""Match a conversion to reservations and customers using guest and advertising data.
|
|
|
|
|
|
|
|
|
|
This is the matching phase that runs AFTER conversion data has been stored.
|
|
|
|
|
It uses hashed guest data to match conversions to existing reservations/customers.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
conversion: The Conversion record to match
|
|
|
|
|
guest_first_name: Guest first name (will be hashed for matching)
|
|
|
|
|
guest_last_name: Guest last name (will be hashed for matching)
|
|
|
|
|
guest_email: Guest email (will be hashed for matching)
|
|
|
|
|
advertising_campagne: Advertising campaign identifier
|
|
|
|
|
advertising_partner: Advertising partner info
|
|
|
|
|
hotel_id: Hotel ID for filtering matches
|
|
|
|
|
reservation_date: Reservation date for additional filtering
|
|
|
|
|
session: AsyncSession to use for database queries
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with match statistics: matched_to_reservation, matched_to_customer,
|
|
|
|
|
matched_to_hashed_customer, and unmatched (all counts of 0 or 1)
|
|
|
|
|
"""
|
|
|
|
|
if session is None:
|
|
|
|
|
session = self.session
|
|
|
|
|
|
|
|
|
|
stats = {
|
|
|
|
|
"matched_to_reservation": 0,
|
|
|
|
|
"matched_to_customer": 0,
|
|
|
|
|
"matched_to_hashed_customer": 0,
|
|
|
|
|
"unmatched": 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Hash guest data for matching (same hashing logic as ConversionGuest)
|
|
|
|
|
hashed_first_name = ConversionGuest._normalize_and_hash(guest_first_name)
|
|
|
|
|
hashed_last_name = ConversionGuest._normalize_and_hash(guest_last_name)
|
|
|
|
|
hashed_email = ConversionGuest._normalize_and_hash(guest_email)
|
|
|
|
|
|
|
|
|
|
# Find matching entities
|
|
|
|
|
match_result = await self._find_matching_entities(
|
|
|
|
|
advertising_campagne,
|
|
|
|
|
hotel_id,
|
|
|
|
|
reservation_date,
|
|
|
|
|
hashed_first_name,
|
|
|
|
|
hashed_last_name,
|
|
|
|
|
hashed_email,
|
|
|
|
|
advertising_partner,
|
|
|
|
|
session,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
matched_reservation = match_result["reservation"]
|
|
|
|
|
matched_customer = match_result["customer"]
|
|
|
|
|
matched_hashed_customer = match_result["hashed_customer"]
|
|
|
|
|
match_type = match_result.get("match_type") # "id" or "guest_details"
|
|
|
|
|
|
|
|
|
|
# Update the conversion with matched entities if found
|
|
|
|
|
if matched_reservation or matched_customer or matched_hashed_customer:
|
|
|
|
|
conversion.reservation_id = (
|
|
|
|
|
matched_reservation.id if matched_reservation else None
|
|
|
|
|
)
|
|
|
|
|
conversion.customer_id = (
|
|
|
|
|
matched_customer.id if matched_customer else None
|
|
|
|
|
)
|
|
|
|
|
conversion.hashed_customer_id = (
|
|
|
|
|
matched_hashed_customer.id if matched_hashed_customer else None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Set attribution flags based on match type
|
|
|
|
|
if match_type == "id":
|
|
|
|
|
conversion.directly_attributable = True
|
|
|
|
|
conversion.guest_matched = False
|
|
|
|
|
elif match_type == "guest_details":
|
|
|
|
|
conversion.directly_attributable = False
|
|
|
|
|
conversion.guest_matched = True
|
|
|
|
|
|
|
|
|
|
conversion.updated_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
# Update stats
|
|
|
|
|
if matched_reservation:
|
|
|
|
|
stats["matched_to_reservation"] = 1
|
|
|
|
|
if matched_customer:
|
|
|
|
|
stats["matched_to_customer"] = 1
|
|
|
|
|
if matched_hashed_customer:
|
|
|
|
|
stats["matched_to_hashed_customer"] = 1
|
|
|
|
|
if not any([matched_reservation, matched_customer, matched_hashed_customer]):
|
|
|
|
|
stats["unmatched"] = 1
|
|
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
async def _find_matching_entities(
|
|
|
|
|
self,
|
|
|
|
|
@@ -994,9 +896,9 @@ class ConversionService:
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Find matching Reservation, Customer, and HashedCustomer.
|
|
|
|
|
|
|
|
|
|
Uses two strategies with separate attribution:
|
|
|
|
|
1. ID-based matching (fbclid/gclid/md5_unique_id) - directly_attributable
|
|
|
|
|
2. Guest detail matching (email/name) - guest_matched only
|
|
|
|
|
Uses two strategies with separate matching paths:
|
|
|
|
|
1. ID-based matching (fbclid/gclid/md5_unique_id) - returns Reservation + Customer + HashedCustomer
|
|
|
|
|
2. Guest detail matching (email/name) - returns Customer + HashedCustomer directly (no Reservation needed)
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
advertising_campagne: Truncated tracking ID from conversion XML
|
|
|
|
|
@@ -1011,6 +913,7 @@ class ConversionService:
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with 'reservation', 'customer', 'hashed_customer', and 'match_type' keys.
|
|
|
|
|
match_type is either 'id' (high confidence) or 'guest_details' (lower confidence)
|
|
|
|
|
For guest_details matches, 'reservation' will be None.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
if session is None:
|
|
|
|
|
@@ -1050,22 +953,31 @@ class ConversionService:
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Strategy 2: If no ID-based match, try email/name-based matching - guest details, lower confidence
|
|
|
|
|
# For guest detail matches, match directly with HashedCustomer (skip Reservation table)
|
|
|
|
|
if not result["reservation"] and (
|
|
|
|
|
guest_email or guest_first_name or guest_last_name
|
|
|
|
|
):
|
|
|
|
|
matched_reservation = await self._match_by_guest_details(
|
|
|
|
|
hotel_id, guest_first_name, guest_last_name, guest_email, session
|
|
|
|
|
matched_hashed_customer = await self._match_by_guest_details_hashed(
|
|
|
|
|
guest_first_name, guest_last_name, guest_email, session
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if matched_reservation:
|
|
|
|
|
result["reservation"] = matched_reservation
|
|
|
|
|
if matched_hashed_customer:
|
|
|
|
|
result["hashed_customer"] = matched_hashed_customer
|
|
|
|
|
result["match_type"] = "guest_details" # Matched by guest details only
|
|
|
|
|
|
|
|
|
|
# Get the customer if it exists
|
|
|
|
|
if matched_hashed_customer.customer_id:
|
|
|
|
|
customer_query = select(Customer).where(
|
|
|
|
|
Customer.id == matched_hashed_customer.customer_id
|
|
|
|
|
)
|
|
|
|
|
customer_result = await session.execute(customer_query)
|
|
|
|
|
result["customer"] = customer_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
_LOGGER.info(
|
|
|
|
|
"Matched conversion by guest details (name=%s %s, email=%s, hotel=%s)",
|
|
|
|
|
"Matched conversion by guest details to hashed_customer (name=%s %s, email=%s)",
|
|
|
|
|
guest_first_name,
|
|
|
|
|
guest_last_name,
|
|
|
|
|
guest_email,
|
|
|
|
|
hotel_id,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
|
@@ -1075,7 +987,7 @@ class ConversionService:
|
|
|
|
|
guest_email,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# If we found a reservation, get its customer and hashed_customer
|
|
|
|
|
# If we found a reservation (ID-based match), get its customer and hashed_customer
|
|
|
|
|
if result["reservation"]:
|
|
|
|
|
if result["reservation"].customer_id:
|
|
|
|
|
customer_query = select(Customer).where(
|
|
|
|
|
@@ -1182,6 +1094,77 @@ class ConversionService:
|
|
|
|
|
|
|
|
|
|
return matched_reservation
|
|
|
|
|
|
|
|
|
|
async def _match_by_guest_details_hashed(
|
|
|
|
|
self,
|
|
|
|
|
guest_first_name: str | None,
|
|
|
|
|
guest_last_name: str | None,
|
|
|
|
|
guest_email: str | None,
|
|
|
|
|
session: AsyncSession | None = None,
|
|
|
|
|
) -> HashedCustomer | None:
|
|
|
|
|
"""Match guest by name and email directly to HashedCustomer (no Reservation needed).
|
|
|
|
|
|
|
|
|
|
This method bypasses the Reservation table entirely and matches directly against
|
|
|
|
|
hashed customer data. Used for guest-detail matching where we don't need to link
|
|
|
|
|
to a specific reservation.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
guest_first_name: Guest first name (pre-hashed)
|
|
|
|
|
guest_last_name: Guest last name (pre-hashed)
|
|
|
|
|
guest_email: Guest email (pre-hashed)
|
|
|
|
|
session: AsyncSession to use. If None, uses self.session.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Matched HashedCustomer or None
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
if session is None:
|
|
|
|
|
session = self.session
|
|
|
|
|
|
|
|
|
|
# Query all hashed customers that match the guest details
|
|
|
|
|
query = select(HashedCustomer).options(
|
|
|
|
|
selectinload(HashedCustomer.customer)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Build filter conditions
|
|
|
|
|
conditions = []
|
|
|
|
|
if guest_email:
|
|
|
|
|
conditions.append(HashedCustomer.hashed_email == guest_email)
|
|
|
|
|
if guest_first_name and guest_last_name:
|
|
|
|
|
conditions.append(
|
|
|
|
|
(HashedCustomer.hashed_given_name == guest_first_name)
|
|
|
|
|
& (HashedCustomer.hashed_surname == guest_last_name)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not conditions:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Combine conditions with OR (match if email matches OR name matches)
|
|
|
|
|
query = query.where(or_(*conditions))
|
|
|
|
|
|
|
|
|
|
db_result = await session.execute(query)
|
|
|
|
|
matches = db_result.scalars().all()
|
|
|
|
|
|
|
|
|
|
if not matches:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# If single match, return it
|
|
|
|
|
if len(matches) == 1:
|
|
|
|
|
return matches[0]
|
|
|
|
|
|
|
|
|
|
# If multiple matches, prefer email match over name match
|
|
|
|
|
for match in matches:
|
|
|
|
|
if guest_email and match.hashed_email == guest_email:
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
|
"Multiple hashed customer matches, preferring email match"
|
|
|
|
|
)
|
|
|
|
|
return match
|
|
|
|
|
|
|
|
|
|
# Otherwise return first match
|
|
|
|
|
_LOGGER.warning(
|
|
|
|
|
"Multiple hashed customer matches found for guest details, using first match"
|
|
|
|
|
)
|
|
|
|
|
return matches[0]
|
|
|
|
|
|
|
|
|
|
async def _match_by_guest_details(
|
|
|
|
|
self,
|
|
|
|
|
hotel_id: str | None,
|
|
|
|
|
@@ -1521,10 +1504,12 @@ class ConversionService:
|
|
|
|
|
and uses their stored hashed data to match to existing reservations/customers.
|
|
|
|
|
No XML parsing, no re-hashing - complete separation of concerns.
|
|
|
|
|
|
|
|
|
|
This enables:
|
|
|
|
|
- Matching historical data that wasn't just created
|
|
|
|
|
- Re-running matching logic independently
|
|
|
|
|
- Consistent hashing (using already-hashed data from DB)
|
|
|
|
|
Matching rules:
|
|
|
|
|
1. Guest detail matches: Record in conversion_guests table with hashed_customer reference
|
|
|
|
|
2. ID-based matches (md5_hash/click_id): Can also record directly in conversions-reservations
|
|
|
|
|
with directly_attributable=True
|
|
|
|
|
3. Regular guest detection: Check if conversions exist with dates before all reservations,
|
|
|
|
|
or if conversion_room dates match reservation dates
|
|
|
|
|
|
|
|
|
|
Updates stats dictionary in-place if provided.
|
|
|
|
|
|
|
|
|
|
@@ -1540,7 +1525,7 @@ class ConversionService:
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(Conversion)
|
|
|
|
|
.where(Conversion.pms_reservation_id == pms_reservation_id)
|
|
|
|
|
.options(selectinload(Conversion.guest))
|
|
|
|
|
.options(selectinload(Conversion.guest), selectinload(Conversion.conversion_rooms))
|
|
|
|
|
)
|
|
|
|
|
conversion = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
@@ -1584,6 +1569,10 @@ class ConversionService:
|
|
|
|
|
|
|
|
|
|
# Update the conversion with matched entities if found
|
|
|
|
|
if matched_reservation or matched_customer or matched_hashed_customer:
|
|
|
|
|
# Set attribution flags and matched entities based on match type
|
|
|
|
|
if match_type == "id":
|
|
|
|
|
# ID-based matches (fbclid/gclid/md5_unique_id) are always directly attributable
|
|
|
|
|
# Link to both reservation and customer
|
|
|
|
|
conversion.reservation_id = (
|
|
|
|
|
matched_reservation.id if matched_reservation else None
|
|
|
|
|
)
|
|
|
|
|
@@ -1593,15 +1582,42 @@ class ConversionService:
|
|
|
|
|
conversion.hashed_customer_id = (
|
|
|
|
|
matched_hashed_customer.id if matched_hashed_customer else None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Set attribution flags based on match type
|
|
|
|
|
if match_type == "id":
|
|
|
|
|
conversion.directly_attributable = True
|
|
|
|
|
conversion.guest_matched = False
|
|
|
|
|
is_attributable = True
|
|
|
|
|
|
|
|
|
|
# Check if guest is regular for ID matches
|
|
|
|
|
if matched_reservation:
|
|
|
|
|
await self._check_if_regular(conversion, matched_reservation, session)
|
|
|
|
|
|
|
|
|
|
elif match_type == "guest_details":
|
|
|
|
|
conversion.directly_attributable = False
|
|
|
|
|
# Guest detail matches: link to customer/hashed_customer directly (NO reservation)
|
|
|
|
|
# Only link to reservation if dates match
|
|
|
|
|
conversion.customer_id = (
|
|
|
|
|
matched_customer.id if matched_customer else None
|
|
|
|
|
)
|
|
|
|
|
conversion.hashed_customer_id = (
|
|
|
|
|
matched_hashed_customer.id if matched_hashed_customer else None
|
|
|
|
|
)
|
|
|
|
|
conversion.guest_matched = True
|
|
|
|
|
|
|
|
|
|
# For guest-detail matches, we don't have a matched_reservation
|
|
|
|
|
# Instead, check if dates align with any existing reservations for this customer
|
|
|
|
|
is_attributable = await self._check_if_attributable_guest_match(
|
|
|
|
|
matched_customer, conversion, session
|
|
|
|
|
)
|
|
|
|
|
conversion.directly_attributable = is_attributable
|
|
|
|
|
|
|
|
|
|
# Check if guest is regular (if we have a customer to reference)
|
|
|
|
|
if matched_customer:
|
|
|
|
|
await self._check_if_regular_by_customer(
|
|
|
|
|
conversion, matched_customer, session
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Update conversion_guest with hashed_customer reference if matched
|
|
|
|
|
if conversion_guest and matched_hashed_customer:
|
|
|
|
|
conversion_guest.hashed_customer_id = matched_hashed_customer.id
|
|
|
|
|
|
|
|
|
|
conversion.updated_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
# Update stats if provided
|
|
|
|
|
@@ -1614,3 +1630,240 @@ class ConversionService:
|
|
|
|
|
stats["matched_to_hashed_customer"] += 1
|
|
|
|
|
else:
|
|
|
|
|
stats["unmatched"] += 1
|
|
|
|
|
|
|
|
|
|
async def _check_if_regular(
|
|
|
|
|
self,
|
|
|
|
|
conversion: Conversion,
|
|
|
|
|
matched_reservation: Reservation,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Check if guest is a regular customer and update is_regular flag.
|
|
|
|
|
|
|
|
|
|
A guest is regular if they have conversions with dates before their first completed reservation.
|
|
|
|
|
Otherwise, is_regular is set to False.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
conversion: The Conversion record being evaluated
|
|
|
|
|
matched_reservation: The matched Reservation record
|
|
|
|
|
session: AsyncSession for database queries
|
|
|
|
|
"""
|
|
|
|
|
if not conversion.guest or not matched_reservation.customer_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Find the earliest paying conversion for this customer
|
|
|
|
|
# (booked reservations from hotel with actual revenue)
|
|
|
|
|
earliest_paying_conversion_result = await session.execute(
|
|
|
|
|
select(Conversion)
|
|
|
|
|
.join(ConversionRoom, Conversion.id == ConversionRoom.conversion_id)
|
|
|
|
|
.where(
|
|
|
|
|
Conversion.hotel_id == conversion.hotel_id,
|
|
|
|
|
Conversion.guest_id == conversion.guest_id,
|
|
|
|
|
ConversionRoom.total_revenue.isnot(None),
|
|
|
|
|
ConversionRoom.total_revenue > Decimal(0),
|
|
|
|
|
)
|
|
|
|
|
.order_by(Conversion.reservation_date.asc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
earliest_paying_conversion = earliest_paying_conversion_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not earliest_paying_conversion:
|
|
|
|
|
conversion.guest.is_regular = False
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Find the earliest reservation (booking request we sent) for this customer
|
|
|
|
|
earliest_reservation_result = await session.execute(
|
|
|
|
|
select(Reservation)
|
|
|
|
|
.where(Reservation.customer_id == matched_reservation.customer_id)
|
|
|
|
|
.order_by(Reservation.start_date.asc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
earliest_reservation = earliest_reservation_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not earliest_reservation:
|
|
|
|
|
conversion.guest.is_regular = False
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Guest is regular if their earliest paying conversion predates all their reservations
|
|
|
|
|
# (meaning they were already a customer before we started tracking reservations)
|
|
|
|
|
is_regular = earliest_paying_conversion.reservation_date < earliest_reservation.start_date
|
|
|
|
|
conversion.guest.is_regular = is_regular
|
|
|
|
|
|
|
|
|
|
if is_regular:
|
|
|
|
|
_LOGGER.info(
|
|
|
|
|
"Marking guest as regular: earliest paying conversion date %s is before first reservation %s",
|
|
|
|
|
earliest_paying_conversion.reservation_date,
|
|
|
|
|
earliest_reservation.start_date,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _check_if_attributable(
|
|
|
|
|
self,
|
|
|
|
|
matched_reservation: Reservation,
|
|
|
|
|
conversion: Conversion,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Check if a guest detail matched conversion should be marked as attributable.
|
|
|
|
|
|
|
|
|
|
A conversion is attributable ONLY if the conversion_room dates match the reservation dates closely.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
matched_reservation: The matched Reservation record
|
|
|
|
|
conversion: The Conversion record being evaluated
|
|
|
|
|
session: AsyncSession for database queries
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if the conversion should be marked as attributable (based on date matching), False otherwise
|
|
|
|
|
"""
|
|
|
|
|
# Check if conversion_room dates match reservation dates (criterion for attributability)
|
|
|
|
|
if not conversion.conversion_rooms or not matched_reservation:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
for room in conversion.conversion_rooms:
|
|
|
|
|
if (
|
|
|
|
|
room.arrival_date
|
|
|
|
|
and room.departure_date
|
|
|
|
|
and matched_reservation.start_date
|
|
|
|
|
and matched_reservation.end_date
|
|
|
|
|
):
|
|
|
|
|
# Check if dates match or mostly match (within 1 day tolerance)
|
|
|
|
|
arrival_match = abs(
|
|
|
|
|
(room.arrival_date - matched_reservation.start_date).days
|
|
|
|
|
) <= 7
|
|
|
|
|
departure_match = abs(
|
|
|
|
|
(room.departure_date - matched_reservation.end_date).days
|
|
|
|
|
) <= 7
|
|
|
|
|
|
|
|
|
|
if arrival_match and departure_match:
|
|
|
|
|
_LOGGER.info(
|
|
|
|
|
"Marking conversion as attributable: room dates %s-%s match reservation dates %s-%s",
|
|
|
|
|
room.arrival_date,
|
|
|
|
|
room.departure_date,
|
|
|
|
|
matched_reservation.start_date,
|
|
|
|
|
matched_reservation.end_date,
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def _check_if_attributable_guest_match(
|
|
|
|
|
self,
|
|
|
|
|
matched_customer: Customer | None,
|
|
|
|
|
conversion: Conversion,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Check if a guest-detail matched conversion is attributable based on date alignment.
|
|
|
|
|
|
|
|
|
|
For guest-detail matches (without a specific reservation), check if the conversion's
|
|
|
|
|
room dates align with ANY of the customer's reservations (date tolerance ±7 days).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
matched_customer: The matched Customer record
|
|
|
|
|
conversion: The Conversion record being evaluated
|
|
|
|
|
session: AsyncSession for database queries
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if conversion dates align with any reservation, False otherwise
|
|
|
|
|
"""
|
|
|
|
|
if not matched_customer or not conversion.conversion_rooms:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# Find all reservations for this customer
|
|
|
|
|
reservations_result = await session.execute(
|
|
|
|
|
select(Reservation).where(Reservation.customer_id == matched_customer.id)
|
|
|
|
|
)
|
|
|
|
|
reservations = reservations_result.scalars().all()
|
|
|
|
|
|
|
|
|
|
if not reservations:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# Check if any conversion_room dates match any reservation dates
|
|
|
|
|
for room in conversion.conversion_rooms:
|
|
|
|
|
if not room.arrival_date or not room.departure_date:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for reservation in reservations:
|
|
|
|
|
if not reservation.start_date or not reservation.end_date:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Check if dates match (within ±7 day tolerance)
|
|
|
|
|
arrival_match = abs(
|
|
|
|
|
(room.arrival_date - reservation.start_date).days
|
|
|
|
|
) <= 7
|
|
|
|
|
departure_match = abs(
|
|
|
|
|
(room.departure_date - reservation.end_date).days
|
|
|
|
|
) <= 7
|
|
|
|
|
|
|
|
|
|
if arrival_match and departure_match:
|
|
|
|
|
_LOGGER.info(
|
|
|
|
|
"Marking guest-detail match as attributable: room dates %s-%s match reservation dates %s-%s",
|
|
|
|
|
room.arrival_date,
|
|
|
|
|
room.departure_date,
|
|
|
|
|
reservation.start_date,
|
|
|
|
|
reservation.end_date,
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def _check_if_regular_by_customer(
|
|
|
|
|
self,
|
|
|
|
|
conversion: Conversion,
|
|
|
|
|
matched_customer: Customer,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Check if guest is regular based on customer without a specific reservation.
|
|
|
|
|
|
|
|
|
|
For guest-detail matches, determine if the guest is regular by checking if
|
|
|
|
|
their earliest paying conversion predates their earliest reservation.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
conversion: The Conversion record being evaluated
|
|
|
|
|
matched_customer: The matched Customer record
|
|
|
|
|
session: AsyncSession for database queries
|
|
|
|
|
"""
|
|
|
|
|
if not conversion.guest or not matched_customer.id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Find the earliest paying conversion for this guest
|
|
|
|
|
# (booked reservations from hotel with actual revenue)
|
|
|
|
|
earliest_paying_conversion_result = await session.execute(
|
|
|
|
|
select(Conversion)
|
|
|
|
|
.join(ConversionRoom, Conversion.id == ConversionRoom.conversion_id)
|
|
|
|
|
.where(
|
|
|
|
|
Conversion.hotel_id == conversion.hotel_id,
|
|
|
|
|
Conversion.guest_id == conversion.guest_id,
|
|
|
|
|
ConversionRoom.total_revenue.isnot(None),
|
|
|
|
|
ConversionRoom.total_revenue > Decimal(0),
|
|
|
|
|
)
|
|
|
|
|
.order_by(Conversion.reservation_date.asc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
earliest_paying_conversion = earliest_paying_conversion_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not earliest_paying_conversion:
|
|
|
|
|
conversion.guest.is_regular = False
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Find the earliest reservation (booking request we sent) for this customer
|
|
|
|
|
earliest_reservation_result = await session.execute(
|
|
|
|
|
select(Reservation)
|
|
|
|
|
.where(Reservation.customer_id == matched_customer.id)
|
|
|
|
|
.order_by(Reservation.start_date.asc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
earliest_reservation = earliest_reservation_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not earliest_reservation:
|
|
|
|
|
conversion.guest.is_regular = False
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Guest is regular if their earliest paying conversion predates all their reservations
|
|
|
|
|
# (meaning they were already a customer before we started tracking reservations)
|
|
|
|
|
is_regular = earliest_paying_conversion.reservation_date < earliest_reservation.start_date
|
|
|
|
|
conversion.guest.is_regular = is_regular
|
|
|
|
|
|
|
|
|
|
if is_regular:
|
|
|
|
|
_LOGGER.info(
|
|
|
|
|
"Marking guest as regular (via customer): earliest paying conversion date %s is before first reservation %s",
|
|
|
|
|
earliest_paying_conversion.reservation_date,
|
|
|
|
|
earliest_reservation.start_date,
|
|
|
|
|
)
|
|
|
|
|
|