Fix production ARR reads and add audited source field review

This commit is contained in:
Wyndham ARR committed 2026-10-08 17:01:54 +08:00
1 parent 2417b1a41c
commit fe8735aac2
26 files changed
+2417 -157

No files matched your search

+19 -1
View File
@@ -58,7 +58,7 @@ result = source.fetch(report_date="2026-09-15", request_id="0123456789abcdef0123
| 当日预订列表 | `searchHotelReservations` | 分页查询,取完后再次核对 |
| 预订详情 | `getReservation` | 每笔预订;明确排除 Traces 请求 |
| 指定日有效价 | `searchRateInfo` | 每笔预订、用户所选日期 |
| 主客完整姓名 | `searchProfiles` | 已关联主客的精确档案编号,重复档案复用 |
| 主客完整姓名 | `getProfiles` | GET 精确档案编号,`summaryInfo=true&limit=1&offset=0`,重复档案复用 |
| 档案详情 | `getProfile` | 关联公司名称或主客姓名组成资料未嵌入预订时补查 |
| 团队代码 | `getBlock` | 已有关联团队编号,但未返回代码时补查 |
| 房号及换房资料 | `getRoomCalendar` | 预订资料不能确定房号时,分页查找同一笔预订 |
@@ -72,6 +72,24 @@ result = source.fetch(report_date="2026-09-15", request_id="0123456789abcdef0123
以及 `getBlock` 返回的 `blocks.blockInfo[].block`,另按 Oracle 固定版本接口说明核对,
没有从模拟服务反推实际接口结构。
2026-10-08 生产兼容修正:2026-10-07 到店日的只读捕获证明,带可选
`orderBy=ConfirmationNo/sortOrder=Asc` 的查询被拒绝;省略这两个参数后查询成功。
结构化数据入口只发送到店起止日期、`limit`、`offset`,保留每页原始顺序及
`source_sequence`,不自行重排。生产默认排序规则尚未获得证明;完整分页后仍逐项比较
首尾两次列表(含顺序),并验证预订身份、重复 ID、分页总数与末页完整性。
这能检出已观察到的批次变化,但不构成原子快照,也不证明未来分页中的默认顺序固定。
旧的候选捕获/探针入口保留原请求,未静默改变历史证据的含义。
同日生产核对中 `searchProfiles` POST 不可用,`getProfiles` GET 按已关联主客的唯一档案 ID
读取成功。结构化入口使用 GET 并严格核对返回的 `operation_id=getProfiles`、酒店、
Oracle 请求编号、唯一档案 ID、结果数量和姓名组成,不按名称搜索或拼接完整姓名。
这些生产捕获只用于核对请求兼容性,不代表完整日报已验收。
原生可选字段省略仍保留为待确认:`reservationBlock`、`reservationPackages`、
`reservationProfiles` 未返回时不自动视为空,套餐金额为零也不能证明没有套餐。
`roomCalendar={}` 不能证明所选日期没有房号,继续保留房号缺口;
Cancelled/NoShow 等状态不会在取数阶段导致预订被丢弃。
## 返回的数据
`result` 包含 `status`、`collection_complete`、`input_complete`、记录数、各字段状态数量、
+5 -4
View File
@@ -98,8 +98,9 @@ def _name(detail, reader):
target = next(g for g in working["reservationGuests"] if g.get("primary") is True)
target["profileInfo"]["profile"] = profile
guest = profiles.primary_guest(working, include_prefix=True)
summary = reader.query(profiles.OPERATION, identity=identity)
inspected = profiles.inspect_summary(summary.raw, guest, reader.hotel_id, include_prefix=True)
summary = reader.query(profiles.GET_OPERATION, identity=identity)
inspected = profiles.inspect_summary(summary.raw, guest, reader.hotel_id, include_prefix=True,
operation=profiles.GET_OPERATION)
require(inspected["name_type"] in (None, "Primary"), "profile_nonprimary_summary_name")
require(all(v in {"equal", "both_absent"} for v in inspected["component_comparisons"].values()),
"profile_name_components_mismatch")
@@ -348,7 +349,7 @@ def collect(reader):
"source_kind": reader.source_kind, "max_requests": reader.max_requests})
complete = False
try:
searches = base.search_day(reader, options)
searches = base.search_day(reader, options, server_sort=False)
search_refs = list(reader.used)
for index, search in enumerate(searches, 1):
before = len(reader.used)
@@ -392,7 +393,7 @@ def collect(reader):
row["related"]["room_history"] = occurrences
row["fields"]["DISP_ROOM_NO"] = observe(lambda: _history_room(occurrences, options.arrival_date))
row["sources"] = list(dict.fromkeys(row["sources"] + reader.used[before:]))
require(base.search_day(reader, options) == searches, "source_changed_during_collection")
require(base.search_day(reader, options, server_sort=False) == searches, "source_changed_during_collection")
complete = True
except base.CollectionError as error:
fatal = str(error)
+7 -4
View File
@@ -304,14 +304,17 @@ def validate_note_counts(search: dict, detail: dict) -> None:
require(integer(expected[0], "invalid_indicator_count") == len(values), "note_or_trace_count_mismatch")
def search_day(reader: Reader, options: Options) -> list[dict]:
def search_day(reader: Reader, options: Options, *, server_sort: bool = True) -> list[dict]:
require(type(server_sort) is bool, "invalid_search_sort_option")
records, seen = [], set()
expected_counts = None
for page_number in range(options.max_pages):
offset = page_number * options.page_size
data = reader.read(SEARCH, body={"arrivalStartDate": options.arrival_date,
"arrivalEndDate": options.arrival_date, "limit": options.page_size, "offset": offset,
"orderBy": ["ConfirmationNo"], "sortOrder": ["Asc"]})
body = {"arrivalStartDate": options.arrival_date, "arrivalEndDate": options.arrival_date,
"limit": options.page_size, "offset": offset}
if server_sort:
body.update(orderBy=["ConfirmationNo"], sortOrder=["Asc"])
data = reader.read(SEARCH, body=body)
page = data.get("reservations")
require(isinstance(page, dict), "invalid_search_page")
rows = page.get("reservationInfo")
+8 -8
View File
@@ -15,7 +15,7 @@ from . import profile_summary, rate_info
FETCH = tuple(value for value in base.FETCH if value != "Traces")
OPERATIONS = (base.SEARCH, base.DETAIL, rate_info.POST, profile_summary.OPERATION,
OPERATIONS = (base.SEARCH, base.DETAIL, rate_info.POST, profile_summary.GET_OPERATION,
"getProfile", "getBlock", "getRoomCalendar", "getPackage")
require = base.require
@@ -83,11 +83,12 @@ class DataReader:
day = self.options.arrival_date
if operation == base.SEARCH:
require(identity is None and params is None and type(body) is dict, "invalid_search_request")
require(set(body) == {"arrivalStartDate", "arrivalEndDate", "limit", "offset", "orderBy", "sortOrder"}
# Optional sorting is rejected by production. Retain the returned
# order and the full end-of-batch recheck rather than guessing a sort.
require(set(body) == {"arrivalStartDate", "arrivalEndDate", "limit", "offset"}
and body["arrivalStartDate"] == body["arrivalEndDate"] == day
and body["limit"] == self.options.page_size and type(body["limit"]) is int
and type(body["offset"]) is int and 0 <= body["offset"] < self.options.max_pages * self.options.page_size
and body["orderBy"] == ["ConfirmationNo"] and body["sortOrder"] == ["Asc"],
and type(body["offset"]) is int and 0 <= body["offset"] < self.options.max_pages * self.options.page_size,
"invalid_search_request")
return "POST", "/api/v1/reservations/searches", body
require(body is None, "unexpected_query_body")
@@ -123,9 +124,8 @@ class DataReader:
if operation == rate_info.POST:
method, path, _payload, value = rate_info.request(identity, day)
return method, path, value
if operation == profile_summary.OPERATION:
method, path, payload = profile_summary.request(identity)
return method, path, base.strict_json(payload)
if operation == profile_summary.GET_OPERATION:
return profile_summary.get_request(identity)
if operation == "getProfile":
return "GET", f"/api/v1/profiles/{identity}", None
return "GET", f"/api/v1/blocks/{identity}?fetchInstructions=Block", None
@@ -176,7 +176,7 @@ class DataReader:
# Reservation details/day prices are used once, and calendars once
# per page. Bound reusable related-object caching in both bytes and
# entries so a large day cannot retain every raw response in RAM.
if operation in (profile_summary.OPERATION, "getProfile", "getBlock", "getPackage"):
if operation in (profile_summary.GET_OPERATION, "getProfile", "getBlock", "getPackage"):
while self.cache and (len(self.cache) >= 128 or self.cache_bytes + len(raw) > 32 * 1024 * 1024):
_key, evicted = self.cache.popitem(last=False)
self.cache_bytes -= len(evicted.raw)
+17 -5
View File
@@ -8,12 +8,15 @@ from __future__ import annotations
from dataclasses import dataclass, field
import re
from urllib.parse import urlencode
from . import collect_arr_source as source
OPERATION = "searchProfiles"
PATH = "/api/v1/profiles/searches"
GET_OPERATION = "getProfiles"
GET_PATH = "/api/v1/profiles"
VERSION = "arr-profile-summary-probe/v1"
COMPONENTS = {"surname": "name", "givenName": "givenName", "middleName": "middleName",
"nameTitle": "nameTitle"}
@@ -73,12 +76,20 @@ def request(profile_id: str) -> tuple[str, str, bytes]:
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:
def get_request(profile_id: str) -> tuple[str, str, None]:
"""Exact-ID summary read supported by the production GET contract."""
_profile_id([{"type": "Profile", "id": profile_id}])
query = urlencode({"profileIds": profile_id, "summaryInfo": "true", "limit": 1, "offset": 0})
return "GET", GET_PATH + "?" + query, None
def summary_row(raw: bytes, profile_id: str, hotel_id: str, *, operation: str = OPERATION) -> dict:
"""Validate response identity/coverage independently of available name fields."""
request(profile_id)
require(operation in (OPERATION, GET_OPERATION), "profile_invalid_operation")
_profile_id([{"type": "Profile", "id": 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,
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")
@@ -108,9 +119,10 @@ def summary_row(raw: bytes, profile_id: str, hotel_id: str) -> dict:
return row
def inspect_summary(raw: bytes, guest: PrimaryGuest, hotel_id: str, *, include_prefix: bool = False) -> dict:
def inspect_summary(raw: bytes, guest: PrimaryGuest, hotel_id: str, *, include_prefix: bool = False,
operation: str = OPERATION) -> dict:
require(type(guest) is PrimaryGuest, "profile_primary_guest_required")
row = summary_row(raw, guest.profile_id, hotel_id)
row = summary_row(raw, guest.profile_id, hotel_id, operation=operation)
profile = row.get("profile")
require(type(profile) is dict, "profile_missing_summary_profile")
name = profile.get("formerName")