Compare commits
4 Commits
0f00b4508e
...
de61d67508
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de61d67508 | ||
|
|
473becfe5b | ||
|
|
0f3805bed4 | ||
|
|
b1c867ca93 |
@@ -0,0 +1,167 @@
|
|||||||
|
"""Id columns changed to integer, foreign_keys added
|
||||||
|
|
||||||
|
Revision ID: b50c0f45030a
|
||||||
|
Revises: b2cfe2d3aabc
|
||||||
|
Create Date: 2025-12-02 11:06:25.850790
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b50c0f45030a'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'b2cfe2d3aabc'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
|
||||||
|
# Drop composite FK constraint first (references guest_id columns)
|
||||||
|
op.drop_constraint(
|
||||||
|
'conversions_hotel_id_guest_id_fkey', 'conversions', type_='foreignkey'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now convert the guest_id columns
|
||||||
|
op.alter_column('conversion_guests', 'guest_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
type_=sa.Integer(),
|
||||||
|
existing_nullable=False,
|
||||||
|
postgresql_using='guest_id::integer')
|
||||||
|
op.alter_column('conversion_guests', 'is_regular',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=True)
|
||||||
|
op.drop_constraint(op.f('conversion_guests_hashed_customer_id_fkey'), 'conversion_guests', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('fk_conversion_guests_hashed_customer_id_hashed_customers'), 'conversion_guests', 'hashed_customers', ['hashed_customer_id'], ['id'])
|
||||||
|
# Create FK with NOT VALID to skip checking existing data
|
||||||
|
# (hotels table will be populated from config when app starts)
|
||||||
|
op.create_foreign_key(
|
||||||
|
op.f('fk_conversion_guests_hotel_id_hotels'),
|
||||||
|
'conversion_guests',
|
||||||
|
'hotels',
|
||||||
|
['hotel_id'],
|
||||||
|
['hotel_id'],
|
||||||
|
ondelete='CASCADE',
|
||||||
|
postgresql_not_valid=True
|
||||||
|
)
|
||||||
|
op.alter_column('conversions', 'hotel_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('conversions', 'pms_reservation_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
type_=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
postgresql_using='pms_reservation_id::integer')
|
||||||
|
op.alter_column('conversions', 'guest_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
type_=sa.Integer(),
|
||||||
|
existing_nullable=True,
|
||||||
|
postgresql_using='guest_id::integer')
|
||||||
|
op.alter_column('conversions', 'directly_attributable',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('conversions', 'guest_matched',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
# Re-create composite FK constraint after column type changes
|
||||||
|
op.create_foreign_key(
|
||||||
|
'conversions_hotel_id_guest_id_fkey',
|
||||||
|
'conversions',
|
||||||
|
'conversion_guests',
|
||||||
|
['hotel_id', 'guest_id'],
|
||||||
|
['hotel_id', 'guest_id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_unique_constraint('uq_conversion_hotel_reservation', 'conversions', ['hotel_id', 'pms_reservation_id'])
|
||||||
|
# Create FK with NOT VALID for same reason as above
|
||||||
|
op.create_foreign_key(
|
||||||
|
op.f('fk_conversions_hotel_id_hotels'),
|
||||||
|
'conversions',
|
||||||
|
'hotels',
|
||||||
|
['hotel_id'],
|
||||||
|
['hotel_id'],
|
||||||
|
ondelete='CASCADE',
|
||||||
|
postgresql_not_valid=True
|
||||||
|
)
|
||||||
|
op.drop_constraint(op.f('customers_contact_id_key'), 'customers', type_='unique')
|
||||||
|
op.create_unique_constraint(op.f('uq_customers_contact_id'), 'customers', ['contact_id'])
|
||||||
|
op.drop_constraint(op.f('hashed_customers_contact_id_key'), 'hashed_customers', type_='unique')
|
||||||
|
op.drop_constraint(op.f('hashed_customers_customer_id_key'), 'hashed_customers', type_='unique')
|
||||||
|
op.create_unique_constraint(op.f('uq_hashed_customers_contact_id'), 'hashed_customers', ['contact_id'])
|
||||||
|
op.create_unique_constraint(op.f('uq_hashed_customers_customer_id'), 'hashed_customers', ['customer_id'])
|
||||||
|
op.drop_index(op.f('ix_reservations_hashed_customer_id'), table_name='reservations')
|
||||||
|
op.drop_constraint(op.f('reservations_md5_unique_id_key'), 'reservations', type_='unique')
|
||||||
|
op.drop_constraint(op.f('reservations_unique_id_key'), 'reservations', type_='unique')
|
||||||
|
op.create_unique_constraint(op.f('uq_reservations_md5_unique_id'), 'reservations', ['md5_unique_id'])
|
||||||
|
op.create_unique_constraint(op.f('uq_reservations_unique_id'), 'reservations', ['unique_id'])
|
||||||
|
op.drop_index(op.f('idx_room_availability_inventory_date'), table_name='room_availability')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('idx_room_availability_inventory_date'), 'room_availability', ['inventory_id', 'date'], unique=False)
|
||||||
|
op.drop_constraint(op.f('uq_reservations_unique_id'), 'reservations', type_='unique')
|
||||||
|
op.drop_constraint(op.f('uq_reservations_md5_unique_id'), 'reservations', type_='unique')
|
||||||
|
op.create_unique_constraint(op.f('reservations_unique_id_key'), 'reservations', ['unique_id'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.create_unique_constraint(op.f('reservations_md5_unique_id_key'), 'reservations', ['md5_unique_id'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.create_index(op.f('ix_reservations_hashed_customer_id'), 'reservations', ['hashed_customer_id'], unique=False)
|
||||||
|
op.drop_constraint(op.f('uq_hashed_customers_customer_id'), 'hashed_customers', type_='unique')
|
||||||
|
op.drop_constraint(op.f('uq_hashed_customers_contact_id'), 'hashed_customers', type_='unique')
|
||||||
|
op.create_unique_constraint(op.f('hashed_customers_customer_id_key'), 'hashed_customers', ['customer_id'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.create_unique_constraint(op.f('hashed_customers_contact_id_key'), 'hashed_customers', ['contact_id'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.drop_constraint(op.f('uq_customers_contact_id'), 'customers', type_='unique')
|
||||||
|
op.create_unique_constraint(op.f('customers_contact_id_key'), 'customers', ['contact_id'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.drop_constraint(op.f('fk_conversions_hotel_id_hotels'), 'conversions', type_='foreignkey')
|
||||||
|
op.drop_constraint('uq_conversion_hotel_reservation', 'conversions', type_='unique')
|
||||||
|
|
||||||
|
# Drop composite FK constraint before changing column types back
|
||||||
|
op.drop_constraint(
|
||||||
|
'conversions_hotel_id_guest_id_fkey', 'conversions', type_='foreignkey'
|
||||||
|
)
|
||||||
|
op.alter_column('conversions', 'guest_matched',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('conversions', 'directly_attributable',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('conversions', 'guest_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
type_=sa.VARCHAR(),
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('conversions', 'pms_reservation_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
type_=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('conversions', 'hotel_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.drop_constraint(op.f('fk_conversion_guests_hotel_id_hotels'), 'conversion_guests', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('fk_conversion_guests_hashed_customer_id_hashed_customers'), 'conversion_guests', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('conversion_guests_hashed_customer_id_fkey'), 'conversion_guests', 'hashed_customers', ['hashed_customer_id'], ['id'], ondelete='SET NULL')
|
||||||
|
op.alter_column('conversion_guests', 'is_regular',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('conversion_guests', 'guest_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
type_=sa.VARCHAR(),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
# Re-create composite FK constraint after reverting column types
|
||||||
|
op.create_foreign_key(
|
||||||
|
'conversions_hotel_id_guest_id_fkey',
|
||||||
|
'conversions',
|
||||||
|
'conversion_guests',
|
||||||
|
['hotel_id', 'guest_id'],
|
||||||
|
['hotel_id', 'guest_id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -32,6 +32,7 @@ from sqlalchemy import and_, select, update
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from alpine_bits_python.hotel_service import HotelService
|
||||||
from alpine_bits_python.schemas import WebhookRequestData
|
from alpine_bits_python.schemas import WebhookRequestData
|
||||||
|
|
||||||
from .alpinebits_server import (
|
from .alpinebits_server import (
|
||||||
@@ -46,14 +47,15 @@ from .const import HttpStatusCode, WebhookStatus
|
|||||||
from .conversion_service import ConversionService
|
from .conversion_service import ConversionService
|
||||||
from .csv_import import CSVImporter
|
from .csv_import import CSVImporter
|
||||||
from .db import Customer as DBCustomer
|
from .db import Customer as DBCustomer
|
||||||
from .db import Reservation as DBReservation
|
|
||||||
from .db import (
|
from .db import (
|
||||||
|
Hotel,
|
||||||
ResilientAsyncSession,
|
ResilientAsyncSession,
|
||||||
SessionMaker,
|
SessionMaker,
|
||||||
WebhookEndpoint,
|
WebhookEndpoint,
|
||||||
WebhookRequest,
|
WebhookRequest,
|
||||||
create_database_engine,
|
create_database_engine,
|
||||||
)
|
)
|
||||||
|
from .db import Reservation as DBReservation
|
||||||
from .db_setup import run_startup_tasks
|
from .db_setup import run_startup_tasks
|
||||||
from .email_monitoring import ReservationStatsCollector
|
from .email_monitoring import ReservationStatsCollector
|
||||||
from .email_service import create_email_service
|
from .email_service import create_email_service
|
||||||
@@ -890,8 +892,6 @@ async def handle_webhook_unified(
|
|||||||
webhook_request.status = WebhookStatus.PROCESSING
|
webhook_request.status = WebhookStatus.PROCESSING
|
||||||
webhook_request.processing_started_at = timestamp
|
webhook_request.processing_started_at = timestamp
|
||||||
else:
|
else:
|
||||||
|
|
||||||
|
|
||||||
webhook_request_data = WebhookRequestData(
|
webhook_request_data = WebhookRequestData(
|
||||||
payload_hash=payload_hash,
|
payload_hash=payload_hash,
|
||||||
webhook_endpoint_id=webhook_endpoint.id,
|
webhook_endpoint_id=webhook_endpoint.id,
|
||||||
@@ -905,7 +905,7 @@ async def handle_webhook_unified(
|
|||||||
)
|
)
|
||||||
# 5. Create new webhook_request
|
# 5. Create new webhook_request
|
||||||
webhook_request = WebhookRequest(**webhook_request_data.model_dump())
|
webhook_request = WebhookRequest(**webhook_request_data.model_dump())
|
||||||
|
|
||||||
db_session.add(webhook_request)
|
db_session.add(webhook_request)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
|
||||||
@@ -1134,6 +1134,7 @@ async def _process_conversion_xml_background(
|
|||||||
filename: str,
|
filename: str,
|
||||||
session_maker: SessionMaker,
|
session_maker: SessionMaker,
|
||||||
log_filename: Path,
|
log_filename: Path,
|
||||||
|
hotel: Hotel,
|
||||||
):
|
):
|
||||||
"""Background task to process conversion XML.
|
"""Background task to process conversion XML.
|
||||||
|
|
||||||
@@ -1162,7 +1163,7 @@ async def _process_conversion_xml_background(
|
|||||||
|
|
||||||
# Now process the conversion XML
|
# Now process the conversion XML
|
||||||
_LOGGER.info("Starting database processing of %s", filename)
|
_LOGGER.info("Starting database processing of %s", filename)
|
||||||
conversion_service = ConversionService(session_maker)
|
conversion_service = ConversionService(session_maker, hotel.hotel_id)
|
||||||
processing_stats = await conversion_service.process_conversion_xml(xml_content)
|
processing_stats = await conversion_service.process_conversion_xml(xml_content)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
@@ -1250,6 +1251,10 @@ async def handle_xml_upload(
|
|||||||
extension = Path(filename).suffix or ".xml"
|
extension = Path(filename).suffix or ".xml"
|
||||||
log_filename = logs_dir / f"{base_filename}_{username}_{timestamp}{extension}"
|
log_filename = logs_dir / f"{base_filename}_{username}_{timestamp}{extension}"
|
||||||
|
|
||||||
|
hotel_service = HotelService(db_session)
|
||||||
|
|
||||||
|
hotel = await hotel_service.get_hotel_by_username(username)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"XML file queued for processing: %s by user %s (original: %s)",
|
"XML file queued for processing: %s by user %s (original: %s)",
|
||||||
log_filename,
|
log_filename,
|
||||||
@@ -1266,6 +1271,7 @@ async def handle_xml_upload(
|
|||||||
filename,
|
filename,
|
||||||
session_maker,
|
session_maker,
|
||||||
log_filename,
|
log_filename,
|
||||||
|
hotel,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_headers = {
|
response_headers = {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""Service for handling conversion data from hotel PMS XML files."""
|
"""Service for handling conversion data from hotel PMS XML files."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import insert, or_, select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -22,6 +21,7 @@ from .db import (
|
|||||||
SessionMaker,
|
SessionMaker,
|
||||||
)
|
)
|
||||||
from .logging_config import get_logger
|
from .logging_config import get_logger
|
||||||
|
from .schemas import ConversionData, ConversionGuestData
|
||||||
|
|
||||||
_LOGGER = get_logger(__name__)
|
_LOGGER = get_logger(__name__)
|
||||||
|
|
||||||
@@ -37,7 +37,11 @@ class ConversionService:
|
|||||||
2. Concurrent mode: SessionMaker passed in, creates independent sessions per task
|
2. Concurrent mode: SessionMaker passed in, creates independent sessions per task
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession | SessionMaker | None = None, hotel_id: str | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession | SessionMaker | None = None,
|
||||||
|
hotel_id: str | None = None,
|
||||||
|
):
|
||||||
"""Initialize the ConversionService.
|
"""Initialize the ConversionService.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -81,17 +85,19 @@ class ConversionService:
|
|||||||
|
|
||||||
async def _extract_unique_guests_from_xml(
|
async def _extract_unique_guests_from_xml(
|
||||||
self, reservations: list
|
self, reservations: list
|
||||||
) -> dict[tuple[str, str | None], dict]:
|
) -> dict[tuple[str, int], ConversionGuestData]:
|
||||||
"""Extract and deduplicate all guest data from XML reservations.
|
"""Extract and deduplicate all guest data from XML reservations.
|
||||||
|
|
||||||
Phase 0: Single pass through XML to collect all unique guests.
|
Phase 0: Single pass through XML to collect all unique guests.
|
||||||
Uses (hotel_id, guest_id) as the key for deduplication.
|
Uses (hotel_id, guest_id) as the key for deduplication.
|
||||||
|
Validates each guest using Pydantic before storing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
reservations: List of XML reservation elements
|
reservations: List of XML reservation elements
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping (hotel_id, guest_id) to guest data dict
|
Dictionary mapping (hotel_id, guest_id) to validated ConversionGuestData
|
||||||
|
|
||||||
"""
|
"""
|
||||||
guest_data_by_key = {}
|
guest_data_by_key = {}
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -101,7 +107,10 @@ class ConversionService:
|
|||||||
guest_elem = reservation_elem.find("guest")
|
guest_elem = reservation_elem.find("guest")
|
||||||
|
|
||||||
if guest_elem is None:
|
if guest_elem is None:
|
||||||
_LOGGER.debug("No guest element found, skipping reservation %s (will be created with guest_id=None in Phase 2)", reservation_elem.get("id"))
|
_LOGGER.debug(
|
||||||
|
"No guest element found, skipping reservation %s (will be created with guest_id=None in Phase 2)",
|
||||||
|
reservation_elem.get("id"),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
guest_id = guest_elem.get("id")
|
guest_id = guest_elem.get("id")
|
||||||
@@ -111,33 +120,50 @@ class ConversionService:
|
|||||||
guest_country_code = guest_elem.get("countryCode")
|
guest_country_code = guest_elem.get("countryCode")
|
||||||
guest_birth_date_str = guest_elem.get("dateOfBirth")
|
guest_birth_date_str = guest_elem.get("dateOfBirth")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
guest_birth_date = None
|
guest_birth_date = None
|
||||||
if guest_birth_date_str:
|
if guest_birth_date_str:
|
||||||
try:
|
try:
|
||||||
guest_birth_date = datetime.strptime(guest_birth_date_str, "%Y-%m-%d").date()
|
guest_birth_date = datetime.strptime(
|
||||||
|
guest_birth_date_str, "%Y-%m-%d"
|
||||||
|
).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
_LOGGER.warning("Invalid birth date format: %s", guest_birth_date_str)
|
_LOGGER.warning(
|
||||||
|
"Invalid birth date format: %s", guest_birth_date_str
|
||||||
|
)
|
||||||
|
|
||||||
key = (hotel_id, guest_id)
|
# Validate guest data with Pydantic during extraction
|
||||||
|
try:
|
||||||
|
validated_guest = ConversionGuestData(
|
||||||
|
hotel_id=hotel_id,
|
||||||
|
guest_id=guest_id, # Will be validated and converted to int
|
||||||
|
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,
|
||||||
|
first_seen=now,
|
||||||
|
last_seen=now,
|
||||||
|
)
|
||||||
|
|
||||||
# Store guest data by key (will keep the last occurrence from XML)
|
# Use validated guest_id (now an int) for the key
|
||||||
guest_data_by_key[key] = {
|
key = (hotel_id, validated_guest.guest_id)
|
||||||
"hotel_id": hotel_id,
|
|
||||||
"guest_id": guest_id,
|
# Store validated guest data (will keep the last occurrence from XML)
|
||||||
"guest_first_name": guest_first_name,
|
guest_data_by_key[key] = validated_guest
|
||||||
"guest_last_name": guest_last_name,
|
|
||||||
"guest_email": guest_email,
|
except ValueError:
|
||||||
"guest_country_code": guest_country_code,
|
_LOGGER.exception(
|
||||||
"guest_birth_date": guest_birth_date,
|
"Failed to validate guest data for reservation %s",
|
||||||
"now": now,
|
reservation_elem.get("id"),
|
||||||
}
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
return guest_data_by_key
|
return guest_data_by_key
|
||||||
|
|
||||||
async def _bulk_upsert_guests(
|
async def _bulk_upsert_guests(
|
||||||
self, session: AsyncSession, guest_data_by_key: dict[tuple[str, str | None], dict]
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
guest_data_by_key: dict[tuple[str, int], ConversionGuestData],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Bulk upsert all unique guests to database using PostgreSQL ON CONFLICT.
|
"""Bulk upsert all unique guests to database using PostgreSQL ON CONFLICT.
|
||||||
|
|
||||||
@@ -147,7 +173,9 @@ class ConversionService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: AsyncSession to use
|
session: AsyncSession to use
|
||||||
guest_data_by_key: Dictionary mapping (hotel_id, guest_id) to guest data
|
guest_data_by_key: Dictionary mapping (hotel_id, guest_id) to
|
||||||
|
validated ConversionGuestData
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not guest_data_by_key:
|
if not guest_data_by_key:
|
||||||
return
|
return
|
||||||
@@ -160,28 +188,12 @@ class ConversionService:
|
|||||||
batch_end = min(batch_start + batch_size, len(items))
|
batch_end = min(batch_start + batch_size, len(items))
|
||||||
batch_items = items[batch_start:batch_end]
|
batch_items = items[batch_start:batch_end]
|
||||||
|
|
||||||
# Prepare list of values for this batch
|
# Prepare list of values for this batch (already validated)
|
||||||
values_list = []
|
values_list = []
|
||||||
for (hotel_id, guest_id), guest_data in batch_items:
|
for (hotel_id, guest_id), validated_guest in batch_items:
|
||||||
now = guest_data["now"]
|
# Convert validated Pydantic model to dict for insertion
|
||||||
values_list.append({
|
# (all validations and hash calculations are already done)
|
||||||
"hotel_id": guest_data["hotel_id"],
|
values_list.append(validated_guest.model_dump())
|
||||||
"guest_id": guest_data["guest_id"],
|
|
||||||
"guest_first_name": guest_data["guest_first_name"],
|
|
||||||
"guest_last_name": guest_data["guest_last_name"],
|
|
||||||
"guest_email": guest_data["guest_email"],
|
|
||||||
"guest_country_code": guest_data["guest_country_code"],
|
|
||||||
"guest_birth_date": guest_data["guest_birth_date"],
|
|
||||||
"hashed_first_name": ConversionGuest._normalize_and_hash(guest_data["guest_first_name"]),
|
|
||||||
"hashed_last_name": ConversionGuest._normalize_and_hash(guest_data["guest_last_name"]),
|
|
||||||
"hashed_email": ConversionGuest._normalize_and_hash(guest_data["guest_email"]),
|
|
||||||
"hashed_country_code": ConversionGuest._normalize_and_hash(guest_data["guest_country_code"]),
|
|
||||||
"hashed_birth_date": ConversionGuest._normalize_and_hash(
|
|
||||||
guest_data["guest_birth_date"].isoformat() if guest_data["guest_birth_date"] else None
|
|
||||||
),
|
|
||||||
"first_seen": now,
|
|
||||||
"last_seen": now,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Use PostgreSQL ON CONFLICT DO UPDATE for atomic upsert
|
# Use PostgreSQL ON CONFLICT DO UPDATE for atomic upsert
|
||||||
stmt = pg_insert(ConversionGuest).values(values_list)
|
stmt = pg_insert(ConversionGuest).values(values_list)
|
||||||
@@ -252,9 +264,13 @@ class ConversionService:
|
|||||||
stats["deleted_reservations"] += 1
|
stats["deleted_reservations"] += 1
|
||||||
pms_reservation_id_str = deleted_res.get("ID")
|
pms_reservation_id_str = deleted_res.get("ID")
|
||||||
try:
|
try:
|
||||||
pms_reservation_id = int(pms_reservation_id_str) if pms_reservation_id_str else None
|
pms_reservation_id = (
|
||||||
|
int(pms_reservation_id_str) if pms_reservation_id_str else None
|
||||||
|
)
|
||||||
if pms_reservation_id is None:
|
if pms_reservation_id is None:
|
||||||
_LOGGER.warning("Deleted reservation missing ID attribute, skipping")
|
_LOGGER.warning(
|
||||||
|
"Deleted reservation missing ID attribute, skipping"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
await self._handle_deleted_reservation(pms_reservation_id, session)
|
await self._handle_deleted_reservation(pms_reservation_id, session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -295,7 +311,10 @@ class ConversionService:
|
|||||||
|
|
||||||
# Phase 1: Bulk upsert all unique guests to database
|
# Phase 1: Bulk upsert all unique guests to database
|
||||||
if guest_data_by_key:
|
if guest_data_by_key:
|
||||||
_LOGGER.debug("Phase 1: Bulk upserting %d unique guests to database", len(guest_data_by_key))
|
_LOGGER.debug(
|
||||||
|
"Phase 1: Bulk upserting %d unique guests to database",
|
||||||
|
len(guest_data_by_key),
|
||||||
|
)
|
||||||
if self.session_maker:
|
if self.session_maker:
|
||||||
session = await self.session_maker.create_session()
|
session = await self.session_maker.create_session()
|
||||||
else:
|
else:
|
||||||
@@ -304,7 +323,9 @@ class ConversionService:
|
|||||||
try:
|
try:
|
||||||
await self._bulk_upsert_guests(session, guest_data_by_key)
|
await self._bulk_upsert_guests(session, guest_data_by_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
_LOGGER.info("Phase 1: Successfully upserted %d guests", len(guest_data_by_key))
|
_LOGGER.info(
|
||||||
|
"Phase 1: Successfully upserted %d guests", len(guest_data_by_key)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
_LOGGER.exception("Phase 1: Error during bulk guest upsert: %s", e)
|
_LOGGER.exception("Phase 1: Error during bulk guest upsert: %s", e)
|
||||||
@@ -318,9 +339,13 @@ class ConversionService:
|
|||||||
# Returns list of successfully created pms_reservation_ids
|
# Returns list of successfully created pms_reservation_ids
|
||||||
_LOGGER.debug("Phase 2: Creating/updating conversions")
|
_LOGGER.debug("Phase 2: Creating/updating conversions")
|
||||||
if self.supports_concurrent:
|
if self.supports_concurrent:
|
||||||
pms_reservation_ids = await self._process_reservations_concurrent(reservations, stats)
|
pms_reservation_ids = await self._process_reservations_concurrent(
|
||||||
|
reservations, stats
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
pms_reservation_ids = await self._process_reservations_sequential(reservations, stats)
|
pms_reservation_ids = await self._process_reservations_sequential(
|
||||||
|
reservations, stats
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Phase 3: Found %d successfully created conversions out of %d total reservations",
|
"Phase 3: Found %d successfully created conversions out of %d total reservations",
|
||||||
@@ -335,9 +360,13 @@ class ConversionService:
|
|||||||
if pms_reservation_ids:
|
if pms_reservation_ids:
|
||||||
_LOGGER.debug("Phase 3: Matching conversions to reservations/customers")
|
_LOGGER.debug("Phase 3: Matching conversions to reservations/customers")
|
||||||
if self.supports_concurrent:
|
if self.supports_concurrent:
|
||||||
await self._match_conversions_from_db_concurrent(pms_reservation_ids, stats)
|
await self._match_conversions_from_db_concurrent(
|
||||||
|
pms_reservation_ids, stats
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await self._match_conversions_from_db_sequential(pms_reservation_ids, stats)
|
await self._match_conversions_from_db_sequential(
|
||||||
|
pms_reservation_ids, stats
|
||||||
|
)
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
@@ -375,9 +404,12 @@ class ConversionService:
|
|||||||
try:
|
try:
|
||||||
# Load all reservations with their hashed customers in one query
|
# Load all reservations with their hashed customers in one query
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
query = select(Reservation).options(
|
query = select(Reservation).options(
|
||||||
selectinload(Reservation.customer).selectinload(Customer.hashed_version),
|
selectinload(Reservation.customer).selectinload(
|
||||||
selectinload(Reservation.hashed_customer)
|
Customer.hashed_version
|
||||||
|
),
|
||||||
|
selectinload(Reservation.hashed_customer),
|
||||||
)
|
)
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
reservations = result.scalars().all()
|
reservations = result.scalars().all()
|
||||||
@@ -420,6 +452,7 @@ class ConversionService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of pms_reservation_ids that were successfully created/updated
|
List of pms_reservation_ids that were successfully created/updated
|
||||||
|
|
||||||
"""
|
"""
|
||||||
semaphore = asyncio.Semaphore(1) # Process one at a time
|
semaphore = asyncio.Semaphore(1) # Process one at a time
|
||||||
results = []
|
results = []
|
||||||
@@ -460,6 +493,7 @@ class ConversionService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of pms_reservation_ids that were successfully created/updated
|
List of pms_reservation_ids that were successfully created/updated
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not self.session_maker:
|
if not self.session_maker:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
@@ -518,7 +552,7 @@ class ConversionService:
|
|||||||
pms_reservation_id if successfully created/updated, None if error occurred
|
pms_reservation_id if successfully created/updated, None if error occurred
|
||||||
|
|
||||||
"""
|
"""
|
||||||
pms_reservation_id = reservation_elem.get("id")
|
pms_reservation_id = int(reservation_elem.get("id"))
|
||||||
|
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
# In concurrent mode, create a new session for this task
|
# In concurrent mode, create a new session for this task
|
||||||
@@ -568,16 +602,22 @@ class ConversionService:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if not self.hotel_id:
|
if not self.hotel_id:
|
||||||
_LOGGER.error("Cannot delete reservation: hotel_id not set in ConversionService")
|
_LOGGER.error(
|
||||||
|
"Cannot delete reservation: hotel_id not set in ConversionService"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
_LOGGER.info("Processing deleted reservation: Hotel %s, PMS ID %s", self.hotel_id, pms_reservation_id)
|
_LOGGER.info(
|
||||||
|
"Processing deleted reservation: Hotel %s, PMS ID %s",
|
||||||
|
self.hotel_id,
|
||||||
|
pms_reservation_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Delete conversion records for this hotel + pms_reservation_id
|
# Delete conversion records for this hotel + pms_reservation_id
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversion).where(
|
select(Conversion).where(
|
||||||
Conversion.hotel_id == self.hotel_id,
|
Conversion.hotel_id == self.hotel_id,
|
||||||
Conversion.pms_reservation_id == pms_reservation_id
|
Conversion.pms_reservation_id == pms_reservation_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conversions = result.scalars().all()
|
conversions = result.scalars().all()
|
||||||
@@ -612,9 +652,15 @@ class ConversionService:
|
|||||||
"daily_sales_count": 0,
|
"daily_sales_count": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract reservation metadata
|
|
||||||
hotel_id = reservation_elem.get("hotelID")
|
hotel_id = reservation_elem.get("hotelID")
|
||||||
pms_reservation_id = reservation_elem.get("id")
|
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
|
||||||
|
|
||||||
reservation_number = reservation_elem.get("number")
|
reservation_number = reservation_elem.get("number")
|
||||||
reservation_date_str = reservation_elem.get("date")
|
reservation_date_str = reservation_elem.get("date")
|
||||||
creation_time_str = reservation_elem.get("creationTime")
|
creation_time_str = reservation_elem.get("creationTime")
|
||||||
@@ -684,7 +730,7 @@ class ConversionService:
|
|||||||
existing_result = await session.execute(
|
existing_result = await session.execute(
|
||||||
select(Conversion).where(
|
select(Conversion).where(
|
||||||
Conversion.hotel_id == hotel_id,
|
Conversion.hotel_id == hotel_id,
|
||||||
Conversion.pms_reservation_id == pms_reservation_id
|
Conversion.pms_reservation_id == pms_reservation_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_conversion = existing_result.scalar_one_or_none()
|
existing_conversion = existing_result.scalar_one_or_none()
|
||||||
@@ -711,11 +757,8 @@ class ConversionService:
|
|||||||
else:
|
else:
|
||||||
# Create new conversion entry (without matching - will be done later)
|
# Create new conversion entry (without matching - will be done later)
|
||||||
# Note: Guest information (first_name, last_name, email, etc) is stored in ConversionGuest table
|
# Note: Guest information (first_name, last_name, email, etc) is stored in ConversionGuest table
|
||||||
conversion = Conversion(
|
conversion_data = ConversionData(
|
||||||
# Links to existing entities (nullable, will be filled in after matching)
|
# Links to existing entities (nullable, will be filled in after matching)
|
||||||
reservation_id=None,
|
|
||||||
customer_id=None,
|
|
||||||
hashed_customer_id=None,
|
|
||||||
# Reservation metadata
|
# Reservation metadata
|
||||||
hotel_id=hotel_id,
|
hotel_id=hotel_id,
|
||||||
guest_id=guest_id, # Links to ConversionGuest
|
guest_id=guest_id, # Links to ConversionGuest
|
||||||
@@ -730,9 +773,8 @@ class ConversionService:
|
|||||||
advertising_partner=advertising_partner,
|
advertising_partner=advertising_partner,
|
||||||
advertising_campagne=advertising_campagne,
|
advertising_campagne=advertising_campagne,
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at=datetime.now(),
|
|
||||||
updated_at=datetime.now(),
|
|
||||||
)
|
)
|
||||||
|
conversion = Conversion(**conversion_data.model_dump())
|
||||||
session.add(conversion)
|
session.add(conversion)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Created conversion (pms_id=%s)",
|
"Created conversion (pms_id=%s)",
|
||||||
@@ -744,9 +786,7 @@ class ConversionService:
|
|||||||
|
|
||||||
# Fetch ALL existing rooms for this conversion (not just the ones in current XML)
|
# Fetch ALL existing rooms for this conversion (not just the ones in current XML)
|
||||||
existing_rooms_result = await session.execute(
|
existing_rooms_result = await session.execute(
|
||||||
select(ConversionRoom).where(
|
select(ConversionRoom).where(ConversionRoom.conversion_id == conversion.id)
|
||||||
ConversionRoom.conversion_id == conversion.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
existing_rooms = {
|
existing_rooms = {
|
||||||
room.pms_hotel_reservation_id: room
|
room.pms_hotel_reservation_id: room
|
||||||
@@ -841,7 +881,6 @@ class ConversionService:
|
|||||||
# 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(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 = arrival_date
|
||||||
@@ -910,7 +949,6 @@ class ConversionService:
|
|||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
async def _match_by_advertising(
|
async def _match_by_advertising(
|
||||||
self,
|
self,
|
||||||
advertising_campagne: str,
|
advertising_campagne: str,
|
||||||
@@ -1026,9 +1064,7 @@ class ConversionService:
|
|||||||
session = self.session
|
session = self.session
|
||||||
|
|
||||||
# Query all hashed customers that match the guest details
|
# Query all hashed customers that match the guest details
|
||||||
query = select(HashedCustomer).options(
|
query = select(HashedCustomer).options(selectinload(HashedCustomer.customer))
|
||||||
selectinload(HashedCustomer.customer)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build filter conditions
|
# Build filter conditions
|
||||||
conditions = []
|
conditions = []
|
||||||
@@ -1165,6 +1201,7 @@ class ConversionService:
|
|||||||
|
|
||||||
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 either:
|
# Find all conversions that either:
|
||||||
# - Have no match at all (reservation_id IS NULL AND customer_id IS NULL), OR
|
# - Have no match at all (reservation_id IS NULL AND customer_id IS NULL), OR
|
||||||
@@ -1246,6 +1283,7 @@ class ConversionService:
|
|||||||
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
|
||||||
stats: Shared stats dictionary to update
|
stats: Shared stats dictionary to update
|
||||||
|
|
||||||
"""
|
"""
|
||||||
for guest_id, matched_hashed_customer in guest_to_hashed_customer.items():
|
for guest_id, matched_hashed_customer in guest_to_hashed_customer.items():
|
||||||
if not matched_hashed_customer or not matched_hashed_customer.customer_id:
|
if not matched_hashed_customer or not matched_hashed_customer.customer_id:
|
||||||
@@ -1259,7 +1297,10 @@ class ConversionService:
|
|||||||
(Conversion.guest_id == guest_id)
|
(Conversion.guest_id == guest_id)
|
||||||
& (Conversion.reservation_id.is_(None))
|
& (Conversion.reservation_id.is_(None))
|
||||||
)
|
)
|
||||||
.options(selectinload(Conversion.conversion_rooms), selectinload(Conversion.guest))
|
.options(
|
||||||
|
selectinload(Conversion.conversion_rooms),
|
||||||
|
selectinload(Conversion.guest),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
conversions = result.scalars().all()
|
conversions = result.scalars().all()
|
||||||
|
|
||||||
@@ -1275,7 +1316,10 @@ class ConversionService:
|
|||||||
|
|
||||||
# Try to link each conversion to a reservation for this customer
|
# Try to link each conversion to a reservation for this customer
|
||||||
for conversion in conversions:
|
for conversion in conversions:
|
||||||
matched_reservation, is_attributable = await self._check_if_attributable(
|
(
|
||||||
|
matched_reservation,
|
||||||
|
is_attributable,
|
||||||
|
) = await self._check_if_attributable(
|
||||||
matched_hashed_customer.customer_id, conversion, session
|
matched_hashed_customer.customer_id, conversion, session
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1329,6 +1373,7 @@ class ConversionService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: AsyncSession for database queries
|
session: AsyncSession for database queries
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Get all ConversionGuests that have ANY customer link
|
# Get all ConversionGuests that have ANY customer link
|
||||||
# This includes:
|
# This includes:
|
||||||
@@ -1345,7 +1390,9 @@ class ConversionService:
|
|||||||
_LOGGER.debug("Phase 3d: No matched guests to check for regularity")
|
_LOGGER.debug("Phase 3d: No matched guests to check for regularity")
|
||||||
return
|
return
|
||||||
|
|
||||||
_LOGGER.debug("Phase 3d: Checking regularity for %d matched guests", len(matched_guests))
|
_LOGGER.debug(
|
||||||
|
"Phase 3d: Checking regularity for %d matched guests", len(matched_guests)
|
||||||
|
)
|
||||||
|
|
||||||
for conversion_guest in matched_guests:
|
for conversion_guest in matched_guests:
|
||||||
if not conversion_guest.hashed_customer_id:
|
if not conversion_guest.hashed_customer_id:
|
||||||
@@ -1458,7 +1505,7 @@ class ConversionService:
|
|||||||
|
|
||||||
async def _match_conversion_from_db_safe(
|
async def _match_conversion_from_db_safe(
|
||||||
self,
|
self,
|
||||||
pms_reservation_id: str,
|
pms_reservation_id: int,
|
||||||
semaphore: asyncio.Semaphore,
|
semaphore: asyncio.Semaphore,
|
||||||
stats: dict[str, int],
|
stats: dict[str, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1528,12 +1575,15 @@ class ConversionService:
|
|||||||
pms_reservation_id: PMS reservation ID to match
|
pms_reservation_id: PMS reservation ID to match
|
||||||
session: AsyncSession to use
|
session: AsyncSession to use
|
||||||
stats: Shared stats dictionary to update (optional)
|
stats: Shared stats dictionary to update (optional)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if session is None:
|
if session is None:
|
||||||
session = self.session
|
session = self.session
|
||||||
|
|
||||||
if not self.hotel_id:
|
if not self.hotel_id:
|
||||||
_LOGGER.error("Cannot match conversion: hotel_id not set in ConversionService")
|
_LOGGER.error(
|
||||||
|
"Cannot match conversion: hotel_id not set in ConversionService"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the conversion from the database with related data
|
# Get the conversion from the database with related data
|
||||||
@@ -1541,9 +1591,12 @@ class ConversionService:
|
|||||||
select(Conversion)
|
select(Conversion)
|
||||||
.where(
|
.where(
|
||||||
Conversion.hotel_id == self.hotel_id,
|
Conversion.hotel_id == self.hotel_id,
|
||||||
Conversion.pms_reservation_id == pms_reservation_id
|
Conversion.pms_reservation_id == pms_reservation_id,
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(Conversion.guest),
|
||||||
|
selectinload(Conversion.conversion_rooms),
|
||||||
)
|
)
|
||||||
.options(selectinload(Conversion.guest), selectinload(Conversion.conversion_rooms))
|
|
||||||
)
|
)
|
||||||
conversion = result.scalar_one_or_none()
|
conversion = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -1601,9 +1654,7 @@ class ConversionService:
|
|||||||
conversion.reservation_id = (
|
conversion.reservation_id = (
|
||||||
matched_reservation.id if matched_reservation else None
|
matched_reservation.id if matched_reservation else None
|
||||||
)
|
)
|
||||||
conversion.customer_id = (
|
conversion.customer_id = matched_customer.id if matched_customer else None
|
||||||
matched_customer.id if matched_customer else None
|
|
||||||
)
|
|
||||||
conversion.hashed_customer_id = (
|
conversion.hashed_customer_id = (
|
||||||
matched_hashed_customer.id if matched_hashed_customer else None
|
matched_hashed_customer.id if matched_hashed_customer else None
|
||||||
)
|
)
|
||||||
@@ -1647,6 +1698,7 @@ class ConversionService:
|
|||||||
guest_id: The guest ID to evaluate
|
guest_id: The guest ID to evaluate
|
||||||
customer_id: The matched customer ID
|
customer_id: The matched customer ID
|
||||||
session: AsyncSession for database queries
|
session: AsyncSession for database queries
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Get the ConversionGuest record
|
# Get the ConversionGuest record
|
||||||
guest_result = await session.execute(
|
guest_result = await session.execute(
|
||||||
@@ -1670,7 +1722,9 @@ class ConversionService:
|
|||||||
.order_by(Conversion.reservation_date.asc())
|
.order_by(Conversion.reservation_date.asc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
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:
|
||||||
# No paying conversions found for this guest
|
# No paying conversions found for this guest
|
||||||
@@ -1695,7 +1749,10 @@ class ConversionService:
|
|||||||
# (meaning they were already a customer before we sent them a reservation)
|
# (meaning they were already a customer before we sent them a reservation)
|
||||||
# Compare against the reservation's creation date (when WE created/sent it), not check-in date
|
# Compare against the reservation's creation date (when WE created/sent it), not check-in date
|
||||||
# Convert created_at to date for comparison with reservation_date (both are dates)
|
# 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()
|
is_regular = (
|
||||||
|
earliest_paying_conversion.reservation_date
|
||||||
|
< earliest_reservation.created_at.date()
|
||||||
|
)
|
||||||
conversion_guest.is_regular = is_regular
|
conversion_guest.is_regular = is_regular
|
||||||
|
|
||||||
if is_regular:
|
if is_regular:
|
||||||
@@ -1735,6 +1792,7 @@ class ConversionService:
|
|||||||
Tuple of (matched_reservation, is_attributable) where:
|
Tuple of (matched_reservation, is_attributable) where:
|
||||||
- matched_reservation: The Reservation that matches (if any)
|
- matched_reservation: The Reservation that matches (if any)
|
||||||
- is_attributable: True if the reservation's dates match this conversion
|
- is_attributable: True if the reservation's dates match this conversion
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Check if conversion_room dates exist (criterion for attributability)
|
# Check if conversion_room dates exist (criterion for attributability)
|
||||||
if not conversion.conversion_rooms:
|
if not conversion.conversion_rooms:
|
||||||
@@ -1763,12 +1821,12 @@ class ConversionService:
|
|||||||
and reservation.end_date
|
and reservation.end_date
|
||||||
):
|
):
|
||||||
# Check if dates match or mostly match (within 7 day tolerance)
|
# Check if dates match or mostly match (within 7 day tolerance)
|
||||||
arrival_match = abs(
|
arrival_match = (
|
||||||
(room.arrival_date - reservation.start_date).days
|
abs((room.arrival_date - reservation.start_date).days) <= 7
|
||||||
) <= 7
|
)
|
||||||
departure_match = abs(
|
departure_match = (
|
||||||
(room.departure_date - reservation.end_date).days
|
abs((room.departure_date - reservation.end_date).days) <= 7
|
||||||
) <= 7
|
)
|
||||||
|
|
||||||
if arrival_match and departure_match:
|
if arrival_match and departure_match:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import os
|
|||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from .const import WebhookStatus
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
Boolean,
|
Boolean,
|
||||||
@@ -17,6 +15,7 @@ from sqlalchemy import (
|
|||||||
ForeignKeyConstraint,
|
ForeignKeyConstraint,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
|
MetaData,
|
||||||
String,
|
String,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
@@ -30,6 +29,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import backref, declarative_base, relationship
|
from sqlalchemy.orm import backref, declarative_base, relationship
|
||||||
|
|
||||||
|
from .const import WebhookStatus
|
||||||
from .logging_config import get_logger
|
from .logging_config import get_logger
|
||||||
|
|
||||||
_LOGGER = get_logger(__name__)
|
_LOGGER = get_logger(__name__)
|
||||||
@@ -58,7 +58,16 @@ class Base:
|
|||||||
# __table_args__ = {"schema": _SCHEMA}
|
# __table_args__ = {"schema": _SCHEMA}
|
||||||
|
|
||||||
|
|
||||||
Base = declarative_base(cls=Base)
|
# Define naming convention for constraints
|
||||||
|
metadata = MetaData(naming_convention={
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s"
|
||||||
|
})
|
||||||
|
|
||||||
|
Base = declarative_base(cls=Base, metadata=metadata)
|
||||||
|
|
||||||
# Type variable for async functions
|
# Type variable for async functions
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
@@ -353,7 +362,10 @@ class HashedCustomer(Base):
|
|||||||
__tablename__ = "hashed_customers"
|
__tablename__ = "hashed_customers"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
customer_id = Column(
|
customer_id = Column(
|
||||||
Integer, ForeignKey("customers.id", ondelete="SET NULL"), unique=True, nullable=True
|
Integer,
|
||||||
|
ForeignKey("customers.id", ondelete="SET NULL"),
|
||||||
|
unique=True,
|
||||||
|
nullable=True,
|
||||||
)
|
)
|
||||||
contact_id = Column(String, unique=True) # Keep unhashed for reference
|
contact_id = Column(String, unique=True) # Keep unhashed for reference
|
||||||
hashed_email = Column(String(64)) # SHA256 produces 64 hex chars
|
hashed_email = Column(String(64)) # SHA256 produces 64 hex chars
|
||||||
@@ -367,7 +379,9 @@ class HashedCustomer(Base):
|
|||||||
hashed_birth_date = Column(String(64))
|
hashed_birth_date = Column(String(64))
|
||||||
created_at = Column(DateTime(timezone=True))
|
created_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
customer = relationship("Customer", backref=backref("hashed_version", uselist=False, lazy="joined"))
|
customer = relationship(
|
||||||
|
"Customer", backref=backref("hashed_version", uselist=False, lazy="joined")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConversionGuest(Base):
|
class ConversionGuest(Base):
|
||||||
@@ -383,7 +397,13 @@ class ConversionGuest(Base):
|
|||||||
__tablename__ = "conversion_guests"
|
__tablename__ = "conversion_guests"
|
||||||
|
|
||||||
# Natural keys from PMS - composite primary key
|
# Natural keys from PMS - composite primary key
|
||||||
hotel_id = Column(String(50), ForeignKey("hotels.hotel_id", ondelete="CASCADE"), nullable=False, primary_key=True, index=True)
|
hotel_id = Column(
|
||||||
|
String(50),
|
||||||
|
ForeignKey("hotels.hotel_id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
guest_id = Column(Integer, nullable=False, primary_key=True, index=True)
|
guest_id = Column(Integer, nullable=False, primary_key=True, index=True)
|
||||||
|
|
||||||
# Unhashed guest information (for reference/transition period)
|
# Unhashed guest information (for reference/transition period)
|
||||||
@@ -401,10 +421,14 @@ class ConversionGuest(Base):
|
|||||||
hashed_birth_date = Column(String(64))
|
hashed_birth_date = Column(String(64))
|
||||||
|
|
||||||
# Matched customer reference (nullable, filled after matching)
|
# Matched customer reference (nullable, filled after matching)
|
||||||
hashed_customer_id = Column(Integer, ForeignKey("hashed_customers.id"), nullable=True, index=True)
|
hashed_customer_id = Column(
|
||||||
|
Integer, ForeignKey("hashed_customers.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Guest classification
|
# Guest classification
|
||||||
is_regular = Column(Boolean, default=False) # True if guest has many prior stays before appearing in our reservations
|
is_regular = Column(
|
||||||
|
Boolean, default=False
|
||||||
|
) # True if guest has many prior stays before appearing in our reservations
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
first_seen = Column(DateTime(timezone=True))
|
first_seen = Column(DateTime(timezone=True))
|
||||||
@@ -428,7 +452,7 @@ class ConversionGuest(Base):
|
|||||||
def create_from_conversion_data(
|
def create_from_conversion_data(
|
||||||
cls,
|
cls,
|
||||||
hotel_id: str,
|
hotel_id: str,
|
||||||
guest_id: str | None,
|
guest_id: int | None,
|
||||||
guest_first_name: str | None,
|
guest_first_name: str | None,
|
||||||
guest_last_name: str | None,
|
guest_last_name: str | None,
|
||||||
guest_email: str | None,
|
guest_email: str | None,
|
||||||
@@ -483,7 +507,9 @@ class ConversionGuest(Base):
|
|||||||
self.hashed_country_code = self._normalize_and_hash(guest_country_code)
|
self.hashed_country_code = self._normalize_and_hash(guest_country_code)
|
||||||
if guest_birth_date:
|
if guest_birth_date:
|
||||||
self.guest_birth_date = guest_birth_date
|
self.guest_birth_date = guest_birth_date
|
||||||
self.hashed_birth_date = self._normalize_and_hash(guest_birth_date.isoformat())
|
self.hashed_birth_date = self._normalize_and_hash(
|
||||||
|
guest_birth_date.isoformat()
|
||||||
|
)
|
||||||
self.last_seen = now
|
self.last_seen = now
|
||||||
|
|
||||||
|
|
||||||
@@ -491,7 +517,9 @@ class Reservation(Base):
|
|||||||
__tablename__ = "reservations"
|
__tablename__ = "reservations"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||||
hashed_customer_id = Column(Integer, ForeignKey("hashed_customers.id", ondelete="CASCADE"))
|
hashed_customer_id = Column(
|
||||||
|
Integer, ForeignKey("hashed_customers.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
unique_id = Column(String, unique=True)
|
unique_id = Column(String, unique=True)
|
||||||
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
|
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
|
||||||
start_date = Column(Date)
|
start_date = Column(Date)
|
||||||
@@ -578,9 +606,18 @@ class Conversion(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Reservation metadata from XML
|
# Reservation metadata from XML
|
||||||
hotel_id = Column(String(50), ForeignKey("hotels.hotel_id", ondelete="CASCADE"), nullable=False, index=True) # hotelID attribute
|
hotel_id = Column(
|
||||||
pms_reservation_id = Column(Integer, nullable=False, index=True) # id attribute from reservation
|
String(50),
|
||||||
guest_id = Column(Integer, nullable=True, index=True) # PMS guest ID, FK to conversion_guests
|
ForeignKey("hotels.hotel_id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
) # hotelID attribute
|
||||||
|
pms_reservation_id = Column(
|
||||||
|
Integer, nullable=False, index=True
|
||||||
|
) # id attribute from reservation
|
||||||
|
guest_id = Column(
|
||||||
|
Integer, nullable=True, index=True
|
||||||
|
) # PMS guest ID, FK to conversion_guests
|
||||||
|
|
||||||
reservation_number = Column(String) # number attribute
|
reservation_number = Column(String) # number attribute
|
||||||
reservation_date = Column(Date) # date attribute (when reservation was made)
|
reservation_date = Column(Date) # date attribute (when reservation was made)
|
||||||
@@ -588,9 +625,6 @@ class Conversion(Base):
|
|||||||
reservation_type = Column(String) # type attribute (e.g., "reservation")
|
reservation_type = Column(String) # type attribute (e.g., "reservation")
|
||||||
booking_channel = Column(String) # bookingChannel attribute
|
booking_channel = Column(String) # bookingChannel attribute
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Advertising/tracking data - used for matching to existing reservations
|
# Advertising/tracking data - used for matching to existing reservations
|
||||||
advertising_medium = Column(
|
advertising_medium = Column(
|
||||||
String, index=True
|
String, index=True
|
||||||
@@ -603,7 +637,9 @@ class Conversion(Base):
|
|||||||
) # advertisingCampagne (contains fbclid/gclid)
|
) # advertisingCampagne (contains fbclid/gclid)
|
||||||
|
|
||||||
# Attribution flags - track how this conversion was matched
|
# Attribution flags - track how this conversion was matched
|
||||||
directly_attributable = Column(Boolean, default=False) # Matched by ID (high confidence)
|
directly_attributable = Column(
|
||||||
|
Boolean, default=False
|
||||||
|
) # Matched by ID (high confidence)
|
||||||
guest_matched = Column(Boolean, default=False) # Matched by guest details only
|
guest_matched = Column(Boolean, default=False) # Matched by guest details only
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
@@ -617,7 +653,9 @@ class Conversion(Base):
|
|||||||
["conversion_guests.hotel_id", "conversion_guests.guest_id"],
|
["conversion_guests.hotel_id", "conversion_guests.guest_id"],
|
||||||
ondelete="SET NULL",
|
ondelete="SET NULL",
|
||||||
),
|
),
|
||||||
UniqueConstraint("hotel_id", "pms_reservation_id", name="uq_conversion_hotel_reservation"),
|
UniqueConstraint(
|
||||||
|
"hotel_id", "pms_reservation_id", name="uq_conversion_hotel_reservation"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -690,7 +728,10 @@ class HotelInventory(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
hotel_id = Column(
|
hotel_id = Column(
|
||||||
String(50), ForeignKey("hotels.hotel_id", ondelete="CASCADE"), nullable=False, index=True
|
String(50),
|
||||||
|
ForeignKey("hotels.hotel_id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
)
|
)
|
||||||
inv_type_code = Column(String(8), nullable=False, index=True)
|
inv_type_code = Column(String(8), nullable=False, index=True)
|
||||||
inv_code = Column(String(16), nullable=True, index=True)
|
inv_code = Column(String(16), nullable=True, index=True)
|
||||||
@@ -726,7 +767,10 @@ class RoomAvailability(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
inventory_id = Column(
|
inventory_id = Column(
|
||||||
Integer, ForeignKey("hotel_inventory.id", ondelete="CASCADE"), nullable=False, index=True
|
Integer,
|
||||||
|
ForeignKey("hotel_inventory.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
)
|
)
|
||||||
date = Column(Date, nullable=False, index=True)
|
date = Column(Date, nullable=False, index=True)
|
||||||
count_type_2 = Column(Integer, nullable=True)
|
count_type_2 = Column(Integer, nullable=True)
|
||||||
@@ -739,7 +783,9 @@ class RoomAvailability(Base):
|
|||||||
inventory_item = relationship("HotelInventory", back_populates="availability")
|
inventory_item = relationship("HotelInventory", back_populates="availability")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("inventory_id", "date", name="uq_room_availability_unique_key"),
|
UniqueConstraint(
|
||||||
|
"inventory_id", "date", name="uq_room_availability_unique_key"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -787,7 +833,9 @@ class WebhookEndpoint(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
# Hotel association
|
# Hotel association
|
||||||
hotel_id = Column(String(50), ForeignKey("hotels.hotel_id"), nullable=False, index=True)
|
hotel_id = Column(
|
||||||
|
String(50), ForeignKey("hotels.hotel_id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Webhook configuration
|
# Webhook configuration
|
||||||
webhook_secret = Column(String(64), unique=True, nullable=False, index=True)
|
webhook_secret = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
@@ -803,7 +851,7 @@ class WebhookEndpoint(Base):
|
|||||||
webhook_requests = relationship("WebhookRequest", back_populates="webhook_endpoint")
|
webhook_requests = relationship("WebhookRequest", back_populates="webhook_endpoint")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_webhook_endpoint_hotel_type', 'hotel_id', 'webhook_type'),
|
Index("idx_webhook_endpoint_hotel_type", "hotel_id", "webhook_type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -816,11 +864,17 @@ class WebhookRequest(Base):
|
|||||||
|
|
||||||
# Request identification
|
# Request identification
|
||||||
payload_hash = Column(String(64), unique=True, nullable=False, index=True) # SHA256
|
payload_hash = Column(String(64), unique=True, nullable=False, index=True) # SHA256
|
||||||
webhook_endpoint_id = Column(Integer, ForeignKey("webhook_endpoints.id"), nullable=True, index=True)
|
webhook_endpoint_id = Column(
|
||||||
hotel_id = Column(String(50), ForeignKey("hotels.hotel_id"), nullable=True, index=True)
|
Integer, ForeignKey("webhook_endpoints.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
hotel_id = Column(
|
||||||
|
String(50), ForeignKey("hotels.hotel_id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Processing tracking
|
# Processing tracking
|
||||||
status = Column(String(20), nullable=False, default=WebhookStatus.PENDING.value, index=True)
|
status = Column(
|
||||||
|
String(20), nullable=False, default=WebhookStatus.PENDING.value, index=True
|
||||||
|
)
|
||||||
# Status values: 'pending', 'processing', 'completed', 'failed' set by Enum WebhookStatus
|
# Status values: 'pending', 'processing', 'completed', 'failed' set by Enum WebhookStatus
|
||||||
|
|
||||||
processing_started_at = Column(DateTime(timezone=True), nullable=True)
|
processing_started_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -841,16 +895,20 @@ class WebhookRequest(Base):
|
|||||||
|
|
||||||
# Result tracking
|
# Result tracking
|
||||||
created_customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
created_customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
||||||
created_reservation_id = Column(Integer, ForeignKey("reservations.id"), nullable=True)
|
created_reservation_id = Column(
|
||||||
|
Integer, ForeignKey("reservations.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
webhook_endpoint = relationship("WebhookEndpoint", back_populates="webhook_requests")
|
webhook_endpoint = relationship(
|
||||||
|
"WebhookEndpoint", back_populates="webhook_requests"
|
||||||
|
)
|
||||||
hotel = relationship("Hotel")
|
hotel = relationship("Hotel")
|
||||||
customer = relationship("Customer")
|
customer = relationship("Customer")
|
||||||
reservation = relationship("Reservation")
|
reservation = relationship("Reservation")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_webhook_status_created', 'status', 'created_at'),
|
Index("idx_webhook_status_created", "status", "created_at"),
|
||||||
Index('idx_webhook_hotel_created', 'hotel_id', 'created_at'),
|
Index("idx_webhook_hotel_created", "hotel_id", "created_at"),
|
||||||
Index('idx_webhook_purge_candidate', 'status', 'purged_at', 'created_at'),
|
Index("idx_webhook_purge_candidate", "status", "purged_at", "created_at"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from XML generation (xsdata) follows clean architecture principles.
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -20,6 +20,35 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validato
|
|||||||
from .const import WebhookStatus
|
from .const import WebhookStatus
|
||||||
|
|
||||||
|
|
||||||
|
# Generalized integer validator for reuse across models
|
||||||
|
def convert_to_int(field_name: str, v: Any) -> int:
|
||||||
|
"""Convert a value to integer, handling string inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name: Name of the field being validated (for error messages)
|
||||||
|
v: Value to convert (can be int, str, or None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Integer value
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If value is None or cannot be converted to int
|
||||||
|
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
msg = f"{field_name} cannot be None"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if isinstance(v, int):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except ValueError as e:
|
||||||
|
msg = f"{field_name} must be a valid integer, got: {v}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
msg = f"{field_name} must be int or str, got: {type(v)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
# Country name to ISO 3166-1 alpha-2 code mapping
|
# Country name to ISO 3166-1 alpha-2 code mapping
|
||||||
COUNTRY_NAME_TO_CODE = {
|
COUNTRY_NAME_TO_CODE = {
|
||||||
# English names
|
# English names
|
||||||
@@ -195,6 +224,7 @@ class CustomerData(BaseModel):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
2-letter ISO country code (uppercase) or None if input is None/empty
|
2-letter ISO country code (uppercase) or None if input is None/empty
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not v:
|
if not v:
|
||||||
return None
|
return None
|
||||||
@@ -367,8 +397,7 @@ class WebhookRequestData(BaseModel):
|
|||||||
|
|
||||||
# Required fields
|
# Required fields
|
||||||
payload_json: dict[str, Any] | None = Field(
|
payload_json: dict[str, Any] | None = Field(
|
||||||
...,
|
..., description="Webhook payload (required for creation, nullable after purge)"
|
||||||
description="Webhook payload (required for creation, nullable after purge)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-calculated from payload_json
|
# Auto-calculated from payload_json
|
||||||
@@ -376,7 +405,7 @@ class WebhookRequestData(BaseModel):
|
|||||||
None,
|
None,
|
||||||
min_length=64,
|
min_length=64,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="SHA256 hash of canonical JSON payload (auto-calculated)"
|
description="SHA256 hash of canonical JSON payload (auto-calculated)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional foreign keys
|
# Optional foreign keys
|
||||||
@@ -455,35 +484,133 @@ class WebhookRequestData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Example usage in a service layer
|
# Example usage in a service layer
|
||||||
class ReservationService:
|
class ConversionGuestData(BaseModel):
|
||||||
"""Example service showing how to use Pydantic models with SQLAlchemy."""
|
"""Validated conversion guest data from PMS XML.
|
||||||
|
|
||||||
def __init__(self, db_session):
|
Handles validation and hashing for guest records extracted from
|
||||||
self.db_session = db_session
|
hotel PMS conversion XML files.
|
||||||
|
"""
|
||||||
|
|
||||||
async def create_reservation(
|
hotel_id: str = Field(..., min_length=1, max_length=50)
|
||||||
self, reservation_data: ReservationData, customer_data: CustomerData
|
guest_id: int = Field(..., gt=0)
|
||||||
):
|
guest_first_name: str | None = Field(None, max_length=100)
|
||||||
"""Create a reservation with validated data.
|
guest_last_name: str | None = Field(None, max_length=100)
|
||||||
|
guest_email: str | None = Field(None, max_length=200)
|
||||||
|
guest_country_code: str | None = Field(None, max_length=10)
|
||||||
|
guest_birth_date: date | None = None
|
||||||
|
|
||||||
The data has already been validated by Pydantic before reaching here.
|
# Auto-calculated hashed fields
|
||||||
|
hashed_first_name: str | None = Field(None, max_length=64)
|
||||||
|
hashed_last_name: str | None = Field(None, max_length=64)
|
||||||
|
hashed_email: str | None = Field(None, max_length=64)
|
||||||
|
hashed_country_code: str | None = Field(None, max_length=64)
|
||||||
|
hashed_birth_date: str | None = Field(None, max_length=64)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
first_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_and_hash(value: str | None) -> str | None:
|
||||||
|
"""Normalize and hash a value for privacy-preserving matching.
|
||||||
|
|
||||||
|
Uses the same logic as ConversionGuest._normalize_and_hash.
|
||||||
"""
|
"""
|
||||||
from alpine_bits_python.db import Customer, Reservation
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
# Normalize: lowercase, strip whitespace
|
||||||
|
normalized = value.lower().strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
# Hash with SHA256
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# Convert validated Pydantic model to SQLAlchemy model
|
@model_validator(mode="after")
|
||||||
db_customer = Customer(**customer_data.model_dump(exclude_none=True))
|
def calculate_hashes(self) -> "ConversionGuestData":
|
||||||
self.db_session.add(db_customer)
|
"""Auto-calculate hashed fields from plain text fields."""
|
||||||
await self.db_session.flush() # Get the customer ID
|
if self.hashed_first_name is None:
|
||||||
|
self.hashed_first_name = self._normalize_and_hash(self.guest_first_name)
|
||||||
|
if self.hashed_last_name is None:
|
||||||
|
self.hashed_last_name = self._normalize_and_hash(self.guest_last_name)
|
||||||
|
if self.hashed_email is None:
|
||||||
|
self.hashed_email = self._normalize_and_hash(self.guest_email)
|
||||||
|
if self.hashed_country_code is None:
|
||||||
|
self.hashed_country_code = self._normalize_and_hash(self.guest_country_code)
|
||||||
|
if self.hashed_birth_date is None and self.guest_birth_date is not None:
|
||||||
|
self.hashed_birth_date = self._normalize_and_hash(
|
||||||
|
self.guest_birth_date.isoformat()
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
# Create reservation linked to customer
|
@field_validator("guest_id", mode="before")
|
||||||
db_reservation = Reservation(
|
@classmethod
|
||||||
customer_id=db_customer.id,
|
def convert_guest_id_to_int(cls, v: Any) -> int:
|
||||||
**reservation_data.model_dump(
|
"""Convert guest_id to integer (handles string input from XML)."""
|
||||||
exclude={"children_ages"}
|
return convert_to_int("guest_id", v)
|
||||||
), # Handle separately
|
|
||||||
children_ages=",".join(map(str, reservation_data.children_ages)),
|
|
||||||
)
|
|
||||||
self.db_session.add(db_reservation)
|
|
||||||
await self.db_session.commit()
|
|
||||||
|
|
||||||
return db_reservation, db_customer
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionData(BaseModel):
|
||||||
|
"""Validated conversion data from PMS XML.
|
||||||
|
|
||||||
|
Handles validation for conversion records extracted from
|
||||||
|
hotel PMS conversion XML files. This model ensures proper type conversion
|
||||||
|
and validation before creating a Conversion database entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Foreign key references (nullable - matched after creation)
|
||||||
|
reservation_id: int | None = Field(None, gt=0)
|
||||||
|
customer_id: int | None = Field(None, gt=0)
|
||||||
|
hashed_customer_id: int | None = Field(None, gt=0)
|
||||||
|
|
||||||
|
# Required reservation metadata from PMS
|
||||||
|
hotel_id: str = Field(..., min_length=1, max_length=50)
|
||||||
|
pms_reservation_id: int = Field(..., gt=0)
|
||||||
|
guest_id: int | None = Field(None, gt=0)
|
||||||
|
|
||||||
|
# Optional reservation metadata
|
||||||
|
reservation_number: str | None = Field(None, max_length=100)
|
||||||
|
reservation_date: date | None = None
|
||||||
|
creation_time: datetime | None = None
|
||||||
|
reservation_type: str | None = Field(None, max_length=50)
|
||||||
|
booking_channel: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
|
# Advertising/tracking data (used for matching)
|
||||||
|
advertising_medium: str | None = Field(None, max_length=200)
|
||||||
|
advertising_partner: str | None = Field(None, max_length=200)
|
||||||
|
advertising_campagne: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
# Attribution flags
|
||||||
|
directly_attributable: bool = Field(default=False)
|
||||||
|
guest_matched: bool = Field(default=False)
|
||||||
|
|
||||||
|
# Timestamps (auto-managed)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"pms_reservation_id", "guest_id", "reservation_id", "customer_id",
|
||||||
|
"hashed_customer_id", mode="before"
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def convert_int_fields(cls, v: Any) -> int | None:
|
||||||
|
"""Convert integer fields from string to int (handles XML input)."""
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
# Get the field name from the validation context if available
|
||||||
|
# For now, use a generic name since we handle multiple fields
|
||||||
|
return convert_to_int("field", v)
|
||||||
|
|
||||||
|
@field_validator("hotel_id", "reservation_number", "reservation_type",
|
||||||
|
"booking_channel", "advertising_medium", "advertising_partner",
|
||||||
|
"advertising_campagne", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_string_fields(cls, v: str | None) -> str | None:
|
||||||
|
"""Strip whitespace from string fields."""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
stripped = str(v).strip()
|
||||||
|
return stripped if stripped else None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -9,6 +9,43 @@ from typing import Optional
|
|||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_convert_id(field_name: str, value: str | int) -> str:
|
||||||
|
"""Validate that an ID field is convertible to integer and return as string.
|
||||||
|
|
||||||
|
This helper ensures ID fields (like reservation_id, guest_id) are valid integers,
|
||||||
|
which is important since the Pydantic models will convert them from strings to ints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name: Name of the field for error messages
|
||||||
|
value: The ID value (can be string or int)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String representation of the validated integer ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If value cannot be converted to a valid positive integer
|
||||||
|
|
||||||
|
"""
|
||||||
|
def _raise_invalid_type_error():
|
||||||
|
"""Raise error for invalid ID type."""
|
||||||
|
msg = (
|
||||||
|
f"{field_name} must be convertible to a positive integer, "
|
||||||
|
f"got: {value!r} (type: {type(value).__name__})"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert to int first to validate it's a valid integer
|
||||||
|
int_value = int(value)
|
||||||
|
if int_value <= 0:
|
||||||
|
msg = f"{field_name} must be a positive integer, got: {value}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
# Return as string for XML attributes
|
||||||
|
return str(int_value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
_raise_invalid_type_error()
|
||||||
|
|
||||||
|
|
||||||
class RoomReservationBuilder:
|
class RoomReservationBuilder:
|
||||||
"""Builder for creating roomReservation XML elements with daily sales."""
|
"""Builder for creating roomReservation XML elements with daily sales."""
|
||||||
|
|
||||||
@@ -133,7 +170,7 @@ class ReservationXMLBuilder:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hotel_id: str,
|
hotel_id: str,
|
||||||
reservation_id: str,
|
reservation_id: str | int,
|
||||||
reservation_number: str,
|
reservation_number: str,
|
||||||
reservation_date: str,
|
reservation_date: str,
|
||||||
creation_time: Optional[str] = None,
|
creation_time: Optional[str] = None,
|
||||||
@@ -146,7 +183,7 @@ class ReservationXMLBuilder:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
hotel_id: Hotel ID
|
hotel_id: Hotel ID
|
||||||
reservation_id: Reservation ID
|
reservation_id: Reservation ID (must be convertible to positive integer)
|
||||||
reservation_number: Reservation number
|
reservation_number: Reservation number
|
||||||
reservation_date: Reservation date in YYYY-MM-DD format
|
reservation_date: Reservation date in YYYY-MM-DD format
|
||||||
creation_time: Creation timestamp (defaults to reservation_date + T00:00:00)
|
creation_time: Creation timestamp (defaults to reservation_date + T00:00:00)
|
||||||
@@ -156,7 +193,7 @@ class ReservationXMLBuilder:
|
|||||||
advertising_campagne: Advertising campaign
|
advertising_campagne: Advertising campaign
|
||||||
"""
|
"""
|
||||||
self.hotel_id = hotel_id
|
self.hotel_id = hotel_id
|
||||||
self.reservation_id = reservation_id
|
self.reservation_id = validate_and_convert_id("reservation_id", reservation_id)
|
||||||
self.reservation_number = reservation_number
|
self.reservation_number = reservation_number
|
||||||
self.reservation_date = reservation_date
|
self.reservation_date = reservation_date
|
||||||
self.creation_time = creation_time or f"{reservation_date}T00:00:00"
|
self.creation_time = creation_time or f"{reservation_date}T00:00:00"
|
||||||
@@ -170,7 +207,7 @@ class ReservationXMLBuilder:
|
|||||||
|
|
||||||
def set_guest(
|
def set_guest(
|
||||||
self,
|
self,
|
||||||
guest_id: str,
|
guest_id: str | int,
|
||||||
first_name: str,
|
first_name: str,
|
||||||
last_name: str,
|
last_name: str,
|
||||||
email: str,
|
email: str,
|
||||||
@@ -182,7 +219,7 @@ class ReservationXMLBuilder:
|
|||||||
"""Set guest information for the reservation.
|
"""Set guest information for the reservation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
guest_id: Guest ID
|
guest_id: Guest ID (must be convertible to positive integer)
|
||||||
first_name: Guest first name
|
first_name: Guest first name
|
||||||
last_name: Guest last name
|
last_name: Guest last name
|
||||||
email: Guest email
|
email: Guest email
|
||||||
@@ -194,8 +231,9 @@ class ReservationXMLBuilder:
|
|||||||
Returns:
|
Returns:
|
||||||
Self for method chaining
|
Self for method chaining
|
||||||
"""
|
"""
|
||||||
|
validated_guest_id = validate_and_convert_id("guest_id", guest_id)
|
||||||
self.guest_data = {
|
self.guest_data = {
|
||||||
"id": guest_id,
|
"id": validated_guest_id,
|
||||||
"firstName": first_name,
|
"firstName": first_name,
|
||||||
"lastName": last_name,
|
"lastName": last_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from alpine_bits_python.db import (
|
|||||||
ConversionGuest,
|
ConversionGuest,
|
||||||
ConversionRoom,
|
ConversionRoom,
|
||||||
Customer,
|
Customer,
|
||||||
HashedCustomer,
|
|
||||||
Reservation,
|
Reservation,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,9 +129,9 @@ class TestConversionServiceWithImportedData:
|
|||||||
|
|
||||||
print(f"\nCSV Import Stats: {csv_stats}")
|
print(f"\nCSV Import Stats: {csv_stats}")
|
||||||
assert csv_stats["total_rows"] > 0, "CSV import should have processed rows"
|
assert csv_stats["total_rows"] > 0, "CSV import should have processed rows"
|
||||||
assert (
|
assert csv_stats["created_reservations"] > 0, (
|
||||||
csv_stats["created_reservations"] > 0
|
"CSV import should create reservations"
|
||||||
), "CSV import should create reservations"
|
)
|
||||||
|
|
||||||
# Step 2: Load and process conversion XML
|
# Step 2: Load and process conversion XML
|
||||||
with xml_file.open(encoding="utf-8") as f:
|
with xml_file.open(encoding="utf-8") as f:
|
||||||
@@ -166,30 +165,36 @@ class TestConversionServiceWithImportedData:
|
|||||||
|
|
||||||
EXPECTED_MATCHED_TO_CUSTOMER = 0
|
EXPECTED_MATCHED_TO_CUSTOMER = 0
|
||||||
|
|
||||||
print(f"\nBaseline Match Counts:")
|
print("\nBaseline Match Counts:")
|
||||||
print(f" Total reservations in XML: {EXPECTED_TOTAL_RESERVATIONS}")
|
print(f" Total reservations in XML: {EXPECTED_TOTAL_RESERVATIONS}")
|
||||||
print(f" Total daily sales records: {EXPECTED_TOTAL_DAILY_SALES}")
|
print(f" Total daily sales records: {EXPECTED_TOTAL_DAILY_SALES}")
|
||||||
print(f" Total conversion room records: {EXPECTED_TOTAL_ROOMS}")
|
print(f" Total conversion room records: {EXPECTED_TOTAL_ROOMS}")
|
||||||
print(f" Matched to reservation: {EXPECTED_MATCHED_TO_RESERVATION}")
|
print(f" Matched to reservation: {EXPECTED_MATCHED_TO_RESERVATION}")
|
||||||
match_rate = (EXPECTED_MATCHED_TO_RESERVATION / EXPECTED_TOTAL_RESERVATIONS * 100) if EXPECTED_TOTAL_RESERVATIONS > 0 else 0
|
match_rate = (
|
||||||
|
(EXPECTED_MATCHED_TO_RESERVATION / EXPECTED_TOTAL_RESERVATIONS * 100)
|
||||||
|
if EXPECTED_TOTAL_RESERVATIONS > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
print(f" Match rate: {match_rate:.1f}%")
|
print(f" Match rate: {match_rate:.1f}%")
|
||||||
print(f" Matched to customer: {EXPECTED_MATCHED_TO_CUSTOMER}")
|
print(f" Matched to customer: {EXPECTED_MATCHED_TO_CUSTOMER}")
|
||||||
print(f" Match rate (to customer): {(EXPECTED_MATCHED_TO_CUSTOMER / EXPECTED_TOTAL_RESERVATIONS * 100) if EXPECTED_TOTAL_RESERVATIONS > 0 else 0:.1f}%")
|
print(
|
||||||
|
f" Match rate (to customer): {(EXPECTED_MATCHED_TO_CUSTOMER / EXPECTED_TOTAL_RESERVATIONS * 100) if EXPECTED_TOTAL_RESERVATIONS > 0 else 0:.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
# Verify baseline stability on subsequent runs
|
# Verify baseline stability on subsequent runs
|
||||||
assert (
|
assert stats["total_reservations"] == EXPECTED_TOTAL_RESERVATIONS, (
|
||||||
stats["total_reservations"] == EXPECTED_TOTAL_RESERVATIONS
|
f"Total reservations should be {EXPECTED_TOTAL_RESERVATIONS}, got {stats['total_reservations']}"
|
||||||
), f"Total reservations should be {EXPECTED_TOTAL_RESERVATIONS}, got {stats['total_reservations']}"
|
)
|
||||||
assert (
|
assert stats["total_daily_sales"] == EXPECTED_TOTAL_DAILY_SALES, (
|
||||||
stats["total_daily_sales"] == EXPECTED_TOTAL_DAILY_SALES
|
f"Total daily sales should be {EXPECTED_TOTAL_DAILY_SALES}, got {stats['total_daily_sales']}"
|
||||||
), f"Total daily sales should be {EXPECTED_TOTAL_DAILY_SALES}, got {stats['total_daily_sales']}"
|
)
|
||||||
assert (
|
assert stats["matched_to_reservation"] == EXPECTED_MATCHED_TO_RESERVATION, (
|
||||||
stats["matched_to_reservation"] == EXPECTED_MATCHED_TO_RESERVATION
|
f"Matched reservations should be {EXPECTED_MATCHED_TO_RESERVATION}, got {stats['matched_to_reservation']}"
|
||||||
), f"Matched reservations should be {EXPECTED_MATCHED_TO_RESERVATION}, got {stats['matched_to_reservation']}"
|
)
|
||||||
|
|
||||||
assert (
|
assert stats["matched_to_customer"] == EXPECTED_MATCHED_TO_CUSTOMER, (
|
||||||
stats["matched_to_customer"] == EXPECTED_MATCHED_TO_CUSTOMER
|
f"Matched customers should be {EXPECTED_MATCHED_TO_CUSTOMER}, got {stats['matched_to_customer']}"
|
||||||
), f"Matched customers should be {EXPECTED_MATCHED_TO_CUSTOMER}, got {stats['matched_to_customer']}"
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_room_revenue_aggregation(
|
async def test_conversion_room_revenue_aggregation(
|
||||||
@@ -237,23 +242,25 @@ class TestConversionServiceWithImportedData:
|
|||||||
|
|
||||||
# Note: Test data may not have revenue values in the XML
|
# Note: Test data may not have revenue values in the XML
|
||||||
# The important thing is that we're capturing room-level data
|
# The important thing is that we're capturing room-level data
|
||||||
print(f"\nRevenue Aggregation Stats:")
|
print("\nRevenue Aggregation Stats:")
|
||||||
print(f" Total conversion rooms: {len(all_rooms)}")
|
print(f" Total conversion rooms: {len(all_rooms)}")
|
||||||
print(f" Rooms with revenue: {len(rooms_with_revenue)}")
|
print(f" Rooms with revenue: {len(rooms_with_revenue)}")
|
||||||
|
|
||||||
if rooms_with_revenue:
|
if rooms_with_revenue:
|
||||||
# Verify revenue values are numeric and positive
|
# Verify revenue values are numeric and positive
|
||||||
for room in rooms_with_revenue:
|
for room in rooms_with_revenue:
|
||||||
assert isinstance(
|
assert isinstance(room.total_revenue, (int, float)), (
|
||||||
room.total_revenue, (int, float)
|
f"Revenue should be numeric, got {type(room.total_revenue)}"
|
||||||
), f"Revenue should be numeric, got {type(room.total_revenue)}"
|
)
|
||||||
assert (
|
assert room.total_revenue > 0, (
|
||||||
room.total_revenue > 0
|
f"Revenue should be positive, got {room.total_revenue}"
|
||||||
), f"Revenue should be positive, got {room.total_revenue}"
|
)
|
||||||
|
|
||||||
total_revenue = sum(room.total_revenue for room in rooms_with_revenue)
|
total_revenue = sum(room.total_revenue for room in rooms_with_revenue)
|
||||||
print(f" Total aggregated revenue: {total_revenue}")
|
print(f" Total aggregated revenue: {total_revenue}")
|
||||||
print(f" Average revenue per room: {total_revenue / len(rooms_with_revenue)}")
|
print(
|
||||||
|
f" Average revenue per room: {total_revenue / len(rooms_with_revenue)}"
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_matching_by_guest_details(
|
async def test_conversion_matching_by_guest_details(
|
||||||
@@ -282,7 +289,9 @@ class TestConversionServiceWithImportedData:
|
|||||||
dryrun=False,
|
dryrun=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert csv_stats["created_reservations"] > 0, "Should have imported reservations"
|
assert csv_stats["created_reservations"] > 0, (
|
||||||
|
"Should have imported reservations"
|
||||||
|
)
|
||||||
|
|
||||||
# Process conversions
|
# Process conversions
|
||||||
with xml_file.open(encoding="utf-8") as f:
|
with xml_file.open(encoding="utf-8") as f:
|
||||||
@@ -307,14 +316,14 @@ class TestConversionServiceWithImportedData:
|
|||||||
)
|
)
|
||||||
conversions_with_customers = result.scalars().all()
|
conversions_with_customers = result.scalars().all()
|
||||||
|
|
||||||
print(f"\nGuest Detail Matching:")
|
print("\nGuest Detail Matching:")
|
||||||
print(f" Total conversions: {len(all_conversions)}")
|
print(f" Total conversions: {len(all_conversions)}")
|
||||||
print(f" Conversions matched to customer: {len(conversions_with_customers)}")
|
print(f" Conversions matched to customer: {len(conversions_with_customers)}")
|
||||||
print(f" Stats matched_to_customer: {stats['matched_to_customer']}")
|
print(f" Stats matched_to_customer: {stats['matched_to_customer']}")
|
||||||
|
|
||||||
# With this test data, matches may be 0 if guest names/emails don't align
|
# With this test data, matches may be 0 if guest names/emails don't align
|
||||||
# The important thing is that the matching logic runs without errors
|
# The important thing is that the matching logic runs without errors
|
||||||
print(f" Note: Matches depend on data alignment between CSV and XML files")
|
print(" Note: Matches depend on data alignment between CSV and XML files")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_service_error_handling(
|
async def test_conversion_service_error_handling(
|
||||||
@@ -354,7 +363,7 @@ class TestConversionServiceWithImportedData:
|
|||||||
with room_number='201', second has status='request' with room_number='202'
|
with room_number='201', second has status='request' with room_number='202'
|
||||||
4. The old room entries (101, 102) should no longer exist in the database
|
4. The old room entries (101, 102) should no longer exist in the database
|
||||||
"""
|
"""
|
||||||
from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder
|
from tests.helpers import MultiReservationXMLBuilder, ReservationXMLBuilder
|
||||||
|
|
||||||
# First batch: Process two reservations
|
# First batch: Process two reservations
|
||||||
multi_builder1 = MultiReservationXMLBuilder()
|
multi_builder1 = MultiReservationXMLBuilder()
|
||||||
@@ -363,13 +372,13 @@ class TestConversionServiceWithImportedData:
|
|||||||
res1_v1 = (
|
res1_v1 = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="res_001",
|
reservation_id="100",
|
||||||
reservation_number="RES-001",
|
reservation_number="100",
|
||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
reservation_type="request",
|
reservation_type="request",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="100",
|
||||||
first_name="Alice",
|
first_name="Alice",
|
||||||
last_name="Johnson",
|
last_name="Johnson",
|
||||||
email="alice@example.com",
|
email="alice@example.com",
|
||||||
@@ -388,13 +397,13 @@ class TestConversionServiceWithImportedData:
|
|||||||
res2_v1 = (
|
res2_v1 = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="res_002",
|
reservation_id="101",
|
||||||
reservation_number="RES-002",
|
reservation_number="101",
|
||||||
reservation_date="2025-11-15",
|
reservation_date="2025-11-15",
|
||||||
reservation_type="reservation",
|
reservation_type="reservation",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_002",
|
guest_id="101",
|
||||||
first_name="Bob",
|
first_name="Bob",
|
||||||
last_name="Smith",
|
last_name="Smith",
|
||||||
email="bob@example.com",
|
email="bob@example.com",
|
||||||
@@ -437,13 +446,13 @@ class TestConversionServiceWithImportedData:
|
|||||||
res1_v2 = (
|
res1_v2 = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="res_001", # Same ID
|
reservation_id="100", # Same ID
|
||||||
reservation_number="RES-001", # Same number
|
reservation_number="100", # Same number
|
||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
reservation_type="reservation", # Changed from request
|
reservation_type="reservation", # Changed from request
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="100",
|
||||||
first_name="Alice",
|
first_name="Alice",
|
||||||
last_name="Johnson",
|
last_name="Johnson",
|
||||||
email="alice@example.com",
|
email="alice@example.com",
|
||||||
@@ -462,13 +471,13 @@ class TestConversionServiceWithImportedData:
|
|||||||
res2_v2 = (
|
res2_v2 = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="res_002", # Same ID
|
reservation_id="101", # Same ID
|
||||||
reservation_number="RES-002", # Same number
|
reservation_number="101", # Same number
|
||||||
reservation_date="2025-11-15",
|
reservation_date="2025-11-15",
|
||||||
reservation_type="request", # Changed from reservation
|
reservation_type="request", # Changed from reservation
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_002",
|
guest_id="101",
|
||||||
first_name="Bob",
|
first_name="Bob",
|
||||||
last_name="Smith",
|
last_name="Smith",
|
||||||
email="bob@example.com",
|
email="bob@example.com",
|
||||||
@@ -533,7 +542,6 @@ class TestConversionServiceWithImportedData:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestXMLBuilderUsage:
|
class TestXMLBuilderUsage:
|
||||||
"""Demonstrate usage of XML builder helpers for creating test data."""
|
"""Demonstrate usage of XML builder helpers for creating test data."""
|
||||||
|
|
||||||
@@ -546,12 +554,12 @@ class TestXMLBuilderUsage:
|
|||||||
xml_content = (
|
xml_content = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="test_123",
|
reservation_id="123",
|
||||||
reservation_number="RES-123",
|
reservation_number="123",
|
||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="157",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
@@ -563,7 +571,7 @@ class TestXMLBuilderUsage:
|
|||||||
room_type="DZV",
|
room_type="DZV",
|
||||||
room_number="101",
|
room_number="101",
|
||||||
revenue_logis_per_day=150.0,
|
revenue_logis_per_day=150.0,
|
||||||
adults=2
|
adults=2,
|
||||||
)
|
)
|
||||||
.build_xml()
|
.build_xml()
|
||||||
)
|
)
|
||||||
@@ -576,21 +584,19 @@ class TestXMLBuilderUsage:
|
|||||||
assert stats["total_daily_sales"] == 5 # 4 nights + departure day
|
assert stats["total_daily_sales"] == 5 # 4 nights + departure day
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_using_xml_builder_for_multi_room_reservation(
|
async def test_using_xml_builder_for_multi_room_reservation(self, test_db_session):
|
||||||
self, test_db_session
|
|
||||||
):
|
|
||||||
"""Example: Create a reservation with multiple rooms."""
|
"""Example: Create a reservation with multiple rooms."""
|
||||||
from tests.helpers import ReservationXMLBuilder
|
from tests.helpers import ReservationXMLBuilder
|
||||||
|
|
||||||
xml_content = (
|
xml_content = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="test_456",
|
reservation_id="456",
|
||||||
reservation_number="RES-456",
|
reservation_number="456",
|
||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_002",
|
guest_id="157",
|
||||||
first_name="Jane",
|
first_name="Jane",
|
||||||
last_name="Smith",
|
last_name="Smith",
|
||||||
email="jane@example.com",
|
email="jane@example.com",
|
||||||
@@ -620,7 +626,7 @@ class TestXMLBuilderUsage:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_using_multi_reservation_builder(self, test_db_session):
|
async def test_using_multi_reservation_builder(self, test_db_session):
|
||||||
"""Example: Create multiple reservations in one XML document."""
|
"""Example: Create multiple reservations in one XML document."""
|
||||||
from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder
|
from tests.helpers import MultiReservationXMLBuilder, ReservationXMLBuilder
|
||||||
|
|
||||||
multi_builder = MultiReservationXMLBuilder()
|
multi_builder = MultiReservationXMLBuilder()
|
||||||
|
|
||||||
@@ -628,12 +634,12 @@ class TestXMLBuilderUsage:
|
|||||||
res1 = (
|
res1 = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="test_001",
|
reservation_id="175",
|
||||||
reservation_number="RES-001",
|
reservation_number="175",
|
||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="157",
|
||||||
first_name="Alice",
|
first_name="Alice",
|
||||||
last_name="Johnson",
|
last_name="Johnson",
|
||||||
email="alice@example.com",
|
email="alice@example.com",
|
||||||
@@ -650,12 +656,12 @@ class TestXMLBuilderUsage:
|
|||||||
res2 = (
|
res2 = (
|
||||||
ReservationXMLBuilder(
|
ReservationXMLBuilder(
|
||||||
hotel_id="39054_001",
|
hotel_id="39054_001",
|
||||||
reservation_id="test_002",
|
reservation_id="2725",
|
||||||
reservation_number="RES-002",
|
reservation_number="RES-002",
|
||||||
reservation_date="2025-11-15",
|
reservation_date="2025-11-15",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_002",
|
guest_id="2525",
|
||||||
first_name="Bob",
|
first_name="Bob",
|
||||||
last_name="Williams",
|
last_name="Williams",
|
||||||
email="bob@example.com",
|
email="bob@example.com",
|
||||||
@@ -683,14 +689,12 @@ class TestHashedMatchingLogic:
|
|||||||
"""Test the hashed matching logic used in ConversionService."""
|
"""Test the hashed matching logic used in ConversionService."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_guest_hashed_fields_are_populated(
|
async def test_conversion_guest_hashed_fields_are_populated(self, test_db_session):
|
||||||
self, test_db_session
|
|
||||||
):
|
|
||||||
"""Test that ConversionGuest properly stores hashed versions of guest data."""
|
"""Test that ConversionGuest properly stores hashed versions of guest data."""
|
||||||
# Create a conversion guest
|
# Create a conversion guest
|
||||||
conversion_guest = ConversionGuest.create_from_conversion_data(
|
conversion_guest = ConversionGuest.create_from_conversion_data(
|
||||||
hotel_id="test_hotel",
|
hotel_id="test_hotel",
|
||||||
guest_id="guest_123",
|
guest_id=123,
|
||||||
guest_first_name="Margaret",
|
guest_first_name="Margaret",
|
||||||
guest_last_name="Brown",
|
guest_last_name="Brown",
|
||||||
guest_email="margaret@example.com",
|
guest_email="margaret@example.com",
|
||||||
@@ -721,7 +725,6 @@ class TestHashedMatchingLogic:
|
|||||||
assert conversion_guest.hashed_last_name == expected_hashed_last
|
assert conversion_guest.hashed_last_name == expected_hashed_last
|
||||||
assert conversion_guest.hashed_email == expected_hashed_email
|
assert conversion_guest.hashed_email == expected_hashed_email
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_records_created_before_matching(
|
async def test_conversion_records_created_before_matching(
|
||||||
self, test_db_session, test_config
|
self, test_db_session, test_config
|
||||||
@@ -749,11 +752,13 @@ class TestHashedMatchingLogic:
|
|||||||
test_db_session.add(reservation)
|
test_db_session.add(reservation)
|
||||||
await test_db_session.commit()
|
await test_db_session.commit()
|
||||||
|
|
||||||
|
PMS_RESERVATION_ID = 157
|
||||||
|
|
||||||
# Create conversion XML with matching hashed data
|
# Create conversion XML with matching hashed data
|
||||||
xml_content = f"""<?xml version="1.0"?>
|
xml_content = f"""<?xml version="1.0"?>
|
||||||
<root>
|
<root>
|
||||||
<reservation id="pms_123" hotelID="hotel_1" number="RES001" date="2025-01-15">
|
<reservation id="{PMS_RESERVATION_ID}" hotelID="hotel_1" number="378" date="2025-01-15">
|
||||||
<guest id="guest_001" firstName="David" lastName="Miller" email="david@example.com"/>
|
<guest id="123" firstName="David" lastName="Miller" email="david@example.com"/>
|
||||||
<roomReservations>
|
<roomReservations>
|
||||||
<roomReservation roomNumber="101" arrival="2025-01-15" departure="2025-01-17" status="confirmed">
|
<roomReservation roomNumber="101" arrival="2025-01-15" departure="2025-01-17" status="confirmed">
|
||||||
<dailySales>
|
<dailySales>
|
||||||
@@ -764,12 +769,14 @@ class TestHashedMatchingLogic:
|
|||||||
</reservation>
|
</reservation>
|
||||||
</root>"""
|
</root>"""
|
||||||
|
|
||||||
service = ConversionService(test_db_session)
|
service = ConversionService(test_db_session, hotel_id="hotel_1")
|
||||||
stats = await service.process_conversion_xml(xml_content)
|
stats = await service.process_conversion_xml(xml_content)
|
||||||
|
|
||||||
# Verify conversion was created
|
# Verify conversion was created
|
||||||
result = await test_db_session.execute(
|
result = await test_db_session.execute(
|
||||||
select(Conversion).where(Conversion.pms_reservation_id == "pms_123")
|
select(Conversion).where(
|
||||||
|
Conversion.pms_reservation_id == PMS_RESERVATION_ID
|
||||||
|
)
|
||||||
)
|
)
|
||||||
conversion = result.scalar_one_or_none()
|
conversion = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -779,22 +786,23 @@ class TestHashedMatchingLogic:
|
|||||||
|
|
||||||
# Verify conversion_guest was created with the correct data
|
# Verify conversion_guest was created with the correct data
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
result_with_guest = await test_db_session.execute(
|
result_with_guest = await test_db_session.execute(
|
||||||
select(Conversion)
|
select(Conversion)
|
||||||
.where(Conversion.pms_reservation_id == "pms_123")
|
.where(Conversion.pms_reservation_id == PMS_RESERVATION_ID)
|
||||||
.options(selectinload(Conversion.guest))
|
.options(selectinload(Conversion.guest))
|
||||||
)
|
)
|
||||||
conversion_with_guest = result_with_guest.scalar_one_or_none()
|
conversion_with_guest = result_with_guest.scalar_one_or_none()
|
||||||
assert conversion_with_guest.guest is not None, "ConversionGuest relationship should exist"
|
assert conversion_with_guest.guest is not None, (
|
||||||
|
"ConversionGuest relationship should exist"
|
||||||
|
)
|
||||||
assert conversion_with_guest.guest.guest_first_name == "David"
|
assert conversion_with_guest.guest.guest_first_name == "David"
|
||||||
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
|
# Verify conversion_room was created
|
||||||
room_result = await test_db_session.execute(
|
room_result = await test_db_session.execute(
|
||||||
select(ConversionRoom).where(
|
select(ConversionRoom).where(ConversionRoom.conversion_id == conversion.id)
|
||||||
ConversionRoom.conversion_id == conversion.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
rooms = room_result.scalars().all()
|
rooms = room_result.scalars().all()
|
||||||
assert len(rooms) > 0, "ConversionRoom should be created"
|
assert len(rooms) > 0, "ConversionRoom should be created"
|
||||||
@@ -804,8 +812,6 @@ class TestHashedMatchingLogic:
|
|||||||
assert stats["total_reservations"] == 1
|
assert stats["total_reservations"] == 1
|
||||||
assert stats["total_daily_sales"] == 1
|
assert stats["total_daily_sales"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_conversion_guest_composite_key_prevents_duplicates(
|
async def test_conversion_guest_composite_key_prevents_duplicates(
|
||||||
self, test_db_session
|
self, test_db_session
|
||||||
@@ -819,7 +825,7 @@ class TestHashedMatchingLogic:
|
|||||||
Now the database itself enforces uniqueness at the PK level.
|
Now the database itself enforces uniqueness at the PK level.
|
||||||
"""
|
"""
|
||||||
hotel_id = "test_hotel"
|
hotel_id = "test_hotel"
|
||||||
guest_id = "guest_123"
|
guest_id = 123
|
||||||
|
|
||||||
# Create and commit first conversion guest
|
# Create and commit first conversion guest
|
||||||
guest1 = ConversionGuest.create_from_conversion_data(
|
guest1 = ConversionGuest.create_from_conversion_data(
|
||||||
@@ -862,6 +868,7 @@ class TestHashedMatchingLogic:
|
|||||||
|
|
||||||
# The composite PK constraint prevents the duplicate insert
|
# The composite PK constraint prevents the duplicate insert
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await test_db_session.commit()
|
await test_db_session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class TestReservationXMLBuilder:
|
|||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
builder.set_guest(
|
builder.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="1001",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
@@ -138,7 +138,7 @@ class TestReservationXMLBuilder:
|
|||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
builder.set_guest(
|
builder.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="1001",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
@@ -179,7 +179,7 @@ class TestReservationXMLBuilder:
|
|||||||
advertising_campagne="EAIaIQobChMI...",
|
advertising_campagne="EAIaIQobChMI...",
|
||||||
)
|
)
|
||||||
builder.set_guest(
|
builder.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="1001",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
@@ -213,7 +213,7 @@ class TestMultiReservationXMLBuilder:
|
|||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
res1.set_guest(
|
res1.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="1001",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
@@ -233,7 +233,7 @@ class TestMultiReservationXMLBuilder:
|
|||||||
reservation_date="2025-11-15",
|
reservation_date="2025-11-15",
|
||||||
)
|
)
|
||||||
res2.set_guest(
|
res2.set_guest(
|
||||||
guest_id="guest_002",
|
guest_id="1002",
|
||||||
first_name="Jane",
|
first_name="Jane",
|
||||||
last_name="Smith",
|
last_name="Smith",
|
||||||
email="jane@example.com",
|
email="jane@example.com",
|
||||||
@@ -268,7 +268,7 @@ class TestConvenienceFeatures:
|
|||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
.set_guest(
|
.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="1001",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
@@ -294,7 +294,7 @@ class TestConvenienceFeatures:
|
|||||||
reservation_date="2025-11-14",
|
reservation_date="2025-11-14",
|
||||||
)
|
)
|
||||||
builder.set_guest(
|
builder.set_guest(
|
||||||
guest_id="guest_001",
|
guest_id="1001",
|
||||||
first_name="John",
|
first_name="John",
|
||||||
last_name="Doe",
|
last_name="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user