Fix production ARR reads and add audited source field review
This commit is contained in:
1 parent
2417b1a41c
commit
fe8735aac2
26 files changed
+2417
-157
No files matched your search
@@ -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`、记录数、各字段状态数量、
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user