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

216 lines
10 KiB
Python

"""Versioned read-only ARR day capture: search, detail and reservation/day rate.
All dates come from the caller. This collects candidates, not an accepted report
or a Finance delivery. Existing v1 archives and batch jobs remain supported.
"""
from __future__ import annotations
import argparse
from collections import Counter
from dataclasses import dataclass
import json
from pathlib import Path
import re
from . import collect_arr_source as source
from . import rate_info
VERSION = "arr-api-capture/v2"
OPERATIONS = [source.SEARCH, source.DETAIL, rate_info.POST]
require = source.require
@dataclass(frozen=True)
class Options:
from_date: str
to_date: str
rate_date: str
hotel_id: str
page_size: int = 100
max_pages: int = 100
max_records: int = 10000
@property
def arrival_date(self) -> str:
return self.from_date
def search_options(self) -> source.Options:
return source.Options(self.from_date, self.hotel_id, self.page_size, self.max_pages, self.max_records)
def validate(self) -> None:
self.search_options().validate()
for day in (self.to_date, self.rate_date):
source.Options(day, self.hotel_id).validate()
require(self.from_date == self.to_date == self.rate_date, "report_dates_must_match")
def detail_currency(detail: dict) -> str:
"""Require an unambiguous currency from supplied base-rate segments.
This checks currency only. It never chooses a base amount, infers interval
boundaries or converts money. Missing or mixed codes require review.
"""
stay = detail.get("roomStay", {})
room_rates = stay.get("roomRates")
require(isinstance(room_rates, list) and bool(room_rates), "missing_detail_rate_currency")
codes = set()
for room_rate in room_rates:
require(isinstance(room_rate, dict), "invalid_detail_rate_currency")
rates = room_rate.get("rates")
values = rates.get("rate") if isinstance(rates, dict) else None
require(isinstance(values, list) and bool(values), "missing_detail_rate_currency")
for value in values:
base = value.get("base") if isinstance(value, dict) else None
code = base.get("currencyCode") if isinstance(base, dict) else None
require(isinstance(code, str) and bool(re.fullmatch(r"[A-Z]{3}", code)),
"missing_detail_rate_currency")
codes.add(code)
require(len(codes) == 1, "mixed_detail_rate_currencies")
return next(iter(codes))
def verified_detail(row: dict, data: dict, options: source.Options) -> dict:
reservations = data.get("reservations")
require(isinstance(reservations, dict), "invalid_detail_envelope")
details = reservations.get("reservation")
require(isinstance(details, list) and len(details) == 1, "ambiguous_detail_count")
detail = details[0]
require(source.validate_row(detail, options) == source.reservation_id(row), "detail_identity_mismatch")
for name in ("reservationStatus", "lastModifyDateTime"):
require(isinstance(row.get(name), str) and bool(row[name]) and detail.get(name) == row[name],
"search_detail_state_mismatch")
source.validate_note_counts(row, detail)
return detail
def collect(options: Options, archive: source.Archive, reader: source.Reader,
rates: rate_info.RateInfoReader) -> dict:
require(type(options) is Options, "invalid_day_options")
return _collect(options, archive, reader, rates)
def _collect(options, archive, reader, rates, *, profiles=None) -> dict:
"""Shared fixed v2/v3 protocol; the public entrypoints select the version."""
options.validate()
for client in (reader, rates):
require(client.archive is archive and client.hotel_id == options.hotel_id and client.request_count == 0,
"reader_context_mismatch")
operations = OPERATIONS
version = VERSION
if profiles is not None:
from . import profile_summary, profile_reader
require(profiles.archive is archive and profiles.hotel_id == options.hotel_id
and profiles.request_count == 0 and profiles.unique_profiles == 0
and profiles.max_profiles == options.max_profiles, "profile_reader_context_mismatch")
operations = [*OPERATIONS, profile_summary.OPERATION]
version = "arr-api-capture/v3"
result = {"version": version, "status": "failed", "candidate_capture_complete": False,
"report_equivalence_verified": False, "finance_ready": False, "atomic_snapshot": False,
"hotel_id": options.hotel_id, "arrival_date": options.from_date,
"from_date": options.from_date, "to_date": options.to_date, "rate_date": options.rate_date,
"search_records": 0, "verified_details": 0, "rate_responses": 0,
"valid_rate_candidates": 0, "explicit_zero_rates": 0, "all_rates_valid": False,
"search_recheck_equal": False}
if profiles is not None:
result.update(profile_records=0, unique_profiles=0, valid_name_candidates=0,
all_names_valid=False, profile_http_attempts=0, profile_issues={})
archive.write("capture.json", {**result, "status": "started", "started_at": source.utc_now(),
"service_url": source.SERVICE, "application_id": source.APPLICATION, "options": vars(options),
"operations": operations, "fetch_instructions": source.FETCH})
issues, currencies, assessments = Counter(), Counter(), []
profile_issues = Counter()
try:
search_options = options.search_options()
rows = source.search_day(reader, search_options)
result["search_records"] = len(rows)
for row in rows:
identity = source.reservation_id(row)
detail = verified_detail(row, reader.read(source.DETAIL, identity=identity), search_options)
result["verified_details"] += 1
# The response has no reservation/date echo; provenance is the exact
# recorded request bound to the already-verified detail identity.
data = rates.read(identity, options.rate_date)
result["rate_responses"] += 1
assessment = {"reservation_id": identity, "rate_request": f"rate-{rates.request_count:06d}.json"}
try:
candidate = rate_info.day_rate_candidate(data)
require(candidate.currency == detail_currency(detail), "rate_currency_mismatch")
except source.CollectionError as error:
issues[str(error)] += 1
assessment["error"] = str(error)
else:
result["valid_rate_candidates"] += 1
result["explicit_zero_rates"] += int(candidate.effective_rate == 0)
currencies[candidate.currency] += 1
assessment.update(effective_rate=str(candidate.effective_rate), currency=candidate.currency)
assessments.append(assessment)
if profiles is not None:
profile = profile_reader.assess(detail, profiles)
assessment["profile"] = profile
result["profile_records"] += int("profile_request" in profile)
if "error" in profile:
profile_issues[profile["error"]] += 1
else:
result["valid_name_candidates"] += 1
require(source.search_day(reader, search_options) == rows, "source_changed_during_collection")
result.update(status="complete_candidate_capture", candidate_capture_complete=True,
search_recheck_equal=True, all_rates_valid=not issues)
if profiles is not None:
result["all_names_valid"] = not profile_issues
except source.CollectionError as error:
result["error"] = str(error)
except Exception:
result["error"] = "unexpected_collection_failure"
finally:
try:
archive.write("rate-assessments.json", {"records": assessments})
except source.CollectionError as error:
# A bounded footer refusal must still leave a failed manifest; never
# replace the real assessments with an empty successful collection.
result.update(status="failed", candidate_capture_complete=False,
all_rates_valid=False, error=str(error))
if profiles is not None:
result["all_names_valid"] = False
result.update(completed_at=source.utc_now(), source_http_attempts=reader.request_count,
rate_http_attempts=rates.request_count, http_attempts=reader.request_count + rates.request_count,
rate_issues=dict(sorted(issues.items())), currencies=dict(sorted(currencies.items())))
if profiles is not None:
result.update(unique_profiles=profiles.unique_profiles, profile_http_attempts=profiles.request_count,
profile_issues=dict(sorted(profile_issues.items())))
result["http_attempts"] += profiles.request_count
archive.write("result.json", {**result, "files": list(archive.files)})
result["manifest_sha256"] = archive.files[-1]["sha256"]
return result
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--from-date", required=True)
parser.add_argument("--to-date", required=True)
parser.add_argument("--rate-date", required=True)
parser.add_argument("--hotel-id", required=True)
parser.add_argument("--credential-file", required=True, type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
args = parser.parse_args(argv)
try:
options = Options(args.from_date, args.to_date, args.rate_date, args.hotel_id)
options.validate()
repository = Path(__file__).resolve().parents[2]
require(not args.output_dir.resolve().is_relative_to(repository), "output_must_be_outside_repository")
key = source.load_key(args.credential_file)
archive = source.Archive(args.output_dir)
transport = source.HTTPTransport(key)
result = collect(options, archive, source.Reader(archive, options.hotel_id, transport, key=key),
rate_info.RateInfoReader(archive, options.hotel_id, transport, key=key))
except source.CollectionError as error:
result = {"status": "failed", "error": str(error), "finance_ready": False}
except Exception:
result = {"status": "failed", "error": "local_setup_or_archive_failure", "finance_ready": False}
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("candidate_capture_complete") else 1
if __name__ == "__main__":
raise SystemExit(main())