"""Offline cancellation scope checks for both daily report input paths.""" from __future__ import annotations import argparse import contextlib import copy import io import json from pathlib import Path import tempfile import unittest import xml.etree.ElementTree as ET from tests.test_arr_opera_daily_ingest import core, reservation, run_processor, xml_document from tests.test_arr_data_review import source_document def xml_row(sequence, statuses=(), *, blank_room=False, blank_rate=False, invalid_fields=False): node = ET.fromstring(reservation(sequence)) for tag, value in statuses: ET.SubElement(node, tag).text = value if blank_room: node.find("DISP_ROOM_NO").text = "" if blank_rate: node.find("RATE_CODE").text = "" if invalid_fields: for tag, value in (("COMPANY_NAME", ""), ("FULL_NAME", ""), ("ADULTS", "-1"), ("NO_OF_ROOMS", "0"), ("TRUNC_END", "2026-07-26")): node.find(tag).text = value return ET.tostring(node, encoding="unicode") class CancelledScopeTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="arr-cancelled-scope-") self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) def classify_xml(self, *rows, apply_scope=True): path = self.root / "source.xml" path.write_text(xml_document(*rows)) business_date, nodes = core.read_xml(path) return core.classify_input_records(nodes, business_date, apply_scope=apply_scope) def read_data(self, document): path = self.root / "source.json" path.write_text(json.dumps(document)) return core.read_data_source(path) def run_data(self, document, output_name="output"): path = self.root / "source.json" path.write_text(json.dumps(document)) output = self.root / output_name args = argparse.Namespace(xml=None, data_json=str(path), output_dir=str(output), result_json=str(output / "result.json"), structured_result_json=str(output / "structured-result.json")) before = path.read_bytes() with contextlib.redirect_stdout(io.StringIO()): result = core.process(args) self.assertEqual(path.read_bytes(), before) return result, json.loads((output / "structured-result.json").read_text()) def test_xml_cancellation_precedes_rate_room_other_validation_and_keeps_lineage(self): all_rows, eligible, rate_removed, duplicates, errors = self.classify_xml( xml_row(1, (("RESV_STATUS", " CxL "),), blank_room=True, blank_rate=True, invalid_fields=True), reservation(2)) self.assertEqual(errors, []) self.assertEqual((rate_removed, duplicates), (0, 0)) self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [2]) self.assertEqual(all_rows[0]["_OUTCOME"], "excluded_cancelled") self.assertEqual(all_rows[0]["_DECISION_CODES"], ["RESERVATION_CANCELLED_EXCLUDED"]) self.assertEqual(all_rows[0]["CONFIRMATION_NO"], "SYNTHETIC-CONF-1") self.assertEqual(all_rows[0]["_SOURCE_LOCATION"], "reservation[1]") def test_cancelled_with_a_room_never_occupies_duplicate_key(self): rows = [ET.fromstring(reservation(index, room="SAME-ROOM")) for index in (1, 2, 3)] ET.SubElement(rows[0], "SHORT_RESV_STATUS").text = "CANCELLED" all_rows, eligible, _, duplicates, errors = self.classify_xml( *(ET.tostring(node, encoding="unicode") for node in rows)) self.assertEqual(errors, []) self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [2]) self.assertEqual([row["_OUTCOME"] for row in all_rows], ["excluded_cancelled", "pending", "duplicate"]) self.assertEqual(all_rows[2]["_DUPLICATE_OF_SOURCE_SEQUENCE"], 2) self.assertEqual(duplicates, 1) def test_only_exact_cancellation_values_in_explicit_status_fields_are_excluded(self): for field in ("RESV_STATUS", "RESERVATION_STATUS", "SHORT_RESV_STATUS"): for status in ("CXL", " cancelled ", "CaNcElEd"): with self.subTest(field=field, status=status): rows, _, _, _, errors = self.classify_xml( xml_row(1, ((field, status),), blank_room=True), reservation(2)) self.assertEqual(errors, []) self.assertTrue(core.is_cancelled_record(rows[0])) for status in ("CA", "CD", "GC", "TA", "NoShow", "CKOT", "CancelledByGuest", ""): with self.subTest(status=status): rows, _, _, _, errors = self.classify_xml( xml_row(1, (("SHORT_RESV_STATUS", status),), blank_room=True), reservation(2)) self.assertFalse(core.is_cancelled_record(rows[0])) self.assertIn("XML_ROOM_MISSING", {error.code for error in errors}) for field in ("RESERVATION_TYPE", "RESV_TYPE", "BOOKING_TYPE"): with self.subTest(type_field=field): rows, _, _, _, errors = self.classify_xml( xml_row(1, ((field, "CANCELLED"),), blank_room=True), reservation(2)) self.assertFalse(core.is_cancelled_record(rows[0])) self.assertIn("XML_ROOM_MISSING", {error.code for error in errors}) def test_conflicting_xml_status_is_retained_and_cancel_aliases_agree(self): for fields in ((("RESV_STATUS", "CXL"), ("SHORT_RESV_STATUS", "CKIN")), (("RESERVATION_STATUS", "CANCELLED"), ("SHORT_RESV_STATUS", "CA")), (("RESV_STATUS", "CXL"), ("RESV_STATUS", "RESERVED"))): with self.subTest(fields=fields): rows, _, _, _, errors = self.classify_xml(xml_row(1, fields, blank_room=True), reservation(2)) self.assertTrue(rows[0]["_RESERVATION_STATUS_CONFLICT"]) self.assertFalse(core.is_cancelled_record(rows[0])) self.assertIn("XML_ROOM_MISSING", {error.code for error in errors}) rows, _, _, _, errors = self.classify_xml(xml_row(1, (("RESV_STATUS", "CXL"), ("SHORT_RESV_STATUS", "CANCELED")), blank_room=True), reservation(2)) self.assertEqual(errors, []) self.assertFalse(rows[0]["_RESERVATION_STATUS_CONFLICT"]) self.assertTrue(core.is_cancelled_record(rows[0])) def test_data_cancellation_is_shared_and_unknown_or_absent_status_keeps_validation(self): for status in ("Cancelled", " CANCELED ", "cxl"): with self.subTest(status=status): document = source_document(2) document["records"][0]["reservation_status"] = status for field in document["records"][0]["fields"]: document["records"][0]["fields"][field] = {"state": "missing", "value": None} business_date, rows = self.read_data(document) all_rows, _, rate_removed, _, errors = core.classify_input_records(rows, business_date) self.assertEqual(errors, []) self.assertEqual(rate_removed, 0) self.assertEqual(all_rows[0]["_OUTCOME"], "excluded_cancelled") for status in (None, "NoShow", "InHouse", "CA"): with self.subTest(status=status): document = source_document(2) if status is not None: document["records"][0]["reservation_status"] = status document["records"][0]["fields"]["DISP_ROOM_NO"] = {"state": "missing", "value": None} business_date, rows = self.read_data(document) _, _, _, _, errors = core.classify_input_records(rows, business_date) self.assertIn("DATA_ROOM_MISSING", {error.code for error in errors}) def test_data_status_rejects_invalid_types_and_empty_strings(self): for value in (None, True, 1, [], {}, "", " "): with self.subTest(value=value): document = source_document() document["records"][0]["reservation_status"] = value with self.assertRaises(core.ProcessingFailure) as raised: self.read_data(document) self.assertEqual(raised.exception.errors[0].code, "INPUT_DATA_INVALID") def test_xml_success_and_independent_validation_audit_cancellation_separately(self): result, source, _, _, payload = run_processor(xml_document( xml_row(1, (("SHORT_RESV_STATUS", "CXL"),), blank_room=True, blank_rate=True), reservation(2)), self.root) self.assertEqual(result, 0, payload["errors"]) self.assertEqual((payload["source_rows"], payload["removed_by_rate_code"], payload["output_rows"]), (2, 0, 1)) self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1) self.assertEqual(set(payload["outcome_counts"]), core.FINAL_OUTCOMES) self.assertEqual(payload["processor_version"], "4.4.0") self.assertEqual([row["outcome"] for row in payload["records"]], ["excluded_cancelled", "retained"]) self.assertEqual(payload["records"][0]["decision_codes"], ["RESERVATION_CANCELLED_EXCLUDED"]) self.assertEqual(payload["records"][0]["confirmation_no"], "SYNTHETIC-CONF-1") self.assertEqual(payload["artifacts"]["source_xml"]["sha256"], core.sha256_file(source)) for field, value in (("decision_codes", ["RATE_CODE_NOT_WHITELISTED"]), ("real_price", 900), ("channel_key", "QBD")): with self.subTest(tampered=field): changed = copy.deepcopy(payload) changed["records"][0][field] = value with self.assertRaises(core.ProcessingFailure): core.validate_structured_completeness(changed) for name in ("structured-result.schema.json", "data-structured-result.schema.json"): schema = json.loads((core.SKILL_ROOT / "references" / name).read_text()) self.assertIn("excluded_cancelled", schema["properties"]["outcome_counts"]["required"]) self.assertIn("excluded_cancelled", schema["$defs"]["record"]["properties"]["outcome"]["enum"]) def test_data_success_review_and_failure_keep_cancelled_rows_outside_business_rules(self): baseline = source_document(2) baseline["records"][0]["reservation_status"] = "Cancelled" for field in baseline["records"][0]["fields"]: baseline["records"][0]["fields"][field] = {"state": "missing", "value": None} result, payload = self.run_data(baseline) self.assertEqual(result, 0, payload["errors"]) self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1) self.assertEqual(payload["removed_by_rate_code"], 0) self.assertEqual(payload["output_rows"], 1) review = copy.deepcopy(baseline) review["records"][1]["fields"]["EFFECTIVE_RATE_AMOUNT"]["value"] = "8765" result, payload = self.run_data(review, "review") self.assertEqual(result, 0, payload["errors"]) self.assertEqual(payload["status"], "review_required", payload["errors"]) self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1) self.assertEqual(payload["outcome_counts"]["price_unmatched"], 1) failed = copy.deepcopy(baseline) failed["records"][1]["fields"]["DISP_ROOM_NO"] = {"state": "missing", "value": None} result, payload = self.run_data(failed, "failure") self.assertNotEqual(result, 0) self.assertEqual(payload["status"], "failed") self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1) self.assertEqual(payload["outcome_counts"]["validation_failed"], 1) def test_legacy_v3_replay_retains_original_scope_and_original_outcome_contract(self): rows = (xml_row(1, (("SHORT_RESV_STATUS", "CXL"),)), reservation(2)) result, _, _, _, payload = run_processor(xml_document(*rows), self.root, legacy_v3_output=True) self.assertEqual(result, 0, payload["errors"]) self.assertEqual(payload["result_schema_version"], "3.0") self.assertEqual(payload["output_rows"], 2) self.assertEqual(set(payload["outcome_counts"]), core.LEGACY_DIRECT_FINAL_OUTCOMES) self.assertEqual([row["outcome"] for row in payload["records"]], ["retained", "retained"]) if __name__ == "__main__": unittest.main()