Activated free rooms

This commit is contained in:
Jonas Linter
2025-12-04 15:32:29 +01:00
parent 5f82de9c53
commit cdb69bc3c7
9 changed files with 234652 additions and 392882 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -0,0 +1,54 @@
"""pk_key_and_name_changes_for_room_availabilty
Revision ID: 872d95f54456
Revises: 1daea5172a03
Create Date: 2025-12-04 15:26:19.484062
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '872d95f54456'
down_revision: Union[str, Sequence[str], None] = '1daea5172a03'
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! ###
op.add_column('room_availability', sa.Column('bookable_type_2', sa.Integer(), nullable=True))
op.add_column('room_availability', sa.Column('out_of_order_type_6', sa.Integer(), nullable=True))
op.add_column('room_availability', sa.Column('not_bookable_type_9', sa.Integer(), nullable=True))
op.drop_index(op.f('ix_room_availability_date'), table_name='room_availability')
op.drop_index(op.f('ix_room_availability_inventory_id'), table_name='room_availability')
op.drop_constraint(op.f('uq_room_availability_unique_key'), 'room_availability', type_='unique')
op.drop_column('room_availability', 'count_type_6')
op.drop_column('room_availability', 'count_type_2')
op.drop_column('room_availability', 'count_type_9')
op.drop_column('room_availability', 'id')
# Create composite primary key on inventory_id and date
op.create_primary_key('pk_room_availability', 'room_availability', ['inventory_id', 'date'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop composite primary key before adding back the id column
op.drop_constraint('pk_room_availability', 'room_availability', type_='primary')
op.add_column('room_availability', sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False))
op.add_column('room_availability', sa.Column('count_type_9', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('room_availability', sa.Column('count_type_2', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('room_availability', sa.Column('count_type_6', sa.INTEGER(), autoincrement=False, nullable=True))
op.create_unique_constraint(op.f('uq_room_availability_unique_key'), 'room_availability', ['inventory_id', 'date'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_room_availability_inventory_id'), 'room_availability', ['inventory_id'], unique=False)
op.create_index(op.f('ix_room_availability_date'), 'room_availability', ['date'], unique=False)
op.drop_column('room_availability', 'not_bookable_type_9')
op.drop_column('room_availability', 'out_of_order_type_6')
op.drop_column('room_availability', 'bookable_type_2')
# ### end Alembic commands ###

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -97,4 +97,44 @@ select sum(room.total_revenue::float), is_regular
group by is_regular group by is_regular
; ;
```
```
SELECT res.created_at AS "AnfrageDatum",
directly_attributable,
con.reservation_date,
res.start_date,
room.arrival_date,
res.end_date,
room.departure_date,
advertising_medium,
guest_first_name,
cus.given_name,
guest_last_name,
cus.surname,
total_revenue,
room.room_status,
room_number,
is_regular,
is_awareness_guest,
guest_matched,
con.hotel_id,
guest.guest_id
FROM alpinebits.conversions AS con
JOIN alpinebits.conversion_rooms AS room ON room.conversion_id = con.id
JOIN alpinebits.conversion_guests AS guest ON guest.guest_id = con.guest_id
LEFT JOIN alpinebits.reservations AS res ON res.id = con.reservation_id
LEFT JOIN alpinebits.customers AS cus ON cus.id = con.customer_id
WHERE reservation_date > '2025-01-01'
AND guest.guest_id IN (
SELECT DISTINCT g.guest_id
FROM alpinebits.conversions AS c
JOIN alpinebits.conversion_rooms AS r ON r.conversion_id = c.id
JOIN alpinebits.conversion_guests AS g ON g.guest_id = c.guest_id
WHERE c.reservation_date > '2025-01-01'
AND r.total_revenue > 0
)
ORDER BY guest_first_name, guest_last_name, room_status;
``` ```

View File

@@ -15,6 +15,7 @@ from enum import Enum
from typing import Any, Optional, override from typing import Any, Optional, override
from xsdata.formats.dataclass.serializers.config import SerializerConfig from xsdata.formats.dataclass.serializers.config import SerializerConfig
from xsdata.exceptions import ParserError
from xsdata_pydantic.bindings import XmlParser, XmlSerializer from xsdata_pydantic.bindings import XmlParser, XmlSerializer
from alpine_bits_python.alpine_bits_helpers import ( from alpine_bits_python.alpine_bits_helpers import (
@@ -476,8 +477,12 @@ class ReadAction(AlpineBitsAction):
return AlpineBitsResponse( return AlpineBitsResponse(
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR "Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
) )
try:
read_request = XmlParser().from_string(request_xml, OtaReadRq) read_request = XmlParser().from_string(request_xml, OtaReadRq)
except ParserError:
return AlpineBitsResponse(
"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
)
hotel_read_request = read_request.read_requests.hotel_read_request hotel_read_request = read_request.read_requests.hotel_read_request
@@ -837,4 +842,4 @@ class AlpineBitsServer:
# Ensure FreeRoomsAction is registered with ServerCapabilities discovery # Ensure FreeRoomsAction is registered with ServerCapabilities discovery
# from .free_rooms_action import FreeRoomsAction from .free_rooms_action import FreeRoomsAction

View File

@@ -16,6 +16,7 @@ from sqlalchemy import (
Index, Index,
Integer, Integer,
MetaData, MetaData,
PrimaryKeyConstraint,
String, String,
UniqueConstraint, UniqueConstraint,
func, func,
@@ -750,17 +751,15 @@ class RoomAvailability(Base):
__tablename__ = "room_availability" __tablename__ = "room_availability"
id = Column(Integer, primary_key=True)
inventory_id = Column( inventory_id = Column(
Integer, Integer,
ForeignKey("hotel_inventory.id", ondelete="CASCADE"), ForeignKey("hotel_inventory.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
) )
date = Column(Date, nullable=False, index=True) date = Column(Date, nullable=False)
count_type_2 = Column(Integer, nullable=True) bookable_type_2 = Column(Integer, nullable=True)
count_type_6 = Column(Integer, nullable=True) out_of_order_type_6 = Column(Integer, nullable=True)
count_type_9 = Column(Integer, nullable=True) not_bookable_type_9 = Column(Integer, nullable=True)
is_closing_season = Column(Boolean, nullable=False, default=False) is_closing_season = Column(Boolean, nullable=False, default=False)
last_updated = Column(DateTime(timezone=True), nullable=False) last_updated = Column(DateTime(timezone=True), nullable=False)
update_type = Column(String(20), nullable=False) update_type = Column(String(20), nullable=False)
@@ -768,9 +767,7 @@ class RoomAvailability(Base):
inventory_item = relationship("HotelInventory", back_populates="availability") inventory_item = relationship("HotelInventory", back_populates="availability")
__table_args__ = ( __table_args__ = (
UniqueConstraint( PrimaryKeyConstraint("inventory_id", "date", name="pk_room_availability"),
"inventory_id", "date", name="uq_room_availability_unique_key"
),
) )

View File

@@ -48,9 +48,9 @@ CLOSING_SEASON_TYPE = "__CLOSE" # <= 8 chars per spec
SOURCE_FREEROOMS = "FreeRooms" SOURCE_FREEROOMS = "FreeRooms"
COUNT_TYPE_MAP = { COUNT_TYPE_MAP = {
InvCountCountType.VALUE_2: "count_type_2", InvCountCountType.VALUE_2: "bookable_type_2",
InvCountCountType.VALUE_6: "count_type_6", InvCountCountType.VALUE_6: "out_of_order_type_6",
InvCountCountType.VALUE_9: "count_type_9", InvCountCountType.VALUE_9: "not_bookable_type_9",
} }
@@ -202,6 +202,107 @@ class FreeRoomsAction(AlpineBitsAction):
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
def _validate_request(
self,
request: OtaHotelInvCountNotifRq,
update_type: str,
enforce_closing_order: bool,
) -> None:
"""
Validate the entire request before making any database changes.
This performs all validation checks upfront to fail fast and avoid
expensive rollbacks of database operations.
Args:
request: The parsed OTA request
update_type: "CompleteSet" or "Delta"
enforce_closing_order: Whether to enforce closing seasons must come first
Raises:
FreeRoomsProcessingError: If any validation fails
"""
inventories = request.inventories.inventory if request.inventories else []
if not inventories:
raise FreeRoomsProcessingError(
"Request must include at least one Inventory block",
HttpStatusCode.BAD_REQUEST,
)
encountered_standard = False
has_categories = False # Tracks if we've seen category reports (no InvCode)
has_rooms = False # Tracks if we've seen individual room reports (with InvCode)
for inventory in inventories:
sac = inventory.status_application_control
if sac is None:
raise FreeRoomsProcessingError(
"StatusApplicationControl element is required for each Inventory",
HttpStatusCode.BAD_REQUEST,
)
is_closing = self._is_closing_season(sac)
# Validate closing seasons
if is_closing:
if inventory.inv_counts is not None:
raise FreeRoomsProcessingError(
"Closing seasons cannot contain InvCounts data",
HttpStatusCode.BAD_REQUEST,
)
if update_type != "CompleteSet":
raise FreeRoomsProcessingError(
"Closing seasons are only allowed on CompleteSet updates",
HttpStatusCode.BAD_REQUEST,
)
if enforce_closing_order and encountered_standard:
raise FreeRoomsProcessingError(
"Closing seasons must appear before other inventory entries",
HttpStatusCode.BAD_REQUEST,
)
if sac.inv_type_code or sac.inv_code:
raise FreeRoomsProcessingError(
"Closing season entries cannot specify InvTypeCode or InvCode",
HttpStatusCode.BAD_REQUEST,
)
# Validate date range
self._parse_date_range(sac.start, sac.end)
continue
# Mark that we've seen a non-closing inventory entry
encountered_standard = True
# Validate standard inventory entries
inv_type_code = (sac.inv_type_code or "").strip()
if not inv_type_code:
raise FreeRoomsProcessingError(
"InvTypeCode is required unless AllInvCode=\"true\"",
HttpStatusCode.BAD_REQUEST,
)
# Validate date range
self._parse_date_range(sac.start, sac.end)
# Validate that we don't mix categories and individual rooms
has_inv_code = sac.inv_code is not None and sac.inv_code.strip() != ""
if has_inv_code:
if has_categories:
raise FreeRoomsProcessingError(
"Mixing room categories and individual rooms in one request is not allowed",
HttpStatusCode.BAD_REQUEST,
)
has_rooms = True
else:
if has_rooms:
raise FreeRoomsProcessingError(
"Mixing room categories and individual rooms in one request is not allowed",
HttpStatusCode.BAD_REQUEST,
)
has_categories = True
# Validate counts
self._extract_counts(inventory.inv_counts)
async def _process_complete_set( async def _process_complete_set(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -210,7 +311,13 @@ class FreeRoomsAction(AlpineBitsAction):
update_type: str, update_type: str,
inventory_cache: dict[tuple[str, str | None], HotelInventory], inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> None: ) -> None:
# Validate first before making any database changes
self._validate_request(request, update_type, enforce_closing_order=True)
# Only delete if validation passes
await self._delete_existing_availability(session, hotel.hotel_id) await self._delete_existing_availability(session, hotel.hotel_id)
# Process the validated request
await self._process_inventories( await self._process_inventories(
session, hotel, request, update_type, inventory_cache, enforce_closing_order=True session, hotel, request, update_type, inventory_cache, enforce_closing_order=True
) )
@@ -223,6 +330,10 @@ class FreeRoomsAction(AlpineBitsAction):
update_type: str, update_type: str,
inventory_cache: dict[tuple[str, str | None], HotelInventory], inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> None: ) -> None:
# Validate first before making any database changes
self._validate_request(request, update_type, enforce_closing_order=False)
# Process the validated request
await self._process_inventories( await self._process_inventories(
session, hotel, request, update_type, inventory_cache, enforce_closing_order=False session, hotel, request, update_type, inventory_cache, enforce_closing_order=False
) )
@@ -246,42 +357,23 @@ class FreeRoomsAction(AlpineBitsAction):
inventory_cache: dict[tuple[str, str | None], HotelInventory], inventory_cache: dict[tuple[str, str | None], HotelInventory],
enforce_closing_order: bool, enforce_closing_order: bool,
) -> None: ) -> None:
inventories = request.inventories.inventory if request.inventories else [] """
if not inventories: Process validated inventory data and store in database.
raise FreeRoomsProcessingError(
"Request must include at least one Inventory block",
HttpStatusCode.BAD_REQUEST,
)
Note: Validation should be done before calling this method via _validate_request().
This method focuses on data transformation and persistence.
"""
inventories = request.inventories.inventory if request.inventories else []
rows_to_upsert: list[dict[str, Any]] = [] rows_to_upsert: list[dict[str, Any]] = []
now = datetime.now(UTC) now = datetime.now(UTC)
encountered_standard = False
for inventory in inventories: for inventory in inventories:
sac = inventory.status_application_control sac = inventory.status_application_control
if sac is None: if sac is None:
raise FreeRoomsProcessingError( continue # Should not happen after validation
"StatusApplicationControl element is required for each Inventory",
HttpStatusCode.BAD_REQUEST,
)
is_closing = self._is_closing_season(sac) is_closing = self._is_closing_season(sac)
if is_closing: if is_closing:
if inventory.inv_counts is not None:
raise FreeRoomsProcessingError(
"Closing seasons cannot contain InvCounts data",
HttpStatusCode.BAD_REQUEST,
)
if update_type != "CompleteSet":
raise FreeRoomsProcessingError(
"Closing seasons are only allowed on CompleteSet updates",
HttpStatusCode.BAD_REQUEST,
)
if enforce_closing_order and encountered_standard:
raise FreeRoomsProcessingError(
"Closing seasons must appear before other inventory entries",
HttpStatusCode.BAD_REQUEST,
)
rows_to_upsert.extend( rows_to_upsert.extend(
await self._process_closing_season( await self._process_closing_season(
session, hotel, sac, update_type, now, inventory_cache session, hotel, sac, update_type, now, inventory_cache
@@ -289,7 +381,6 @@ class FreeRoomsAction(AlpineBitsAction):
) )
continue continue
encountered_standard = True
rows_to_upsert.extend( rows_to_upsert.extend(
await self._process_inventory_item( await self._process_inventory_item(
session, session,
@@ -313,12 +404,7 @@ class FreeRoomsAction(AlpineBitsAction):
timestamp: datetime, timestamp: datetime,
inventory_cache: dict[tuple[str, str | None], HotelInventory], inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
if sac.inv_type_code or sac.inv_code: """Process a closing season entry. Assumes validation already done."""
raise FreeRoomsProcessingError(
"Closing season entries cannot specify InvTypeCode or InvCode",
HttpStatusCode.BAD_REQUEST,
)
start_date, end_date = self._parse_date_range(sac.start, sac.end) start_date, end_date = self._parse_date_range(sac.start, sac.end)
inventory_item = await self._ensure_inventory_item( inventory_item = await self._ensure_inventory_item(
session, session,
@@ -331,9 +417,9 @@ class FreeRoomsAction(AlpineBitsAction):
base_payload = { base_payload = {
"inventory_id": inventory_item.id, "inventory_id": inventory_item.id,
"count_type_2": None, "bookable_type_2": None,
"count_type_6": None, "out_of_order_type_6": None,
"count_type_9": None, "not_bookable_type_9": None,
"is_closing_season": True, "is_closing_season": True,
"last_updated": timestamp, "last_updated": timestamp,
"update_type": update_type, "update_type": update_type,
@@ -358,21 +444,16 @@ class FreeRoomsAction(AlpineBitsAction):
timestamp: datetime, timestamp: datetime,
inventory_cache: dict[tuple[str, str | None], HotelInventory], inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Process a standard inventory item. Assumes validation already done."""
inv_type_code = (sac.inv_type_code or "").strip() inv_type_code = (sac.inv_type_code or "").strip()
if not inv_type_code:
raise FreeRoomsProcessingError(
"InvTypeCode is required unless AllInvCode=\"true\"",
HttpStatusCode.BAD_REQUEST,
)
inv_code = sac.inv_code.strip() if sac.inv_code else None inv_code = sac.inv_code.strip() if sac.inv_code else None
start_date, end_date = self._parse_date_range(sac.start, sac.end) start_date, end_date = self._parse_date_range(sac.start, sac.end)
counts = self._extract_counts(inv_counts) counts = self._extract_counts(inv_counts)
base_counts = { base_counts = {
"count_type_2": counts.get("count_type_2"), "bookable_type_2": counts.get("bookable_type_2"),
"count_type_6": counts.get("count_type_6"), "out_of_order_type_6": counts.get("out_of_order_type_6"),
"count_type_9": counts.get("count_type_9"), "not_bookable_type_9": counts.get("not_bookable_type_9"),
} }
inventory_item = await self._ensure_inventory_item( inventory_item = await self._ensure_inventory_item(
@@ -545,9 +626,9 @@ class FreeRoomsAction(AlpineBitsAction):
def _build_upsert_set(self, stmt): def _build_upsert_set(self, stmt):
return { return {
"count_type_2": stmt.excluded.count_type_2, "bookable_type_2": stmt.excluded.bookable_type_2,
"count_type_6": stmt.excluded.count_type_6, "out_of_order_type_6": stmt.excluded.out_of_order_type_6,
"count_type_9": stmt.excluded.count_type_9, "not_bookable_type_9": stmt.excluded.not_bookable_type_9,
"is_closing_season": stmt.excluded.is_closing_season, "is_closing_season": stmt.excluded.is_closing_season,
"last_updated": stmt.excluded.last_updated, "last_updated": stmt.excluded.last_updated,
"update_type": stmt.excluded.update_type, "update_type": stmt.excluded.update_type,
@@ -565,9 +646,9 @@ class FreeRoomsAction(AlpineBitsAction):
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing: if existing:
existing.count_type_2 = row["count_type_2"] existing.bookable_type_2 = row["bookable_type_2"]
existing.count_type_6 = row["count_type_6"] existing.out_of_order_type_6 = row["out_of_order_type_6"]
existing.count_type_9 = row["count_type_9"] existing.not_bookable_type_9 = row["not_bookable_type_9"]
existing.is_closing_season = row["is_closing_season"] existing.is_closing_season = row["is_closing_season"]
existing.last_updated = row["last_updated"] existing.last_updated = row["last_updated"]
existing.update_type = row["update_type"] existing.update_type = row["update_type"]

View File

@@ -124,7 +124,7 @@ async def test_complete_set_creates_inventory_and_availability(db_session: Async
) )
).scalars().all() ).scalars().all()
assert len(rows) == 3 assert len(rows) == 3
assert rows[0].count_type_2 == 4 assert rows[0].bookable_type_2 == 4
assert rows[0].update_type == "CompleteSet" assert rows[0].update_type == "CompleteSet"
@@ -157,7 +157,7 @@ async def test_complete_set_replaces_previous_availability(db_session: AsyncSess
).scalars().all() ).scalars().all()
assert len(rows) == 1 assert len(rows) == 1
assert rows[0].date.isoformat() == "2025-02-01" assert rows[0].date.isoformat() == "2025-02-01"
assert rows[0].count_type_2 == 1 assert rows[0].bookable_type_2 == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -186,7 +186,7 @@ async def test_delta_updates_only_specified_dates(db_session: AsyncSession):
rows = ( rows = (
await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date)) await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date))
).scalars().all() ).scalars().all()
counts = {row.date.isoformat(): row.count_type_2 for row in rows} counts = {row.date.isoformat(): row.bookable_type_2 for row in rows}
assert counts == { assert counts == {
"2025-03-01": 2, "2025-03-01": 2,
"2025-03-02": 7, "2025-03-02": 7,
@@ -229,7 +229,7 @@ async def test_closing_season_entries_marked_correctly(db_session: AsyncSession)
).scalars().all() ).scalars().all()
closing_rows = [row for row in rows if row.is_closing_season] closing_rows = [row for row in rows if row.is_closing_season]
assert len(closing_rows) == 2 assert len(closing_rows) == 2
assert all(row.count_type_2 is None for row in closing_rows) assert all(row.bookable_type_2 is None for row in closing_rows)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -366,3 +366,197 @@ async def test_invalid_xml_returns_error(db_session: AsyncSession):
) )
assert response.status_code == HttpStatusCode.BAD_REQUEST assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "Invalid XML payload" in response.xml_content assert "Invalid XML payload" in response.xml_content
@pytest.mark.asyncio
async def test_mixing_categories_and_rooms_is_rejected(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# First inventory is a category (no InvCode), second is an individual room (with InvCode)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-21" End="2025-08-30" InvTypeCode="DOUBLE" InvCode="108" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "Mixing room categories and individual rooms" in response.xml_content
@pytest.mark.asyncio
async def test_mixing_rooms_and_categories_is_rejected(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# First inventory is an individual room (with InvCode), second is a category (no InvCode)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-21" End="2025-08-30" InvTypeCode="DOUBLE" InvCode="108" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "Mixing room categories and individual rooms" in response.xml_content
@pytest.mark.asyncio
async def test_multiple_categories_are_allowed(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# Multiple category reports (all without InvCode) should be allowed
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-11" End="2025-08-20" InvTypeCode="SINGLE" />
<InvCounts>
<InvCount CountType="2" Count="2" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
@pytest.mark.asyncio
async def test_multiple_rooms_are_allowed(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# Multiple individual room reports (all with InvCode) should be allowed
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" InvCode="101" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-11" End="2025-08-20" InvTypeCode="DOUBLE" InvCode="102" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
@pytest.mark.asyncio
async def test_closing_season_with_categories_is_allowed(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# Closing season followed by category reports should be allowed
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-04-01" End="2025-04-02" AllInvCode="true"/>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-04-03" End="2025-04-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
@pytest.mark.asyncio
async def test_closing_season_with_rooms_is_allowed(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# Closing season followed by individual room reports should be allowed
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-04-01" End="2025-04-02" AllInvCode="true"/>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-04-03" End="2025-04-10" InvTypeCode="DOUBLE" InvCode="101" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK