"""Offline PM scope checks for both daily inputs and the frozen legacy replay.""" 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, validator, xml_document, ) from tests.test_arr_data_review import source_document def pm_xml_row(sequence, *, label="PM", room=None, status=None, incomplete=False, rate_amount="900"): node = ET.fromstring(reservation(sequence, room=room, rate_amount=rate_amount)) node.find("ROOM_CATEGORY_LABEL").text = label if status is not None: ET.SubElement(node, "SHORT_RESV_STATUS").text = status if incomplete: for field, value in (("RATE_CODE", ""), ("DISP_ROOM_NO", ""), ("COMPANY_NAME", ""), ("FULL_NAME", ""), ("ADULTS", "-1"), ("NO_OF_ROOMS", "0"), ("EFFECTIVE_RATE_AMOUNT", "INVALID"), ("TRUNC_END", "2026-07-26")): node.find(field).text = value return ET.tostring(node, encoding="unicode") class PMScopeTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="arr-pm-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 run_data(self, document, name="output"): path = self.root / (name + ".json") path.write_text(json.dumps(document)) output = self.root / 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()): exit_code = core.process(args) self.assertEqual(path.read_bytes(), before) return exit_code, json.loads((output / "structured-result.json").read_text()) def test_xml_pm_precedes_rate_and_required_fields_but_cancelled_takes_precedence(self): rows, eligible, removed_rate, duplicates, errors = self.classify_xml( pm_xml_row(1, label=" pm ", incomplete=True), pm_xml_row(2, status="CXL", incomplete=True), reservation(3)) self.assertEqual(errors, []) self.assertEqual((removed_rate, duplicates), (0, 0)) self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [3]) self.assertEqual([row["_OUTCOME"] for row in rows], ["excluded_pm", "excluded_cancelled", "pending"]) self.assertEqual(rows[0]["_DECISION_CODES"], ["ROOM_TYPE_PM_EXCLUDED"]) self.assertEqual(rows[1]["_DECISION_CODES"], ["RESERVATION_CANCELLED_EXCLUDED"]) self.assertEqual(rows[0]["ROOM_CATEGORY_LABEL"], "pm") self.assertEqual(rows[0]["CONFIRMATION_NO"], "SYNTHETIC-CONF-1") self.assertEqual(rows[0]["_SOURCE_LOCATION"], "reservation[1]") def test_only_exact_explicit_pm_room_type_is_excluded(self): for label in ("PM", "pm", " pM ", "\tPm\n"): with self.subTest(label=label): rows, _, _, _, errors = self.classify_xml( pm_xml_row(1, label=label, incomplete=True), reservation(2)) self.assertEqual(errors, []) self.assertTrue(core.is_pm_record(rows[0])) for label in (None, "", "PM1", "PM ROOM", "P M", "SU6"): with self.subTest(label=label): rows, _, _, _, errors = self.classify_xml( pm_xml_row(1, label=label, room="PM", rate_amount="0"), reservation(2)) self.assertEqual(errors, []) self.assertFalse(core.is_pm_record(rows[0])) self.assertEqual(rows[0]["_OUTCOME"], "pending") def test_pm_does_not_consume_a_real_room_duplicate_key_or_create_price_issue(self): rows, eligible, _, duplicates, errors = self.classify_xml( pm_xml_row(1, room="SAME-ROOM", rate_amount="0"), reservation(2, room="SAME-ROOM"), reservation(3, room="SAME-ROOM")) self.assertEqual(errors, []) self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [2]) self.assertEqual(duplicates, 1) self.assertEqual([row["_OUTCOME"] for row in rows], ["excluded_pm", "pending", "duplicate"]) self.assertEqual(rows[2]["_DUPLICATE_OF_SOURCE_SEQUENCE"], 2) prices = core.load_price_map(core.PRICE_REFERENCE) self.assertEqual(core.apply_prices_classified(eligible, prices), []) self.assertEqual(core.review_issues(eligible, prices), []) def test_xml_success_and_independent_replay_count_pm_and_cancelled_separately(self): code, source, output, _, payload = run_processor(xml_document( pm_xml_row(1, label=" pm ", incomplete=True), pm_xml_row(2, status="CXL", incomplete=True), reservation(3)), self.root) self.assertEqual(code, 0, payload["errors"]) self.assertEqual(payload["processor_version"], "4.4.0") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1) self.assertEqual(sum(payload["outcome_counts"].values()), 3) self.assertEqual((payload["source_rows"], payload["output_rows"]), (3, 1)) self.assertEqual(payload["artifacts"]["source_xml"]["sha256"], core.sha256_file(source)) self.assertEqual(payload["records"][0]["room_category_label"], "pm") for field in ("real_price", "total_price", "kb_amount", "channel_key", "pricing_method"): self.assertIsNone(payload["records"][0][field]) # A structurally valid cancellation reclassification cannot replace PM evidence. forged = copy.deepcopy(payload) forged["records"][0]["outcome"] = "excluded_cancelled" forged["records"][0]["decision_codes"] = ["RESERVATION_CANCELLED_EXCLUDED"] forged["outcome_counts"]["excluded_cancelled"] += 1 forged["outcome_counts"]["excluded_pm"] -= 1 core.validate_structured_completeness(forged) structured = output / "structured-result.json" structured.write_text(json.dumps(forged)) errors = validator.validate(argparse.Namespace(xml=str(source), daily=str(next(output.glob("*.xlsx"))), result_json=str(output / "result.json"), structured_result_json=str(structured), price_reference=str(core.PRICE_REFERENCE))) self.assertIn("OUTPUT_STRUCTURED_OUTCOME_MISMATCH", {error.code for error in errors}) self.assertIn("OUTPUT_STRUCTURED_RECORD_MISMATCH", {error.code for error in errors}) def test_pm_self_validation_and_schemas_require_explicit_type_reason_and_null_facts(self): code, _, _, _, payload = run_processor(xml_document( pm_xml_row(1, label="pM"), reservation(2)), self.root) self.assertEqual(code, 0, payload["errors"]) for field, value in (("room_category_label", "SU6"), ("decision_codes", ["RATE_CODE_NOT_WHITELISTED"]), ("real_price", 0), ("total_price", 0), ("kb_amount", 0), ("channel_key", "QBD"), ("pricing_method", "manual_review")): with self.subTest(field=field): forged = copy.deepcopy(payload) forged["records"][0][field] = value with self.assertRaises(core.ProcessingFailure): core.validate_structured_completeness(forged) forged = copy.deepcopy(payload) del forged["outcome_counts"]["excluded_pm"] with self.assertRaises(core.ProcessingFailure): core.validate_structured_completeness(forged) for name in ("structured-result.schema.json", "data-structured-result.schema.json"): schema = json.loads((core.SKILL_ROOT / "references" / name).read_text()) self.assertEqual(schema["properties"]["processor_version"]["const"], "4.4.0") self.assertIn("excluded_pm", schema["properties"]["outcome_counts"]["required"]) rule = next(item for item in schema["$defs"]["record"]["allOf"] if item["if"]["properties"]["outcome"].get("const") == "excluded_pm") self.assertEqual(rule["then"]["properties"]["decision_codes"]["const"], ["ROOM_TYPE_PM_EXCLUDED"]) self.assertEqual(rule["then"]["properties"]["real_price"]["type"], "null") def test_xml_review_and_failure_keep_pm_outside_pricing_and_other_validation(self): code, _, _, _, payload = run_processor(xml_document( pm_xml_row(1, incomplete=True), reservation(2, rate_amount="8765")), self.root / "review") self.assertEqual(code, 0, payload["errors"]) self.assertEqual(payload["status"], "review_required") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["review_required_rows"], 1) self.assertEqual(payload["review_issues"][0]["affected_records"], 1) invalid = ET.fromstring(reservation(2)) invalid.find("DISP_ROOM_NO").text = "" code, _, _, _, payload = run_processor(xml_document( pm_xml_row(1, incomplete=True), ET.tostring(invalid, encoding="unicode")), self.root / "failure") self.assertNotEqual(code, 0) self.assertEqual(payload["status"], "failed") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["outcome_counts"]["validation_failed"], 1) def test_data_success_review_and_failure_keep_pm_outside_business_rules(self): baseline = source_document(2) for field in baseline["records"][0]["fields"]: baseline["records"][0]["fields"][field] = {"state": "missing", "value": None} baseline["records"][0]["fields"]["ROOM_CATEGORY_LABEL"] = {"state": "available", "value": " pm "} baseline["input_complete"] = False baseline["status"] = "collected_with_gaps" code, payload = self.run_data(baseline) self.assertEqual(code, 0, payload["errors"]) self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["output_rows"], 1) self.assertEqual(payload["removed_by_rate_code"], 0) self.assertEqual(payload["records"][0]["room_category_label"], "pm") review = copy.deepcopy(baseline) review["records"][1]["fields"]["EFFECTIVE_RATE_AMOUNT"]["value"] = "8765" code, payload = self.run_data(review, "review") self.assertEqual(code, 0, payload["errors"]) self.assertEqual(payload["status"], "review_required") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["review_required_rows"], 1) failed = copy.deepcopy(baseline) failed["records"][1]["fields"]["DISP_ROOM_NO"] = {"state": "missing", "value": None} code, payload = self.run_data(failed, "failure") self.assertNotEqual(code, 0) self.assertEqual(payload["status"], "failed") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["outcome_counts"]["validation_failed"], 1) def test_frozen_legacy_v3_replay_keeps_pm_and_never_projects_new_outcomes(self): code, _, _, _, payload = run_processor(xml_document( pm_xml_row(1), reservation(2)), self.root, legacy_v3_output=True) self.assertEqual(code, 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"]) rows, _, _, _, _ = self.classify_xml(pm_xml_row(1), reservation(2)) for function in (core.build_legacy_direct_structured_result, core.build_legacy_direct_failed_structured_result): with self.subTest(function=function.__name__), self.assertRaises(core.ProcessingFailure): if function is core.build_legacy_direct_structured_result: function(None, rows[:1], [], self.root / "source.xml", None, None) else: function(None, rows[:1], self.root / "source.xml", None, None, []) if __name__ == "__main__": unittest.main()