Hashed conversion matching and more. #12
@@ -1127,18 +1127,24 @@ class ConversionService:
|
|||||||
Returns a mapping of guest_id -> HashedCustomer for all unique guests found in
|
Returns a mapping of guest_id -> HashedCustomer for all unique guests found in
|
||||||
unmatched conversions. Only processes each guest once.
|
unmatched conversions. Only processes each guest once.
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
1. Conversions with no customer match at all (reservation_id IS NULL AND customer_id IS NULL)
|
||||||
|
2. Conversions matched to a customer but not a reservation (reservation_id IS NULL AND customer_id IS NOT NULL)
|
||||||
|
- These may have been matched in a previous run and need re-evaluation for reservation linking
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: AsyncSession for database queries
|
session: AsyncSession for database queries
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping guest_id to matched HashedCustomer (or None if no match)
|
Dictionary mapping guest_id to matched HashedCustomer (or None if no match)
|
||||||
"""
|
"""
|
||||||
# Find all conversions that have no reservation/customer match yet
|
# Find all conversions that either:
|
||||||
|
# - Have no match at all (reservation_id IS NULL AND customer_id IS NULL), OR
|
||||||
|
# - Have a customer but no reservation (for re-linking in case new reservations were added)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversion)
|
select(Conversion)
|
||||||
.where(
|
.where(
|
||||||
(Conversion.reservation_id.is_(None))
|
(Conversion.reservation_id.is_(None))
|
||||||
& (Conversion.customer_id.is_(None))
|
|
||||||
& (Conversion.guest_id.isnot(None))
|
& (Conversion.guest_id.isnot(None))
|
||||||
)
|
)
|
||||||
.options(selectinload(Conversion.guest))
|
.options(selectinload(Conversion.guest))
|
||||||
@@ -1201,6 +1207,13 @@ class ConversionService:
|
|||||||
that don't have a reservation yet, and try to link them to reservations based on
|
that don't have a reservation yet, and try to link them to reservations based on
|
||||||
date matching.
|
date matching.
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
1. Conversions with no customer match (will link customer first)
|
||||||
|
2. Conversions already linked to a customer from a previous run (will try to link to reservation)
|
||||||
|
|
||||||
|
After all conversions for a guest are processed, check if the guest is a regular
|
||||||
|
by looking at whether they have paying conversions that predate any reservations.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
guest_to_hashed_customer: Mapping from guest_id to matched HashedCustomer
|
guest_to_hashed_customer: Mapping from guest_id to matched HashedCustomer
|
||||||
session: AsyncSession for database queries
|
session: AsyncSession for database queries
|
||||||
@@ -1210,15 +1223,15 @@ class ConversionService:
|
|||||||
if not matched_hashed_customer or not matched_hashed_customer.customer_id:
|
if not matched_hashed_customer or not matched_hashed_customer.customer_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find all unmatched conversions from this guest
|
# Find all conversions from this guest that don't have a reservation
|
||||||
|
# (whether or not they have a customer match - we might be re-running after new reservations added)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversion)
|
select(Conversion)
|
||||||
.where(
|
.where(
|
||||||
(Conversion.guest_id == guest_id)
|
(Conversion.guest_id == guest_id)
|
||||||
& (Conversion.reservation_id.is_(None))
|
& (Conversion.reservation_id.is_(None))
|
||||||
& (Conversion.customer_id.is_(None))
|
|
||||||
)
|
)
|
||||||
.options(selectinload(Conversion.conversion_rooms))
|
.options(selectinload(Conversion.conversion_rooms), selectinload(Conversion.guest))
|
||||||
)
|
)
|
||||||
conversions = result.scalars().all()
|
conversions = result.scalars().all()
|
||||||
|
|
||||||
@@ -1239,21 +1252,26 @@ class ConversionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if matched_reservation and is_attributable:
|
if matched_reservation and is_attributable:
|
||||||
|
# Only update stats if this is a NEW match (wasn't matched before)
|
||||||
|
was_previously_matched = conversion.customer_id is not None
|
||||||
|
|
||||||
conversion.reservation_id = matched_reservation.id
|
conversion.reservation_id = matched_reservation.id
|
||||||
conversion.customer_id = matched_hashed_customer.customer_id
|
conversion.customer_id = matched_hashed_customer.customer_id
|
||||||
conversion.hashed_customer_id = matched_hashed_customer.id
|
conversion.hashed_customer_id = matched_hashed_customer.id
|
||||||
conversion.directly_attributable = False
|
conversion.directly_attributable = True
|
||||||
conversion.guest_matched = True
|
conversion.guest_matched = True
|
||||||
conversion.updated_at = datetime.now()
|
conversion.updated_at = datetime.now()
|
||||||
stats["matched_to_reservation"] += 1
|
|
||||||
|
if not was_previously_matched:
|
||||||
|
stats["matched_to_reservation"] += 1
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Phase 3c: Linked conversion (pms_id=%s) to reservation %d via guest matching",
|
"Phase 3c: Linked conversion (pms_id=%s) to reservation %d via guest matching",
|
||||||
conversion.pms_reservation_id,
|
conversion.pms_reservation_id,
|
||||||
matched_reservation.id,
|
matched_reservation.id,
|
||||||
)
|
)
|
||||||
elif matched_hashed_customer:
|
elif matched_hashed_customer and conversion.customer_id is None:
|
||||||
# No attributable reservation found, but link to customer/hashed customer
|
# Only count new customer matches (conversions that didn't have a customer before)
|
||||||
conversion.customer_id = matched_hashed_customer.customer_id
|
conversion.customer_id = matched_hashed_customer.customer_id
|
||||||
conversion.hashed_customer_id = matched_hashed_customer.id
|
conversion.hashed_customer_id = matched_hashed_customer.id
|
||||||
conversion.directly_attributable = False
|
conversion.directly_attributable = False
|
||||||
@@ -1261,6 +1279,63 @@ class ConversionService:
|
|||||||
conversion.updated_at = datetime.now()
|
conversion.updated_at = datetime.now()
|
||||||
stats["matched_to_customer"] += 1
|
stats["matched_to_customer"] += 1
|
||||||
|
|
||||||
|
# After all conversions for this guest are processed, check if guest is regular
|
||||||
|
# Look at ALL conversions from this guest to see if there are pre-dated payments
|
||||||
|
if conversions and conversions[0].guest:
|
||||||
|
await self._check_if_guest_is_regular(
|
||||||
|
guest_id, matched_hashed_customer.customer_id, session
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_regularity_for_all_matched_guests(
|
||||||
|
self, session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Phase 3d: Check regularity for ALL matched guests (both ID-matched and guest-detail-matched).
|
||||||
|
|
||||||
|
This is called after all matching is complete to evaluate every guest that has been
|
||||||
|
matched to a customer, regardless of match type. This ensures consistent regularity
|
||||||
|
evaluation across all matched conversions.
|
||||||
|
|
||||||
|
This is run on ALL matched guests, not just newly matched ones, to ensure that if
|
||||||
|
the regularity logic changes, it gets re-applied to all guests on the next run.
|
||||||
|
This maintains idempotency of the matching process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession for database queries
|
||||||
|
"""
|
||||||
|
# Get all ConversionGuests that have ANY customer link
|
||||||
|
# This includes:
|
||||||
|
# 1. Guests matched via guest-details (hashed_customer_id is not null)
|
||||||
|
# 2. Guests matched via ID-based matching (customer_id is not null via conversion)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConversionGuest).where(
|
||||||
|
ConversionGuest.hashed_customer_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
matched_guests = result.scalars().all()
|
||||||
|
|
||||||
|
if not matched_guests:
|
||||||
|
_LOGGER.debug("Phase 3d: No matched guests to check for regularity")
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug("Phase 3d: Checking regularity for %d matched guests", len(matched_guests))
|
||||||
|
|
||||||
|
for conversion_guest in matched_guests:
|
||||||
|
if not conversion_guest.hashed_customer_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the customer ID from the hashed_customer
|
||||||
|
hashed_customer_result = await session.execute(
|
||||||
|
select(HashedCustomer).where(
|
||||||
|
HashedCustomer.id == conversion_guest.hashed_customer_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hashed_customer = hashed_customer_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if hashed_customer and hashed_customer.customer_id:
|
||||||
|
await self._check_if_guest_is_regular(
|
||||||
|
conversion_guest.guest_id, hashed_customer.customer_id, session
|
||||||
|
)
|
||||||
|
|
||||||
async def _match_conversions_from_db_sequential(
|
async def _match_conversions_from_db_sequential(
|
||||||
self, pms_reservation_ids: list[str], stats: dict[str, int]
|
self, pms_reservation_ids: list[str], stats: dict[str, int]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1291,10 +1366,14 @@ class ConversionService:
|
|||||||
await self._link_matched_guests_to_reservations(
|
await self._link_matched_guests_to_reservations(
|
||||||
guest_to_hashed_customer, session, stats
|
guest_to_hashed_customer, session, stats
|
||||||
)
|
)
|
||||||
await session.commit()
|
|
||||||
|
# Phase 3d: Check regularity for all matched guests (both ID and guest-detail matched)
|
||||||
|
await self._check_regularity_for_all_matched_guests(session)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
_LOGGER.exception("Error in Phase 3b/3c guest matching: %s", e)
|
_LOGGER.exception("Error in Phase 3b/3c/3d guest matching: %s", e)
|
||||||
finally:
|
finally:
|
||||||
if self.session_maker:
|
if self.session_maker:
|
||||||
await session.close()
|
await session.close()
|
||||||
@@ -1338,10 +1417,14 @@ class ConversionService:
|
|||||||
await self._link_matched_guests_to_reservations(
|
await self._link_matched_guests_to_reservations(
|
||||||
guest_to_hashed_customer, session, stats
|
guest_to_hashed_customer, session, stats
|
||||||
)
|
)
|
||||||
await session.commit()
|
|
||||||
|
# Phase 3d: Check regularity for all matched guests (both ID and guest-detail matched)
|
||||||
|
await self._check_regularity_for_all_matched_guests(session)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
_LOGGER.exception("Error in Phase 3b/3c guest matching: %s", e)
|
_LOGGER.exception("Error in Phase 3b/3c/3d guest matching: %s", e)
|
||||||
finally:
|
finally:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
@@ -1494,10 +1577,6 @@ class ConversionService:
|
|||||||
conversion.directly_attributable = True
|
conversion.directly_attributable = True
|
||||||
conversion.guest_matched = False
|
conversion.guest_matched = False
|
||||||
|
|
||||||
# Check if guest is regular
|
|
||||||
if matched_reservation:
|
|
||||||
await self._check_if_regular(conversion, matched_reservation, session)
|
|
||||||
|
|
||||||
# Update conversion_guest with hashed_customer reference if matched
|
# Update conversion_guest with hashed_customer reference if matched
|
||||||
if conversion_guest and matched_hashed_customer:
|
if conversion_guest and matched_hashed_customer:
|
||||||
conversion_guest.hashed_customer_id = matched_hashed_customer.id
|
conversion_guest.hashed_customer_id = matched_hashed_customer.id
|
||||||
@@ -1515,33 +1594,41 @@ class ConversionService:
|
|||||||
else:
|
else:
|
||||||
stats["unmatched"] += 1
|
stats["unmatched"] += 1
|
||||||
|
|
||||||
async def _check_if_regular(
|
async def _check_if_guest_is_regular(
|
||||||
self,
|
self,
|
||||||
conversion: Conversion,
|
guest_id: str,
|
||||||
matched_reservation: Reservation,
|
customer_id: int,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check if guest is a regular customer and update is_regular flag.
|
"""Check if a guest is a regular customer based on conversion and reservation history.
|
||||||
|
|
||||||
A guest is regular if they have conversions with dates before their first completed reservation.
|
A guest is regular if they have conversions with paying bookings that predate their first
|
||||||
Otherwise, is_regular is set to False.
|
reservation sent by us. This indicates they were already a customer before we started tracking.
|
||||||
|
|
||||||
|
This check is done AFTER all conversions for a guest have been processed and matched,
|
||||||
|
so it can evaluate the complete picture of their payment history vs our reservation history.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conversion: The Conversion record being evaluated
|
guest_id: The guest ID to evaluate
|
||||||
matched_reservation: The matched Reservation record
|
customer_id: The matched customer ID
|
||||||
session: AsyncSession for database queries
|
session: AsyncSession for database queries
|
||||||
"""
|
"""
|
||||||
if not conversion.guest or not matched_reservation.customer_id:
|
# Get the ConversionGuest record
|
||||||
|
guest_result = await session.execute(
|
||||||
|
select(ConversionGuest).where(ConversionGuest.guest_id == guest_id)
|
||||||
|
)
|
||||||
|
conversion_guest = guest_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not conversion_guest:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the earliest paying conversion for this customer
|
# Find the earliest paying conversion for this guest (across all hotels)
|
||||||
# (booked reservations from hotel with actual revenue)
|
# Look for conversions with actual revenue
|
||||||
earliest_paying_conversion_result = await session.execute(
|
earliest_paying_conversion_result = await session.execute(
|
||||||
select(Conversion)
|
select(Conversion)
|
||||||
.join(ConversionRoom, Conversion.id == ConversionRoom.conversion_id)
|
.join(ConversionRoom, Conversion.id == ConversionRoom.conversion_id)
|
||||||
.where(
|
.where(
|
||||||
Conversion.hotel_id == conversion.hotel_id,
|
Conversion.guest_id == guest_id,
|
||||||
Conversion.guest_id == conversion.guest_id,
|
|
||||||
ConversionRoom.total_revenue.isnot(None),
|
ConversionRoom.total_revenue.isnot(None),
|
||||||
ConversionRoom.total_revenue > Decimal(0),
|
ConversionRoom.total_revenue > Decimal(0),
|
||||||
)
|
)
|
||||||
@@ -1551,32 +1638,44 @@ class ConversionService:
|
|||||||
earliest_paying_conversion = earliest_paying_conversion_result.scalar_one_or_none()
|
earliest_paying_conversion = earliest_paying_conversion_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not earliest_paying_conversion:
|
if not earliest_paying_conversion:
|
||||||
conversion.guest.is_regular = False
|
# No paying conversions found for this guest
|
||||||
|
conversion_guest.is_regular = False
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the earliest reservation (booking request we sent) for this customer
|
# Find the earliest reservation sent to this customer
|
||||||
earliest_reservation_result = await session.execute(
|
earliest_reservation_result = await session.execute(
|
||||||
select(Reservation)
|
select(Reservation)
|
||||||
.where(Reservation.customer_id == matched_reservation.customer_id)
|
.where(Reservation.customer_id == customer_id)
|
||||||
.order_by(Reservation.start_date.asc())
|
.order_by(Reservation.start_date.asc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
earliest_reservation = earliest_reservation_result.scalar_one_or_none()
|
earliest_reservation = earliest_reservation_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not earliest_reservation:
|
if not earliest_reservation:
|
||||||
conversion.guest.is_regular = False
|
# No reservations for this customer yet, can't determine regularity
|
||||||
|
conversion_guest.is_regular = False
|
||||||
return
|
return
|
||||||
|
|
||||||
# Guest is regular if their earliest paying conversion predates all their reservations
|
# Guest is regular if their earliest paying conversion predates our first reservation
|
||||||
# (meaning they were already a customer before we started tracking reservations)
|
# (meaning they were already a customer before we sent them a reservation)
|
||||||
is_regular = earliest_paying_conversion.reservation_date < earliest_reservation.start_date
|
# Compare against the reservation's creation date (when WE created/sent it), not check-in date
|
||||||
conversion.guest.is_regular = is_regular
|
# Convert created_at to date for comparison with reservation_date (both are dates)
|
||||||
|
is_regular = earliest_paying_conversion.reservation_date < earliest_reservation.created_at.date()
|
||||||
|
conversion_guest.is_regular = is_regular
|
||||||
|
|
||||||
if is_regular:
|
if is_regular:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Marking guest as regular: earliest paying conversion date %s is before first reservation %s",
|
"Marking guest %s as regular: earliest paying conversion %s predates first reservation created at %s",
|
||||||
|
guest_id,
|
||||||
earliest_paying_conversion.reservation_date,
|
earliest_paying_conversion.reservation_date,
|
||||||
earliest_reservation.start_date,
|
earliest_reservation.created_at,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Guest %s is not regular: first paying conversion %s is after/equal to first reservation created at %s",
|
||||||
|
guest_id,
|
||||||
|
earliest_paying_conversion.reservation_date,
|
||||||
|
earliest_reservation.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_if_attributable(
|
async def _check_if_attributable(
|
||||||
|
|||||||
Reference in New Issue
Block a user