diff --git a/src/alpine_bits_python/free_rooms_action.py b/src/alpine_bits_python/free_rooms_action.py
index 472671f..ca7e5a2 100644
--- a/src/alpine_bits_python/free_rooms_action.py
+++ b/src/alpine_bits_python/free_rooms_action.py
@@ -242,6 +242,9 @@ class FreeRoomsAction(AlpineBitsAction):
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)
+ closing_season_ranges: list[tuple[date, date]] = []
+ # Track date ranges per room/category to detect overlaps
+ inventory_ranges: dict[tuple[str, str | None], list[tuple[date, date]]] = {}
for inventory in inventories:
sac = inventory.status_application_control
@@ -255,16 +258,17 @@ class FreeRoomsAction(AlpineBitsAction):
# Validate closing seasons
if is_closing:
- if inventory.inv_counts is not None:
- raise FreeRoomsProcessingError(
- "Closing seasons cannot contain InvCounts data",
- HttpStatusCode.BAD_REQUEST,
- )
+ # Closing seasons are only allowed in CompleteSet - fail fast
if update_type != "CompleteSet":
raise FreeRoomsProcessingError(
"Closing seasons are only allowed on CompleteSet updates",
HttpStatusCode.BAD_REQUEST,
)
+ if inventory.inv_counts is not None:
+ raise FreeRoomsProcessingError(
+ "Closing seasons cannot contain InvCounts data",
+ HttpStatusCode.BAD_REQUEST,
+ )
if enforce_closing_order and encountered_standard:
raise FreeRoomsProcessingError(
"Closing seasons must appear before other inventory entries",
@@ -275,8 +279,9 @@ class FreeRoomsAction(AlpineBitsAction):
"Closing season entries cannot specify InvTypeCode or InvCode",
HttpStatusCode.BAD_REQUEST,
)
- # Validate date range
- self._parse_date_range(sac.start, sac.end)
+ # Validate and store date range
+ start_date, end_date = self._parse_date_range(sac.start, sac.end)
+ closing_season_ranges.append((start_date, end_date))
continue
# Mark that we've seen a non-closing inventory entry
@@ -291,7 +296,32 @@ class FreeRoomsAction(AlpineBitsAction):
)
# Validate date range
- self._parse_date_range(sac.start, sac.end)
+ start_date, end_date = self._parse_date_range(sac.start, sac.end)
+
+ # Check for overlap with closing seasons
+ for closing_start, closing_end in closing_season_ranges:
+ if self._date_ranges_overlap(start_date, end_date, closing_start, closing_end):
+ raise FreeRoomsProcessingError(
+ f"Inventory entry ({start_date} to {end_date}) overlaps with closing season ({closing_start} to {closing_end})",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ # Check for overlap with other inventory entries for the same room/category
+ inv_code = sac.inv_code.strip() if sac.inv_code else None
+ inventory_key = (inv_type_code, inv_code)
+
+ if inventory_key in inventory_ranges:
+ for existing_start, existing_end in inventory_ranges[inventory_key]:
+ if self._date_ranges_overlap(start_date, end_date, existing_start, existing_end):
+ room_desc = f"room '{inv_code}'" if inv_code else f"category '{inv_type_code}'"
+ raise FreeRoomsProcessingError(
+ f"Overlapping date ranges for {room_desc}: ({start_date} to {end_date}) and ({existing_start} to {existing_end})",
+ HttpStatusCode.BAD_REQUEST,
+ )
+ else:
+ inventory_ranges[inventory_key] = []
+
+ inventory_ranges[inventory_key].append((start_date, end_date))
# Validate that we don't mix categories and individual rooms
has_inv_code = sac.inv_code is not None and sac.inv_code.strip() != ""
@@ -313,6 +343,15 @@ class FreeRoomsAction(AlpineBitsAction):
# Validate counts
self._extract_counts(inventory.inv_counts)
+ # Check for overlapping closing seasons
+ for i, (start1, end1) in enumerate(closing_season_ranges):
+ for start2, end2 in closing_season_ranges[i + 1:]:
+ if self._date_ranges_overlap(start1, end1, start2, end2):
+ raise FreeRoomsProcessingError(
+ f"Closing seasons overlap: ({start1} to {end1}) and ({start2} to {end2})",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
async def _process_complete_set(
self,
session: AsyncSession,
@@ -528,6 +567,15 @@ class FreeRoomsAction(AlpineBitsAction):
)
return start_date, end_date
+ def _date_ranges_overlap(
+ self, start1: date, end1: date, start2: date, end2: date
+ ) -> bool:
+ """Check if two date ranges overlap (inclusive).
+
+ Returns True if the ranges have any dates in common.
+ """
+ return start1 <= end2 and start2 <= end1
+
def _iter_days(self, start_date: date, end_date: date):
current = start_date
while current <= end_date:
diff --git a/tests/test_free_rooms_action.py b/tests/test_free_rooms_action.py
index 0a4d943..a1215b1 100644
--- a/tests/test_free_rooms_action.py
+++ b/tests/test_free_rooms_action.py
@@ -723,3 +723,339 @@ async def test_complete_set_preserves_inventory_from_other_sources(db_session: A
# Preserved HotelInventory source
assert inventory_after[1].source == "HotelInventory"
assert inventory_after[1].inv_type_code == "SGL"
+
+
+@pytest.mark.asyncio
+async def test_closing_season_overlapping_with_inventory_is_rejected(db_session: AsyncSession):
+ """Test that closing seasons cannot overlap with regular inventory entries."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Closing season from July 31 to Sept 30, with inventory from Aug 1-10 (overlaps!)
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "overlaps with closing season" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_overlapping_closing_seasons_are_rejected(db_session: AsyncSession):
+ """Test that multiple closing seasons cannot overlap with each other."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Two overlapping closing seasons
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "Closing seasons overlap" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_non_overlapping_closing_seasons_are_allowed(db_session: AsyncSession):
+ """Test that multiple non-overlapping closing seasons are allowed."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Two non-overlapping closing seasons
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.OK
+
+ # Verify closing seasons were created
+ rows = (
+ await db_session.execute(
+ select(RoomAvailability).where(RoomAvailability.is_closing_season.is_(True))
+ )
+ ).scalars().all()
+ # 15 days in July + 15 days in August = 30 closing season days
+ assert len(rows) == 30
+
+
+@pytest.mark.asyncio
+async def test_adjacent_closing_season_and_inventory_are_allowed(db_session: AsyncSession):
+ """Test that closing seasons and inventory can be adjacent (not overlapping) without error."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Closing season ends July 31, inventory starts Aug 1 (adjacent, not overlapping)
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ 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_overlapping_inventory_for_same_category_is_rejected(db_session: AsyncSession):
+ """Test that overlapping date ranges for the same room category are rejected."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Two overlapping date ranges for DOUBLE category
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "Overlapping date ranges for category 'DOUBLE'" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_overlapping_inventory_for_same_room_is_rejected(db_session: AsyncSession):
+ """Test that overlapping date ranges for the same individual room are rejected."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Two overlapping date ranges for room 101
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "Overlapping date ranges for room '101'" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_non_overlapping_inventory_for_same_category_is_allowed(db_session: AsyncSession):
+ """Test that non-overlapping date ranges for the same category are allowed."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Three non-overlapping date ranges for DOUBLE category
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.OK
+
+ # Verify all dates were created
+ rows = (
+ await db_session.execute(
+ select(RoomAvailability).order_by(RoomAvailability.date)
+ )
+ ).scalars().all()
+ assert len(rows) == 30 # Aug 1-30
+
+
+@pytest.mark.asyncio
+async def test_overlapping_inventory_for_different_categories_is_allowed(db_session: AsyncSession):
+ """Test that overlapping dates for different room categories are allowed."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Overlapping dates but for different categories (DOUBLE vs SINGLE)
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ 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_overlapping_inventory_for_different_rooms_is_allowed(db_session: AsyncSession):
+ """Test that overlapping dates for different individual rooms are allowed."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Overlapping dates but for different rooms (101 vs 102)
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.OK