feat: add date-based OHIP data reporting alongside XML
This commit is contained in:
1 parent
5ecd571e3b
commit
ad2c59256f
259 files changed
+37326
-126
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""Single-profile, read-only summary probe; not an accepted ARR name mapping.
|
||||
|
||||
The caller owns the authorized sample budget. This module performs exactly one
|
||||
HTTP attempt, with no broad search, pagination, retry or profile-detail fallback.
|
||||
Raw names/IDs and the returned assessment are private data, not log messages.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
from . import collect_arr_source as source
|
||||
|
||||
|
||||
OPERATION = "searchProfiles"
|
||||
PATH = "/api/v1/profiles/searches"
|
||||
VERSION = "arr-profile-summary-probe/v1"
|
||||
COMPONENTS = {"surname": "name", "givenName": "givenName", "middleName": "middleName",
|
||||
"nameTitle": "nameTitle"}
|
||||
require = source.require
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrimaryGuest:
|
||||
profile_id: str = field(repr=False)
|
||||
name: dict = field(repr=False)
|
||||
|
||||
|
||||
def _profile_id(identifiers):
|
||||
require(type(identifiers) is list and all(type(item) is dict for item in identifiers),
|
||||
"profile_invalid_identity_list")
|
||||
require(all(type(item.get("type")) is str for item in identifiers), "profile_invalid_identity_type")
|
||||
matches = [item.get("id") for item in identifiers if item["type"] == "Profile"]
|
||||
require(len(matches) == 1 and type(matches[0]) is str
|
||||
and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", matches[0])), "profile_ambiguous_identity")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def primary_guest(detail: dict, *, include_prefix: bool = False) -> PrimaryGuest:
|
||||
source.reservation_id(detail)
|
||||
guests = detail.get("reservationGuests")
|
||||
require("reservationGuests" in detail, "profile_missing_guest_list")
|
||||
require(type(guests) is list and all(type(item) is dict for item in guests), "profile_invalid_guest_list")
|
||||
require(all("primary" not in guest or type(guest["primary"]) is bool for guest in guests),
|
||||
"profile_invalid_primary_flag")
|
||||
primary = [guest for guest in guests if guest.get("primary") is True]
|
||||
require(len(primary) == 1, "profile_ambiguous_primary_guest")
|
||||
info = primary[0].get("profileInfo")
|
||||
require("profileInfo" in primary[0], "profile_missing_primary_info")
|
||||
require(type(info) is dict, "profile_invalid_primary_info")
|
||||
identity = _profile_id(info.get("profileIdList"))
|
||||
profile = info.get("profile")
|
||||
require("profile" in info, "profile_missing_person_names")
|
||||
require(type(profile) is dict, "profile_invalid_primary_profile")
|
||||
require("customer" in profile, "profile_missing_person_names")
|
||||
customer = profile["customer"]
|
||||
require(type(customer) is dict, "profile_invalid_primary_customer")
|
||||
require("personName" in customer, "profile_missing_person_names")
|
||||
names = customer["personName"]
|
||||
require(type(names) is list and all(type(item) is dict for item in names), "profile_invalid_person_names")
|
||||
require(all("nameType" not in name or type(name["nameType"]) is str for name in names),
|
||||
"profile_invalid_primary_name_type")
|
||||
selected = [name for name in names if name.get("nameType") == "Primary"]
|
||||
require(len(selected) == 1, "profile_ambiguous_primary_name")
|
||||
name = selected[0]
|
||||
components = {**COMPONENTS, **({"namePrefix": "namePrefix"} if include_prefix else {})}
|
||||
require(all(key not in name or type(name[key]) is str for key in components), "profile_invalid_name_component")
|
||||
return PrimaryGuest(identity, {key: name[key] for key in components if key in name})
|
||||
|
||||
|
||||
def request(profile_id: str) -> tuple[str, str, bytes]:
|
||||
_profile_id([{"type": "Profile", "id": profile_id}])
|
||||
return "POST", PATH, source.json_bytes({"profileIds": [profile_id], "summaryInfo": True, "limit": 1, "offset": 0})
|
||||
|
||||
|
||||
def summary_row(raw: bytes, profile_id: str, hotel_id: str) -> dict:
|
||||
"""Validate response identity/coverage independently of available name fields."""
|
||||
request(profile_id)
|
||||
require(type(raw) is bytes and len(raw) <= source.MAX_RESPONSE_BYTES, "profile_response_too_large")
|
||||
document = source.strict_json(raw)
|
||||
require(document.get("operation_id") == OPERATION and document.get("hotel_id") == hotel_id,
|
||||
"profile_response_context_mismatch")
|
||||
require(type(document.get("oracle_request_id")) is str and bool(document["oracle_request_id"].strip()),
|
||||
"profile_missing_oracle_request_id")
|
||||
source.check_warnings(document)
|
||||
data = document.get("data")
|
||||
summaries = data.get("profileSummaries") if type(data) is dict else None
|
||||
require(type(summaries) is dict, "profile_missing_summary")
|
||||
if "hasMore" in summaries:
|
||||
require(summaries["hasMore"] is False, "profile_unexpected_more_results")
|
||||
for field in ("limit", "count", "totalResults", "totalPages"):
|
||||
if field in summaries:
|
||||
require(type(summaries[field]) is int and summaries[field] == 1, "profile_inconsistent_pagination")
|
||||
if "offset" in summaries:
|
||||
# Oracle's schema describes an initial offset; the real single-row
|
||||
# samples return1. Both supported values are bounded to this one row;
|
||||
# no broader pagination convention is inferred from these samples.
|
||||
require(type(summaries["offset"]) is int and summaries["offset"] in (0, 1),
|
||||
"profile_inconsistent_pagination")
|
||||
rows = summaries.get("profileInfo")
|
||||
require(type(rows) is list and len(rows) == 1 and type(rows[0]) is dict, "profile_ambiguous_summary_count")
|
||||
row = rows[0]
|
||||
require(_profile_id(row.get("profileIdList")) == profile_id, "profile_identity_mismatch")
|
||||
if "profile" in row:
|
||||
require(type(row["profile"]) is dict, "profile_invalid_summary_profile")
|
||||
if "formerName" in row["profile"]:
|
||||
require(type(row["profile"]["formerName"]) is dict, "profile_invalid_former_name")
|
||||
return row
|
||||
|
||||
|
||||
def inspect_summary(raw: bytes, guest: PrimaryGuest, hotel_id: str, *, include_prefix: bool = False) -> dict:
|
||||
require(type(guest) is PrimaryGuest, "profile_primary_guest_required")
|
||||
row = summary_row(raw, guest.profile_id, hotel_id)
|
||||
profile = row.get("profile")
|
||||
require(type(profile) is dict, "profile_missing_summary_profile")
|
||||
name = profile.get("formerName")
|
||||
require(type(name) is dict, "profile_missing_former_name")
|
||||
components = {**COMPONENTS, **({"namePrefix": "namePrefix"} if include_prefix else {})}
|
||||
require(all(key not in name or type(name[key]) is str
|
||||
for key in (*components.values(), "fullName", "nameType")), "profile_invalid_summary_name")
|
||||
comparison = {}
|
||||
for detail_key, summary_key in components.items():
|
||||
if detail_key not in guest.name:
|
||||
comparison[detail_key] = "both_absent" if summary_key not in name else "summary_only"
|
||||
elif summary_key not in name:
|
||||
comparison[detail_key] = "summary_missing"
|
||||
else:
|
||||
comparison[detail_key] = "equal" if guest.name[detail_key] == name[summary_key] else "different"
|
||||
full_name = name.get("fullName")
|
||||
return {"version": VERSION, "identity_matched": True, "profile_id": guest.profile_id,
|
||||
"name_type": name.get("nameType"), "component_comparisons": comparison,
|
||||
"full_name_state": "missing" if full_name is None else "present" if full_name.strip() else "explicit_blank",
|
||||
"full_name": full_name, "alt_name_returned": "altName" in profile,
|
||||
"report_equivalence_verified": False, "finance_ready": False}
|
||||
|
||||
|
||||
def read_once(guest: PrimaryGuest, hotel_id: str, transport, archive: source.Archive,
|
||||
index: int, *, key: str = "") -> dict:
|
||||
"""One exact-ID read; capture the original response before interpreting it."""
|
||||
require(type(index) is int and 1 <= index <= 3, "profile_probe_index_out_of_bounds")
|
||||
source.Options("2000-01-01", hotel_id).validate()
|
||||
require(type(guest) is PrimaryGuest, "profile_primary_guest_required")
|
||||
method, path, payload = request(guest.profile_id)
|
||||
prefix = f"profile-{index:02d}"
|
||||
archive.write(prefix + ".request.json", {"operation_id": OPERATION, "method": method, "path": path,
|
||||
"hotel_id": hotel_id, "body": source.strict_json(payload), "started_at": source.utc_now()})
|
||||
try:
|
||||
status, headers, raw = transport(method, path, payload)
|
||||
except (OSError, TimeoutError, ConnectionError):
|
||||
archive.write(prefix + ".meta.json", {"error": "transport_failure", "ended_at": source.utc_now()})
|
||||
raise source.CollectionError("profile_transport_failure") from None
|
||||
require(not key or key.encode() not in raw, "secret_in_response")
|
||||
archive.write(prefix + ".response.bin", raw)
|
||||
headers = {key.lower(): value for key, value in headers.items()}
|
||||
archive.write(prefix + ".meta.json", {"http_status": status, "edge_request_id": headers.get("x-request-id"),
|
||||
"ended_at": source.utc_now(), "oversized": len(raw) > source.MAX_RESPONSE_BYTES})
|
||||
require(status == 200, "profile_http_failure")
|
||||
result = inspect_summary(raw, guest, hotel_id)
|
||||
archive.write(prefix + ".assessment.json", result)
|
||||
return result
|
||||
Reference in new issue
Block a user