216 lines
10 KiB
Python
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())
|