"""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())