Files
ARR-2.0-0918/integrations/ohip/source_fields.py

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 ()