374 lines
18 KiB
Python
374 lines
18 KiB
Python
"""Strict field selectors for a future ARR adapter.
|
|
|
|
These functions select supported API values; they do not select report rows,
|
|
format names/companies, establish report order, or construct SourceRow/XML.
|
|
Missing values are errors, never fabricated blanks/zero or another field.
|
|
Explicit blank text is preserved for the processor. A local selector failure
|
|
is not a report exclusion or a new whole-batch acceptance rule.
|
|
The independent source comparator deliberately does not import this module.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
import re
|
|
|
|
from . import collect_arr_day as day_capture
|
|
from . import collect_arr_source as capture
|
|
from . import rate_info
|
|
|
|
|
|
VERSION = "arr-source-fields/v3"
|
|
require = capture.require
|
|
|
|
|
|
def _date(value) -> date:
|
|
require(type(value) is str and bool(re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value)),
|
|
"source_field_invalid_date")
|
|
try:
|
|
return date.fromisoformat(value)
|
|
except ValueError:
|
|
raise capture.CollectionError("source_field_invalid_date") from None
|
|
|
|
|
|
def _stay(detail: dict) -> dict:
|
|
require(type(detail) is dict and type(detail.get("roomStay")) is dict, "source_field_missing_stay")
|
|
return detail["roomStay"]
|
|
|
|
|
|
def confirmation_no(detail: dict) -> str:
|
|
"""Select exactly one explicitly typed Confirmation ID, preserving text."""
|
|
capture.reservation_id(detail) # A confirmation never replaces source identity.
|
|
identifiers = detail["reservationIdList"]
|
|
require(all(type(item.get("type")) is str and bool(item["type"].strip()) for item in identifiers),
|
|
"source_field_invalid_id_type")
|
|
matches = [item.get("id") for item in identifiers if item["type"] == "Confirmation"]
|
|
require(len(matches) == 1, "source_field_missing_or_ambiguous_confirmation")
|
|
require(type(matches[0]) is str, "source_field_invalid_confirmation")
|
|
return matches[0]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StayDates:
|
|
arrival: date
|
|
departure: date
|
|
|
|
|
|
def stay_dates(detail: dict, report_date: str) -> StayDates:
|
|
"""Use roomStay dates, never expected/actual times or originalTimeSpan."""
|
|
expected = _date(report_date)
|
|
stay = _stay(detail)
|
|
arrival, departure = _date(stay.get("arrivalDate")), _date(stay.get("departureDate"))
|
|
require(arrival == expected, "source_field_arrival_date_mismatch")
|
|
require(departure >= arrival, "source_field_negative_stay")
|
|
return StayDates(arrival, departure)
|
|
|
|
|
|
def single_day_rate_index(detail: dict, report_date: str) -> int:
|
|
"""Support only explicit one-day segments; do not guess range endpoints.
|
|
|
|
Validate every segment before selecting the unique target day. Even a
|
|
second identical segment is ambiguous; no summing or first-item choice.
|
|
"""
|
|
target = _date(report_date)
|
|
stay = _stay(detail)
|
|
require(_date(stay.get("arrivalDate")) == target, "source_field_arrival_date_mismatch")
|
|
segments = stay.get("roomRates")
|
|
require(type(segments) is list and bool(segments), "source_field_missing_rate_segments")
|
|
matches = []
|
|
for index, segment in enumerate(segments):
|
|
require(type(segment) is dict, "source_field_invalid_rate_segment")
|
|
start, end = _date(segment.get("start")), _date(segment.get("end"))
|
|
require(start == end, "source_field_unsupported_rate_interval")
|
|
if start == target:
|
|
matches.append(index)
|
|
require(len(matches) == 1, "source_field_missing_or_ambiguous_day_segment")
|
|
return matches[0]
|
|
|
|
|
|
def arrival_rate_code(detail: dict, report_date: str) -> str:
|
|
"""Return the exact day segment's code, without normalizing/whitelisting."""
|
|
index = single_day_rate_index(detail, report_date)
|
|
value = detail["roomStay"]["roomRates"][index].get("ratePlanCode")
|
|
require(type(value) is str, "source_field_missing_or_invalid_rate_code")
|
|
return value
|
|
|
|
|
|
def agreed_room_number(search: dict, detail: dict, report_date: str) -> str:
|
|
"""Exact search/current/arrival-day agreement, not a historical-room policy.
|
|
|
|
Bind both records to the same reservation, hotel and explicit arrival day.
|
|
Preserve blank/leading-zero/text values; missing room fields cannot become
|
|
an empty or unassigned room. Do not take current-room fallback, suggested
|
|
rooms, an integer conversion or another day's room when a layer is absent.
|
|
"""
|
|
target = _date(report_date)
|
|
require(capture.reservation_id(search) == capture.reservation_id(detail),
|
|
"source_field_room_reservation_mismatch")
|
|
require(type(detail.get("hotelId")) is str and bool(detail["hotelId"].strip())
|
|
and search.get("hotelId") == detail["hotelId"], "source_field_room_hotel_mismatch")
|
|
search_stay = _stay(search)
|
|
require(_date(search_stay.get("arrivalDate")) == target, "source_field_arrival_date_mismatch")
|
|
index = single_day_rate_index(detail, report_date)
|
|
stay = _stay(detail)
|
|
current = stay.get("currentRoomInfo")
|
|
require(type(current) is dict, "source_field_missing_or_invalid_current_room")
|
|
values = (search_stay.get("roomId"), current.get("roomId"), stay["roomRates"][index].get("roomId"))
|
|
require(all(type(value) is str for value in values), "source_field_missing_or_invalid_room_number")
|
|
require(values[0] == values[1] == values[2], "source_field_room_number_disagreement")
|
|
return values[0]
|
|
|
|
|
|
def agreed_room_type(search: dict, detail: dict, report_date: str) -> str:
|
|
"""Select the room-type code agreed by search/current/arrival-day sources.
|
|
|
|
ROOM_CATEGORY_LABEL is Oracle's room-type field, not the room description
|
|
or the roomTypeCharged pricing category. This supported subset requires
|
|
all three explicit codes to agree; it does not choose a historical value
|
|
when types changed during the stay. No metadata fetch or name conversion.
|
|
"""
|
|
target = _date(report_date)
|
|
require(capture.reservation_id(search) == capture.reservation_id(detail),
|
|
"source_field_room_type_reservation_mismatch")
|
|
require(type(detail.get("hotelId")) is str and bool(detail["hotelId"].strip())
|
|
and search.get("hotelId") == detail["hotelId"], "source_field_room_type_hotel_mismatch")
|
|
search_stay = _stay(search)
|
|
require(_date(search_stay.get("arrivalDate")) == target, "source_field_arrival_date_mismatch")
|
|
index = single_day_rate_index(detail, report_date)
|
|
stay = _stay(detail)
|
|
current = stay.get("currentRoomInfo")
|
|
require(type(current) is dict, "source_field_missing_or_invalid_current_room")
|
|
values = (search_stay.get("roomType"), current.get("roomType"), stay["roomRates"][index].get("roomType"))
|
|
require(all(type(value) is str for value in values), "source_field_missing_or_invalid_room_type")
|
|
require(values[0] == values[1] == values[2], "source_field_room_type_disagreement")
|
|
return values[0]
|
|
|
|
|
|
def _block_identifiers(value, hotel_id: str) -> tuple[str, str]:
|
|
"""Read typed Block/BlockCode IDs, never the block's name or numeric ID.
|
|
|
|
The actual captured RSV documents carry BlockCode inside blockIdList.
|
|
Missing reservationBlock is not proof of an unblocked reservation. A
|
|
present hotel/context must agree; absent optional context is not invented.
|
|
"""
|
|
require(type(value) is dict, "source_field_missing_or_invalid_reservation_block")
|
|
require("hotelId" not in value or value["hotelId"] == hotel_id,
|
|
"source_field_block_hotel_mismatch")
|
|
identifiers = value.get("blockIdList")
|
|
require(type(identifiers) is list and all(type(item) is dict for item in identifiers),
|
|
"source_field_missing_or_invalid_block_ids")
|
|
require(all(type(item.get("type")) is str and bool(item["type"].strip()) for item in identifiers),
|
|
"source_field_invalid_block_id_type")
|
|
selected = []
|
|
for kind in ("Block", "BlockCode"):
|
|
matches = [item for item in identifiers if item["type"] == kind]
|
|
require(len(matches) == 1, "source_field_missing_or_ambiguous_block_identity")
|
|
item = matches[0]
|
|
require("idContext" not in item or item["idContext"] == "OPERA",
|
|
"source_field_unsupported_block_id_context")
|
|
identity = item.get("id")
|
|
require(type(identity) is str, "source_field_invalid_block_identity")
|
|
if kind == "Block":
|
|
require(bool(identity.strip()), "source_field_invalid_block_identity")
|
|
selected.append(identity)
|
|
return selected[0], selected[1]
|
|
|
|
|
|
def agreed_block_code(search: dict, detail: dict, report_date: str) -> str:
|
|
"""Select a raw typed BlockCode agreed by search and arrival-day detail.
|
|
|
|
Binding includes reservation, hotel, date AND the separate typed Block ID:
|
|
equal display codes on two different blocks cannot establish agreement.
|
|
Other dates and external IDs never supply a fallback. Explicit blank code
|
|
is preserved as an observation, not accepted as a blank report field.
|
|
This does not establish native report equivalence or missing-block rules.
|
|
"""
|
|
target = _date(report_date)
|
|
require(capture.reservation_id(search) == capture.reservation_id(detail),
|
|
"source_field_block_reservation_mismatch")
|
|
hotel_id = detail.get("hotelId")
|
|
require(type(hotel_id) is str and bool(hotel_id.strip()) and search.get("hotelId") == hotel_id,
|
|
"source_field_block_hotel_mismatch")
|
|
search_stay = _stay(search)
|
|
require(_date(search_stay.get("arrivalDate")) == target, "source_field_arrival_date_mismatch")
|
|
index = single_day_rate_index(detail, report_date)
|
|
left = _block_identifiers(search_stay.get("reservationBlock"), hotel_id)
|
|
right = _block_identifiers(detail["roomStay"]["roomRates"][index].get("reservationBlock"), hotel_id)
|
|
require(left == right, "source_field_block_identity_or_code_disagreement")
|
|
return left[1]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssociatedProfile:
|
|
role: str
|
|
profile_id: str = field(repr=False)
|
|
name: str = field(repr=False)
|
|
|
|
|
|
_ASSOCIATION_ROLES = {"Company", "TravelAgent", "Source", "Group"}
|
|
_PROFILE_ROLES = _ASSOCIATION_ROLES | {"Guest", "ReservationContact", "BillingContact", "Addressee"}
|
|
|
|
|
|
def _associated_profile(profiles, role: str) -> AssociatedProfile | None:
|
|
require(type(profiles) is list and all(type(item) is dict for item in profiles),
|
|
"source_field_missing_or_invalid_associated_profiles")
|
|
require(all(type(item.get("reservationProfileType")) is str
|
|
and item["reservationProfileType"] in _PROFILE_ROLES for item in profiles),
|
|
"source_field_missing_or_invalid_profile_role")
|
|
selected = [item for item in profiles if item["reservationProfileType"] == role]
|
|
require(len(selected) <= 1, "source_field_ambiguous_associated_profile")
|
|
if not selected:
|
|
return None # Role absent from an explicit collection, not an empty company name.
|
|
item = selected[0]
|
|
identifiers = item.get("profileIdList")
|
|
require(type(identifiers) is list and all(type(value) is dict for value in identifiers),
|
|
"source_field_invalid_profile_identity_list")
|
|
require(all(type(value.get("type")) is str and bool(value["type"].strip()) for value in identifiers),
|
|
"source_field_invalid_profile_identity_type")
|
|
internal = [value.get("id") for value in identifiers if value["type"] == "Profile"]
|
|
require(len(internal) == 1 and type(internal[0]) is str
|
|
and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", internal[0])),
|
|
"source_field_missing_or_ambiguous_profile_identity")
|
|
profile = item.get("profile")
|
|
company = profile.get("company") if type(profile) is dict else None
|
|
require(type(company) is dict and type(company.get("companyName")) is str,
|
|
"source_field_missing_or_invalid_profile_name")
|
|
return AssociatedProfile(role, internal[0], company["companyName"])
|
|
|
|
|
|
def agreed_associated_profile(detail: dict, report_date: str, role: str) -> AssociatedProfile | None:
|
|
"""Compare one explicit role's internal ID and raw name across both levels.
|
|
|
|
Caller must name the role; this does not choose Company/TravelAgent/Source
|
|
priority, turn Group into company, prepend a display prefix or combine names.
|
|
Ignore unrelated external IDs/extra metadata, never internal identity.
|
|
None means role absent from BOTH explicit collections. Missing collections
|
|
fail locally; None is not an accepted blank report COMPANY_NAME mapping.
|
|
"""
|
|
require(type(role) is str and role in _ASSOCIATION_ROLES, "source_field_unsupported_association_role")
|
|
index = single_day_rate_index(detail, report_date)
|
|
container = detail.get("reservationProfiles")
|
|
require(type(container) is dict, "source_field_missing_or_invalid_reservation_profiles")
|
|
reservation = _associated_profile(container.get("reservationProfile"), role)
|
|
arrival = _associated_profile(detail["roomStay"]["roomRates"][index].get("stayProfiles"), role)
|
|
require(reservation == arrival, "source_field_associated_profile_disagreement")
|
|
return reservation
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GuestCounts:
|
|
adults: int
|
|
children: int
|
|
|
|
|
|
def _nonnegative_integer(value) -> int:
|
|
require(type(value) is int and value >= 0, "source_field_missing_or_invalid_count")
|
|
return value
|
|
|
|
|
|
def _guest_counts(value) -> GuestCounts:
|
|
require(type(value) is dict, "source_field_missing_or_invalid_guest_counts")
|
|
return GuestCounts(_nonnegative_integer(value.get("adults")),
|
|
_nonnegative_integer(value.get("children")))
|
|
|
|
|
|
def agreed_guest_counts(detail: dict, report_date: str) -> GuestCounts:
|
|
"""Select explicit equal stay/day counts, without assuming report share rules.
|
|
|
|
Require both levels; never fill a missing level from the other, sum across
|
|
nights/guests, or infer children from childAges/childBuckets. Agreement is a
|
|
source observation, not proof of the report's shared/multi-room counting.
|
|
"""
|
|
index = single_day_rate_index(detail, report_date)
|
|
stay = _stay(detail)
|
|
stay_counts = _guest_counts(stay.get("guestCounts"))
|
|
day_counts = _guest_counts(stay["roomRates"][index].get("guestCounts"))
|
|
require(stay_counts == day_counts, "source_field_guest_count_disagreement")
|
|
return stay_counts
|
|
|
|
|
|
def arrival_number_of_units(detail: dict, report_date: str) -> int:
|
|
"""Raw arrival-day room units, NOT an accepted NO_OF_ROOMS mapping.
|
|
|
|
Keep explicit zero; never default to one, sum nightly segments, or infer
|
|
shared/non-shared status from missing share markers. The processor's room
|
|
count validation and the report's primary/component rules are separate.
|
|
"""
|
|
index = single_day_rate_index(detail, report_date)
|
|
return _nonnegative_integer(detail["roomStay"]["roomRates"][index].get("numberOfUnits"))
|
|
|
|
|
|
def reservation_gen_notes(detail: dict) -> tuple[str, ...]:
|
|
"""Select all exact GEN/RESERVATION texts in original API array order.
|
|
|
|
Include internal and external notes; confidential is an independent flag,
|
|
not a synonym for internal. Present flags must be booleans; absent flags
|
|
are not treated as false and do not filter the selected text. Missing or
|
|
malformed comments are not an empty report. The verified capture owns
|
|
fetch completeness and indicator-count checks. Preserve blanks, repeated
|
|
text and CR; do not sort by timestamps or choose the first nonempty note.
|
|
API array order is not yet established as native report note order.
|
|
"""
|
|
require(type(detail) is dict and type(detail.get("comments")) is list,
|
|
"source_field_missing_or_invalid_comments")
|
|
notes = []
|
|
for item in detail["comments"]:
|
|
require(type(item) is dict and type(item.get("comment")) is dict,
|
|
"source_field_invalid_comment")
|
|
comment = item["comment"]
|
|
require(all(type(comment.get(key)) is str and bool(comment[key].strip())
|
|
for key in ("type", "notificationLocation")),
|
|
"source_field_missing_or_invalid_comment_category")
|
|
if comment["type"] != "GEN" or comment["notificationLocation"] != "RESERVATION":
|
|
continue
|
|
require(all(key not in comment or type(comment[key]) is bool
|
|
for key in ("internal", "confidential")),
|
|
"source_field_invalid_comment_flag")
|
|
text = comment.get("text")
|
|
require(type(text) is dict and type(text.get("value")) is str,
|
|
"source_field_missing_or_invalid_comment_text")
|
|
notes.append(text["value"])
|
|
return tuple(notes)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EffectiveRate:
|
|
amount: Decimal
|
|
currency: str
|
|
|
|
|
|
def effective_rate(detail: dict, request: dict, response: dict, options: day_capture.Options) -> EffectiveRate:
|
|
"""Read raw day-rate bytes decoded with Decimal after archive verification.
|
|
|
|
Bind the recorded request to the reservation/hotel/three explicit dates;
|
|
check the complete response envelope and detail currency anew. Do not trust
|
|
an assessment's success flag or substitute search/base/total values. This
|
|
is a field check, not authentication of an archive or report equivalence.
|
|
"""
|
|
options.validate()
|
|
identity = capture.validate_row(detail, options.search_options())
|
|
method, path, _, body = rate_info.request(identity, options.rate_date)
|
|
require(type(request) is dict and request.get("operation_id") == rate_info.POST
|
|
and request.get("method") == method and request.get("path") == path,
|
|
"source_field_rate_request_mismatch")
|
|
# JSON object order is irrelevant; false vs 0 remains significant.
|
|
actual = request.get("body")
|
|
require(type(actual) is dict and set(actual) == set(body)
|
|
and all(type(actual[key]) is type(value) and actual[key] == value for key, value in body.items()),
|
|
"source_field_rate_request_mismatch")
|
|
require(type(response) is dict and response.get("operation_id") == rate_info.POST
|
|
and response.get("hotel_id") == options.hotel_id,
|
|
"source_field_rate_response_mismatch")
|
|
require(type(response.get("oracle_request_id")) is str and bool(response["oracle_request_id"].strip()),
|
|
"source_field_missing_oracle_request_id")
|
|
capture.check_warnings(response)
|
|
candidate = rate_info.day_rate_candidate(response.get("data"),
|
|
expected_currency=day_capture.detail_currency(detail))
|
|
return EffectiveRate(candidate.effective_rate, candidate.currency)
|
|
|
|
|
|
def traces() -> tuple[str, ...]:
|
|
"""Current user scope explicitly excludes Trace from the API report."""
|
|
return ()
|