Activated free rooms
This commit is contained in:
@@ -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 ###
|
||||||
627019
config/alpinebits.log
627019
config/alpinebits.log
File diff suppressed because one or more lines are too long
@@ -98,3 +98,43 @@ select sum(room.total_revenue::float), 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;
|
||||||
|
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user