merge_db_fixes_to_main #16
@@ -1558,7 +1558,7 @@ class ConversionService:
|
|||||||
"regular": 0,
|
"regular": 0,
|
||||||
"awareness": 0,
|
"awareness": 0,
|
||||||
}
|
}
|
||||||
|
_LOGGER.info("Classifying regular guests for hotel %s", self.hotel_id)
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(ConversionGuest)
|
select(ConversionGuest)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import pytest
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from alpine_bits_python.conversion_service import ConversionService
|
from alpine_bits_python.conversion_service import ConversionService
|
||||||
from alpine_bits_python.csv_import import CSVImporter
|
from alpine_bits_python.csv_import import CSVImporter
|
||||||
@@ -542,6 +543,187 @@ class TestConversionServiceWithImportedData:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversionUpdatesAndMatching:
|
||||||
|
"""Tests covering conversion updates and core matching logic."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reprocessing_conversion_updates_metadata(self, test_db_session):
|
||||||
|
"""Ensure reprocessing a reservation updates metadata instead of duplicating."""
|
||||||
|
def build_xml(
|
||||||
|
*,
|
||||||
|
booking_channel: str,
|
||||||
|
advertising_medium: str,
|
||||||
|
advertising_partner: str,
|
||||||
|
room_number: str,
|
||||||
|
arrival: str,
|
||||||
|
departure: str,
|
||||||
|
revenue: float,
|
||||||
|
) -> str:
|
||||||
|
return f"""<?xml version="1.0"?>
|
||||||
|
<root>
|
||||||
|
<reservation id="2001" hotelID="39054_001" number="A-1" date="2025-01-05"
|
||||||
|
bookingChannel="{booking_channel}"
|
||||||
|
advertisingMedium="{advertising_medium}"
|
||||||
|
advertisingPartner="{advertising_partner}"
|
||||||
|
advertisingCampagne="abc123">
|
||||||
|
<guest id="900" firstName="Casey" lastName="Jordan" email="casey@example.com"/>
|
||||||
|
<roomReservations>
|
||||||
|
<roomReservation roomNumber="{room_number}" arrival="{arrival}" departure="{departure}" status="reserved">
|
||||||
|
<dailySales>
|
||||||
|
<dailySale date="{arrival}" revenueTotal="{revenue}"/>
|
||||||
|
<dailySale date="{departure}" revenueTotal="{revenue}"/>
|
||||||
|
</dailySales>
|
||||||
|
</roomReservation>
|
||||||
|
</roomReservations>
|
||||||
|
</reservation>
|
||||||
|
</root>"""
|
||||||
|
|
||||||
|
first_xml = build_xml(
|
||||||
|
booking_channel="OTA",
|
||||||
|
advertising_medium="META",
|
||||||
|
advertising_partner="cpc",
|
||||||
|
room_number="33",
|
||||||
|
arrival="2025-02-01",
|
||||||
|
departure="2025-02-03",
|
||||||
|
revenue=120.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ConversionService(test_db_session, hotel_id="39054_001")
|
||||||
|
stats_first = await service.process_conversion_xml(first_xml)
|
||||||
|
assert stats_first["total_reservations"] == 1
|
||||||
|
|
||||||
|
result = await test_db_session.execute(
|
||||||
|
select(Conversion)
|
||||||
|
.where(
|
||||||
|
Conversion.hotel_id == "39054_001",
|
||||||
|
Conversion.pms_reservation_id == 2001,
|
||||||
|
)
|
||||||
|
.options(selectinload(Conversion.conversion_rooms))
|
||||||
|
)
|
||||||
|
conversion = result.scalar_one()
|
||||||
|
assert conversion.booking_channel == "OTA"
|
||||||
|
assert conversion.advertising_partner == "cpc"
|
||||||
|
original_room_count = len(conversion.conversion_rooms)
|
||||||
|
assert original_room_count == 1
|
||||||
|
assert conversion.conversion_rooms[0].room_number == "33"
|
||||||
|
|
||||||
|
updated_xml = build_xml(
|
||||||
|
booking_channel="DIRECT",
|
||||||
|
advertising_medium="WEBSITE",
|
||||||
|
advertising_partner="organic",
|
||||||
|
room_number="44",
|
||||||
|
arrival="2025-02-02",
|
||||||
|
departure="2025-02-04",
|
||||||
|
revenue=150.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_second = await service.process_conversion_xml(updated_xml)
|
||||||
|
assert stats_second["total_reservations"] == 1
|
||||||
|
|
||||||
|
test_db_session.expire_all()
|
||||||
|
result = await test_db_session.execute(
|
||||||
|
select(Conversion)
|
||||||
|
.where(
|
||||||
|
Conversion.hotel_id == "39054_001",
|
||||||
|
Conversion.pms_reservation_id == 2001,
|
||||||
|
)
|
||||||
|
.options(selectinload(Conversion.conversion_rooms))
|
||||||
|
)
|
||||||
|
updated_conversion = result.scalar_one()
|
||||||
|
assert updated_conversion.booking_channel == "DIRECT"
|
||||||
|
assert updated_conversion.advertising_medium == "WEBSITE"
|
||||||
|
assert updated_conversion.advertising_partner == "organic"
|
||||||
|
assert len(updated_conversion.conversion_rooms) == 1
|
||||||
|
assert updated_conversion.conversion_rooms[0].room_number == "44"
|
||||||
|
assert updated_conversion.conversion_rooms[0].arrival_date.strftime(
|
||||||
|
"%Y-%m-%d"
|
||||||
|
) == "2025-02-02"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_advertising_match_uses_hashed_email_for_disambiguation(
|
||||||
|
self, test_db_session
|
||||||
|
):
|
||||||
|
"""Ensure hashed email filters ambiguous advertising matches."""
|
||||||
|
# Create two customers/reservations sharing the same click-id prefix
|
||||||
|
customer_a = Customer(
|
||||||
|
given_name="Lara",
|
||||||
|
surname="North",
|
||||||
|
email_address="lara@example.com",
|
||||||
|
contact_id="contact_a",
|
||||||
|
)
|
||||||
|
customer_a.update_hashed_fields()
|
||||||
|
customer_b = Customer(
|
||||||
|
given_name="Mia",
|
||||||
|
surname="West",
|
||||||
|
email_address="mia@example.com",
|
||||||
|
contact_id="contact_b",
|
||||||
|
)
|
||||||
|
customer_b.update_hashed_fields()
|
||||||
|
|
||||||
|
test_db_session.add_all([customer_a, customer_b])
|
||||||
|
await test_db_session.flush()
|
||||||
|
|
||||||
|
reservation_a = Reservation(
|
||||||
|
customer_id=customer_a.id,
|
||||||
|
unique_id="res_a",
|
||||||
|
md5_unique_id="A" * 32,
|
||||||
|
hotel_id="39054_001",
|
||||||
|
fbclid="click-prefix-111",
|
||||||
|
)
|
||||||
|
reservation_b = Reservation(
|
||||||
|
customer_id=customer_b.id,
|
||||||
|
unique_id="res_b",
|
||||||
|
md5_unique_id="B" * 32,
|
||||||
|
hotel_id="39054_001",
|
||||||
|
fbclid="click-prefix-222",
|
||||||
|
)
|
||||||
|
test_db_session.add_all([reservation_a, reservation_b])
|
||||||
|
await test_db_session.commit()
|
||||||
|
|
||||||
|
from tests.helpers import ReservationXMLBuilder
|
||||||
|
|
||||||
|
xml_content = (
|
||||||
|
ReservationXMLBuilder(
|
||||||
|
hotel_id="39054_001",
|
||||||
|
reservation_id="3001",
|
||||||
|
reservation_number="B-1",
|
||||||
|
reservation_date="2025-03-10",
|
||||||
|
advertising_campagne="click-prefix",
|
||||||
|
)
|
||||||
|
.set_guest(
|
||||||
|
guest_id="701",
|
||||||
|
first_name="Mia",
|
||||||
|
last_name="West",
|
||||||
|
email="mia@example.com",
|
||||||
|
)
|
||||||
|
.add_room(
|
||||||
|
arrival="2025-04-01",
|
||||||
|
departure="2025-04-03",
|
||||||
|
room_number="55",
|
||||||
|
status="reserved",
|
||||||
|
revenue_logis_per_day=180.0,
|
||||||
|
)
|
||||||
|
.build_xml()
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ConversionService(test_db_session, hotel_id="39054_001")
|
||||||
|
stats = await service.process_conversion_xml(xml_content)
|
||||||
|
|
||||||
|
result = await test_db_session.execute(
|
||||||
|
select(Conversion)
|
||||||
|
.where(
|
||||||
|
Conversion.hotel_id == "39054_001",
|
||||||
|
Conversion.pms_reservation_id == 3001,
|
||||||
|
)
|
||||||
|
.options(selectinload(Conversion.guest))
|
||||||
|
)
|
||||||
|
conversion = result.scalar_one()
|
||||||
|
assert conversion.reservation_id == reservation_b.id
|
||||||
|
assert conversion.customer_id == customer_b.id
|
||||||
|
assert stats["matched_to_reservation"] == 1
|
||||||
|
assert stats["matched_to_customer"] == 0
|
||||||
|
|
||||||
|
|
||||||
class TestXMLBuilderUsage:
|
class TestXMLBuilderUsage:
|
||||||
"""Demonstrate usage of XML builder helpers for creating test data."""
|
"""Demonstrate usage of XML builder helpers for creating test data."""
|
||||||
|
|
||||||
@@ -799,17 +981,152 @@ class TestHashedMatchingLogic:
|
|||||||
assert conversion_with_guest.guest.guest_last_name == "Miller"
|
assert conversion_with_guest.guest.guest_last_name == "Miller"
|
||||||
assert conversion_with_guest.guest.guest_email == "david@example.com"
|
assert conversion_with_guest.guest.guest_email == "david@example.com"
|
||||||
|
|
||||||
# Verify conversion_room was created
|
|
||||||
room_result = await test_db_session.execute(
|
|
||||||
select(ConversionRoom).where(ConversionRoom.conversion_id == conversion.id)
|
|
||||||
)
|
|
||||||
rooms = room_result.scalars().all()
|
|
||||||
assert len(rooms) > 0, "ConversionRoom should be created"
|
|
||||||
|
|
||||||
# Verify matching occurred (may or may not have matched depending on data)
|
class TestRegularGuestClassification:
|
||||||
# The important thing is that the records exist
|
"""Tests for the classify_regular_guests helper."""
|
||||||
assert stats["total_reservations"] == 1
|
|
||||||
assert stats["total_daily_sales"] == 1
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_regular_guest_with_unattributable_history(
|
||||||
|
self, test_db_session
|
||||||
|
):
|
||||||
|
"""Guests with unattributable paying stays become regulars."""
|
||||||
|
from tests.helpers import MultiReservationXMLBuilder, ReservationXMLBuilder
|
||||||
|
|
||||||
|
multi = MultiReservationXMLBuilder()
|
||||||
|
base_builder = ReservationXMLBuilder(
|
||||||
|
hotel_id="39054_001",
|
||||||
|
reservation_id="4001",
|
||||||
|
reservation_number="REG-1",
|
||||||
|
reservation_date="2025-05-01",
|
||||||
|
).set_guest(
|
||||||
|
guest_id="888",
|
||||||
|
first_name="Regular",
|
||||||
|
last_name="Guest",
|
||||||
|
email="regular@example.com",
|
||||||
|
)
|
||||||
|
base_builder.add_room(
|
||||||
|
arrival="2025-06-01",
|
||||||
|
departure="2025-06-03",
|
||||||
|
room_number="71",
|
||||||
|
status="departed",
|
||||||
|
revenue_logis_per_day=220.0,
|
||||||
|
)
|
||||||
|
multi.add_reservation(base_builder)
|
||||||
|
|
||||||
|
second = ReservationXMLBuilder(
|
||||||
|
hotel_id="39054_001",
|
||||||
|
reservation_id="4002",
|
||||||
|
reservation_number="REG-2",
|
||||||
|
reservation_date="2025-05-10",
|
||||||
|
).set_guest(
|
||||||
|
guest_id="888",
|
||||||
|
first_name="Regular",
|
||||||
|
last_name="Guest",
|
||||||
|
email="regular@example.com",
|
||||||
|
)
|
||||||
|
second.add_room(
|
||||||
|
arrival="2025-07-01",
|
||||||
|
departure="2025-07-04",
|
||||||
|
room_number="72",
|
||||||
|
status="departed",
|
||||||
|
revenue_logis_per_day=210.0,
|
||||||
|
)
|
||||||
|
multi.add_reservation(second)
|
||||||
|
|
||||||
|
service = ConversionService(test_db_session, hotel_id="39054_001")
|
||||||
|
await service.process_conversion_xml(multi.build_xml())
|
||||||
|
|
||||||
|
stats = await service.classify_regular_guests(updated_within_hours=None)
|
||||||
|
assert stats["regular"] == 1
|
||||||
|
|
||||||
|
guest = await test_db_session.execute(
|
||||||
|
select(ConversionGuest).where(
|
||||||
|
ConversionGuest.hotel_id == "39054_001",
|
||||||
|
ConversionGuest.guest_id == 888,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
guest_record = guest.scalar_one()
|
||||||
|
assert guest_record.is_regular is True
|
||||||
|
assert guest_record.is_awareness_guest is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_awareness_guest_when_first_stay_attributable(
|
||||||
|
self, test_db_session
|
||||||
|
):
|
||||||
|
"""If the earliest paying stay is attributable, mark awareness guests."""
|
||||||
|
from tests.helpers import MultiReservationXMLBuilder, ReservationXMLBuilder
|
||||||
|
|
||||||
|
multi = MultiReservationXMLBuilder()
|
||||||
|
first = ReservationXMLBuilder(
|
||||||
|
hotel_id="39054_001",
|
||||||
|
reservation_id="4101",
|
||||||
|
reservation_number="AW-1",
|
||||||
|
reservation_date="2025-08-01",
|
||||||
|
).set_guest(
|
||||||
|
guest_id="889",
|
||||||
|
first_name="Aware",
|
||||||
|
last_name="Guest",
|
||||||
|
email="aware@example.com",
|
||||||
|
)
|
||||||
|
first.add_room(
|
||||||
|
arrival="2025-09-01",
|
||||||
|
departure="2025-09-03",
|
||||||
|
room_number="81",
|
||||||
|
status="departed",
|
||||||
|
revenue_logis_per_day=250.0,
|
||||||
|
)
|
||||||
|
multi.add_reservation(first)
|
||||||
|
|
||||||
|
second = ReservationXMLBuilder(
|
||||||
|
hotel_id="39054_001",
|
||||||
|
reservation_id="4102",
|
||||||
|
reservation_number="AW-2",
|
||||||
|
reservation_date="2025-08-10",
|
||||||
|
).set_guest(
|
||||||
|
guest_id="889",
|
||||||
|
first_name="Aware",
|
||||||
|
last_name="Guest",
|
||||||
|
email="aware@example.com",
|
||||||
|
)
|
||||||
|
second.add_room(
|
||||||
|
arrival="2025-10-05",
|
||||||
|
departure="2025-10-08",
|
||||||
|
room_number="82",
|
||||||
|
status="departed",
|
||||||
|
revenue_logis_per_day=260.0,
|
||||||
|
)
|
||||||
|
multi.add_reservation(second)
|
||||||
|
|
||||||
|
service = ConversionService(test_db_session, hotel_id="39054_001")
|
||||||
|
await service.process_conversion_xml(multi.build_xml())
|
||||||
|
|
||||||
|
# Mark earliest stay as attributable to simulate campaign match
|
||||||
|
result = await test_db_session.execute(
|
||||||
|
select(Conversion)
|
||||||
|
.where(
|
||||||
|
Conversion.hotel_id == "39054_001",
|
||||||
|
Conversion.guest_id == 889,
|
||||||
|
)
|
||||||
|
.order_by(Conversion.reservation_date.asc())
|
||||||
|
)
|
||||||
|
conversions = result.scalars().all()
|
||||||
|
conversions[0].directly_attributable = True
|
||||||
|
conversions[1].directly_attributable = False
|
||||||
|
await test_db_session.commit()
|
||||||
|
|
||||||
|
stats = await service.classify_regular_guests(updated_within_hours=None)
|
||||||
|
assert stats["regular"] == 1
|
||||||
|
assert stats["awareness"] == 1
|
||||||
|
|
||||||
|
guest = await test_db_session.execute(
|
||||||
|
select(ConversionGuest).where(
|
||||||
|
ConversionGuest.hotel_id == "39054_001",
|
||||||
|
ConversionGuest.guest_id == 889,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
guest_record = guest.scalar_one()
|
||||||
|
assert guest_record.is_regular is True
|
||||||
|
assert guest_record.is_awareness_guest is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_guest_composite_key_prevents_duplicates(
|
async def test_conversion_guest_composite_key_prevents_duplicates(
|
||||||
|
|||||||
Reference in New Issue
Block a user