"""Approved PM exclusion contracts and additive Finance upgrade; all inputs synthetic.""" from __future__ import annotations import copy from dataclasses import replace import json import os from pathlib import Path import tempfile import unittest from openpyxl import load_workbook from arr_ingestion.contracts import DeliveryEnvelope, IngestionError from arr_ingestion.postgres import DatabaseConfig, PostgresIngestionRepository, _outcome_counts from arr_ingestion.validation import DeliveryValidator, _validate_structured_payload_v4 from arr_processing.policy import load_processor_policy from tests.local_postgres import TemporaryPostgres from tests import test_arr_direct_data as direct_helpers from tests.test_arr_ingestion_validation import build_delivery, policy from tests.test_arr_opera_daily_ingest import reservation, xml_document PROJECT = Path(__file__).resolve().parents[1] def pm_row(*, status=None): value = reservation(1, rate_code="", rate_amount="0", company="").replace( "SYNTHETIC ROOM TYPE", " pm ", ).replace("SYNTHETIC-ROOM-1", "") if status: value = value.replace("", f"{status}") return value class PMIngestionContractTests(unittest.TestCase): def generated(self, root, *rows): raw, store, _ = build_delivery(xml_document(*rows), root) envelope = DeliveryEnvelope.from_dict(json.loads(raw)) payload = json.loads(store.objects[envelope.artifacts["structured_result_json"].object_key]) return raw, store, envelope, payload def test_pm_missing_required_fields_is_audited_before_validation_and_pricing(self): with tempfile.TemporaryDirectory() as temporary: raw, store, _, payload = self.generated(Path(temporary), pm_row(), reservation(2)) verified = DeliveryValidator(store, policy()).validate(raw) self.assertEqual(verified.envelope.status, "success") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) self.assertEqual(payload["output_rows"], 1) row = payload["records"][0] self.assertEqual(row["outcome"], "excluded_pm") self.assertEqual(row["decision_codes"], ["ROOM_TYPE_PM_EXCLUDED"]) for field in ("real_price", "total_price", "kb_amount", "channel_key", "pricing_method"): self.assertIsNone(row[field]) def test_pm_count_is_required_only_for_44_and_older_versions_stay_readable(self): with tempfile.TemporaryDirectory() as temporary: _, _, envelope, payload = self.generated(Path(temporary), reservation(1)) self.assertEqual(payload["processor_version"], "4.4.0") self.assertEqual(payload["outcome_counts"]["excluded_pm"], 0) for field in ("excluded_pm", "excluded_cancelled"): missing = copy.deepcopy(payload) del missing["outcome_counts"][field] with self.assertRaisesRegex(IngestionError, "outcome counts contract"): _validate_structured_payload_v4(missing, envelope) for version in ("4.3.0", "4.2.0"): older = copy.deepcopy(payload) older["processor_version"] = version with self.assertRaisesRegex(IngestionError, "outcome counts contract"): _validate_structured_payload_v4(older, replace(envelope, processor_version=version)) del older["outcome_counts"]["excluded_pm"] if version == "4.2.0": del older["outcome_counts"]["excluded_cancelled"] _validate_structured_payload_v4(older, replace(envelope, processor_version=version)) def test_pm_record_cannot_be_relabelled_or_given_price_facts(self): with tempfile.TemporaryDirectory() as temporary: _, _, envelope, payload = self.generated(Path(temporary), pm_row(), reservation(2)) changes = { "room_category_label": "PM-SUITE", "decision_codes": ["RATE_CODE_EXCLUDED"], "real_price": 0, "total_price": 0, "kb_amount": 0, "channel_key": "Group", "pricing_method": "zero_price_exception", } for field, value in changes.items(): changed = copy.deepcopy(payload) changed["records"][0][field] = value with self.subTest(field=field), self.assertRaisesRegex(IngestionError, "PM exclusion"): _validate_structured_payload_v4(changed, envelope) older = copy.deepcopy(payload) older["processor_version"] = "4.3.0" del older["outcome_counts"]["excluded_pm"] with self.assertRaisesRegex(IngestionError, "structured outcome is invalid"): _validate_structured_payload_v4(older, replace(envelope, processor_version="4.3.0")) changed = copy.deepcopy(payload) changed["outcome_counts"]["excluded_pm"] = 0 with self.assertRaisesRegex(IngestionError, "outcomes do not reconcile"): _validate_structured_payload_v4(changed, envelope) def test_pm_balances_price_review_and_failure_and_cancelled_takes_precedence(self): cases = ( (reservation(2, rate_amount="1800"), "review_required"), (reservation(2, departure="2026-07-26"), "failed"), ) for row, status in cases: with self.subTest(status=status), tempfile.TemporaryDirectory() as temporary: raw, store, _, payload = self.generated(Path(temporary), pm_row(), row) self.assertEqual(DeliveryValidator(store, policy()).validate(raw).envelope.status, status) self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1) with tempfile.TemporaryDirectory() as temporary: raw, store, _, payload = self.generated(Path(temporary), pm_row(status="CXL"), reservation(2)) DeliveryValidator(store, policy()).validate(raw) self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1) self.assertEqual(payload["outcome_counts"]["excluded_pm"], 0) def test_finance_defaults_only_old_pm_counts_and_rejects_44_missing_keys(self): counts = dict(retained=2, excluded_rate_code=1, duplicate=3, validation_failed=4, price_unmatched=5) self.assertEqual(_outcome_counts({"outcome_counts": counts}), (2, 1, 3, 4, 5, 0, 0)) counts["excluded_cancelled"] = 6 self.assertEqual(_outcome_counts({"processor_version": "4.3.0", "outcome_counts": counts}), (2, 1, 3, 4, 5, 6, 0)) with self.assertRaises(IngestionError): _outcome_counts({"processor_version": "4.4.0", "outcome_counts": counts}) counts["excluded_pm"] = 7 self.assertEqual(_outcome_counts({"processor_version": "4.4.0", "outcome_counts": counts}), (2, 1, 3, 4, 5, 6, 7)) del counts["excluded_cancelled"] with self.assertRaises(IngestionError): _outcome_counts({"processor_version": "4.4.0", "outcome_counts": counts}) @unittest.skipUnless(os.environ.get("ARR_TEST_LOCAL_POSTGRES") == "1", "owned PostgreSQL opt-in required") class PMFinanceMigrationTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.database = TemporaryPostgres().__enter__() cls.addClassCleanup(cls.database.__exit__, None, None, None) def test_additive_upgrade_preserves_cancelled_history_and_role_privileges(self): import psycopg self.database.reset_database(schema_version=20) repository = PostgresIngestionRepository(DatabaseConfig("owned-fixture"), connect=self.database.connect) repository.assert_cancelled_scope_schema() with self.assertRaises(IngestionError) as missing: repository.assert_pm_scope_schema() self.assertEqual(missing.exception.code, "DATABASE_MIGRATION_MISSING") with self.database.connect(autocommit=True) as connection: artifact = connection.execute("""INSERT INTO ingestion.artifacts (artifact_kind,storage_provider,bucket_alias,object_key,original_filename,sha256,byte_size) VALUES ('opera_xml','local_fixture','fixture','old/source.xml','source.xml',%s,0) RETURNING id""", ("a" * 64,)).fetchone()[0] output_ids = {} for kind in ("daily_xlsx", "result_json", "structured_result_json"): output_ids[kind] = connection.execute("""INSERT INTO ingestion.artifacts (artifact_kind,storage_provider,bucket_alias,object_key,original_filename,sha256,byte_size) VALUES (%s,'local_fixture','fixture',%s,%s,%s,0) RETURNING id""", (kind, "old/" + kind, kind, "d" * 64)).fetchone()[0] run = connection.execute("""INSERT INTO ingestion.processing_runs (run_key,pipeline_type,source_artifact_id,result_artifact_id,run_status,result_delivery_mode,business_date, delivered_processor_version,delivered_rule_set_sha256,result_schema_version, delivery_sha256,validated_at,finished_at) VALUES ('old-cancelled','opera_daily',%s,%s,'accepted','artifact_callback','2026-07-27', '4.3.0',%s,'4.0',%s,now(),now()) RETURNING id""", (artifact, output_ids["result_json"], "b" * 64, "c" * 64)).fetchone()[0] version = connection.execute("""INSERT INTO finance.daily_versions (business_date,version_no,processing_run_id,source_artifact_id,daily_report_artifact_id, result_json_artifact_id,structured_result_artifact_id,version_status, processor_version,rule_set_sha256,result_schema_version,result_sha256,source_rows, retained_rows,excluded_rate_code_rows,duplicate_rows,validation_failed_rows, price_unmatched_rows,excluded_cancelled_rows,validated_at,result_delivery_mode) VALUES ('2026-07-27',1,%s,%s,%s,%s,%s,'validated','4.3.0',%s,'4.0',%s, 2,1,0,0,0,0,1,now(),'artifact_callback') RETURNING id""", (run, artifact, output_ids["daily_xlsx"], output_ids["result_json"], output_ids["structured_result_json"], "b" * 64, "c" * 64)).fetchone()[0] connection.execute("""INSERT INTO finance.daily_records (daily_version_id,source_sequence,source_location,outcome,decision_codes, booking_source_match_status) VALUES (%s,1,'reservation[1]','excluded_cancelled', ARRAY['RESERVATION_CANCELLED_EXCLUDED'],'missing_group_code')""", (version,)) # Processor 4.3 included this PM row. A schema upgrade must preserve it. connection.execute("""INSERT INTO finance.daily_records (daily_version_id,source_sequence,source_location,outcome,decision_codes,block_code, adults,children,company_name,company_key,confirmation_no,disp_room_no, effective_rate_amount,full_name,no_of_rooms,products,rate_code,room_category_label, arrival,departure,nights,real_price,total_price,channel_key,pricing_method, booking_source_match_status) VALUES (%s,2,'reservation[2]','retained',ARRAY['PRICE_REFERENCE_EXACT'],'',2,0, 'SYNTHETIC COMPANY','SYNTHETIC COMPANY','SYNTHETIC CONF','SYNTHETIC ROOM', 900,'SYNTHETIC GUEST',1,'SYNTHETIC','GRPA1','PM','2026-07-27','2026-07-28', 1,900,900,'Group','price_reference_exact','missing_group_code')""", (version,)) before = connection.execute("SELECT to_jsonb(v) FROM finance.daily_versions v WHERE id=%s", (version,)).fetchone()[0] records_before = connection.execute("SELECT to_jsonb(r) FROM finance.daily_records r ORDER BY source_sequence").fetchall() guards = connection.execute("SELECT tgname,pg_get_triggerdef(oid) FROM pg_trigger WHERE NOT tgisinternal ORDER BY tgname").fetchall() connection.execute("CREATE ROLE arr_pm_fixture") connection.execute("CREATE ROLE arr_pm_column_fixture") connection.execute("GRANT USAGE ON SCHEMA finance TO arr_pm_fixture,arr_pm_column_fixture") connection.execute("GRANT SELECT,INSERT ON finance.daily_versions TO arr_pm_fixture") connection.execute("GRANT INSERT (excluded_cancelled_rows) ON finance.daily_versions TO arr_pm_column_fixture") connection.execute((PROJECT / "database/021_daily_pm_exclusion.sql").read_text(), prepare=False) repository.assert_data_source_schema() after = connection.execute("SELECT to_jsonb(v) FROM finance.daily_versions v WHERE id=%s", (version,)).fetchone()[0] self.assertEqual(after.pop("excluded_pm_rows"), 0) self.assertEqual(after, before) self.assertEqual(connection.execute("SELECT to_jsonb(r) FROM finance.daily_records r ORDER BY source_sequence").fetchall(), records_before) self.assertEqual(connection.execute("SELECT tgname,pg_get_triggerdef(oid) FROM pg_trigger WHERE NOT tgisinternal ORDER BY tgname").fetchall(), guards) self.assertEqual(connection.execute("SELECT has_column_privilege('arr_pm_fixture','finance.daily_versions','excluded_pm_rows','INSERT'),has_table_privilege('arr_pm_fixture','finance.daily_versions','UPDATE'),has_column_privilege('arr_pm_column_fixture','finance.daily_versions','excluded_pm_rows','INSERT')").fetchone(), (True, False, False)) for statement in ( "UPDATE finance.daily_versions SET excluded_pm_rows=-1 WHERE id=%s", "UPDATE finance.daily_versions SET excluded_pm_rows=1 WHERE id=%s", ): with self.assertRaises(psycopg.errors.CheckViolation): connection.execute(statement, (version,)) connection.execute("UPDATE finance.daily_versions SET excluded_cancelled_rows=0,excluded_pm_rows=1 WHERE id=%s", (version,)) with self.assertRaisesRegex(psycopg.errors.RaiseException, "PM-exclusion facts exist"): connection.execute((PROJECT / "database/021_daily_pm_exclusion.down.sql").read_text(), prepare=False) connection.execute("ROLLBACK") connection.execute("SET ROLE arr_pm_fixture") with self.assertRaises(psycopg.errors.InsufficientPrivilege): connection.execute("UPDATE finance.daily_versions SET excluded_pm_rows=0 WHERE id=%s", (version,)) connection.execute("RESET ROLE") def test_empty_rollback_preserves_020_and_allows_reapply(self): self.database.reset_database() repository = PostgresIngestionRepository(DatabaseConfig("owned-fixture"), connect=self.database.connect) repository.assert_data_source_schema() with self.database.connect(autocommit=True) as connection: connection.execute((PROJECT / "database/021_daily_pm_exclusion.down.sql").read_text(), prepare=False) repository.assert_cancelled_scope_schema() with self.assertRaises(IngestionError) as missing: repository.assert_pm_scope_schema() self.assertEqual(missing.exception.code, "DATABASE_MIGRATION_MISSING") with self.database.connect(autocommit=True) as connection: connection.execute((PROJECT / "database/021_daily_pm_exclusion.sql").read_text(), prepare=False) repository.assert_data_source_schema() @unittest.skipUnless(os.environ.get("ARR_TEST_LOCAL_POSTGRES") == "1", "owned PostgreSQL opt-in required") class PMFinanceProcessingTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.database = TemporaryPostgres().__enter__() cls.addClassCleanup(cls.database.__exit__, None, None, None) cls.policy = load_processor_policy(PROJECT) setUp = direct_helpers.DirectDataPostgresTests.setUp configure = direct_helpers.DirectDataTests.configure execute = direct_helpers.DirectDataTests.execute sql = direct_helpers.DirectDataPostgresTests.sql count = direct_helpers.DirectDataPostgresTests.count monthly_worker = direct_helpers.DirectDataPostgresTests.monthly_worker def make_first_pm(self): row = self.transport.rows[0] row["roomStay"]["roomType"] = "PM" row["roomStay"]["currentRoomInfo"]["roomType"] = "PM" for rate in row["roomStay"]["roomRates"]: rate["roomType"] = "PM" def test_direct_pm_is_audited_and_daily_monthly_exclude_it_without_refetch(self): self.make_first_pm() self.assertEqual(self.execute().status, "succeeded") self.assertEqual(self.sql("SELECT result_schema_version,source_rows,retained_rows,excluded_pm_rows FROM finance.daily_versions"), [("5.0", 6, 5, 1)]) self.assertEqual(self.sql("SELECT outcome,decision_codes,real_price,channel_key FROM finance.daily_records WHERE source_sequence=1"), [("excluded_pm", ["ROOM_TYPE_PM_EXCLUDED"], None, None)]) self.assertEqual(self.count("finance.v_active_daily_facts"), 5) self.assertEqual(self.monthly_worker().process_next().status, "published") key, size = self.sql("SELECT a.object_key,a.byte_size FROM reporting.monthly_runs r JOIN ingestion.artifacts a ON a.id=r.workbook_artifact_id")[0] output = self.files / "monthly.xlsx" self.store.materialize(key, output, size) workbook = load_workbook(output, data_only=False) try: self.assertEqual(sum(cell.data_type == "f" for sheet in workbook for row in sheet for cell in row), 5) finally: workbook.close() calls = len(self.transport.calls) self.assertEqual(self.execute().status, "succeeded") self.assertEqual(len(self.transport.calls), calls) self.assertEqual(self.count("finance.daily_versions"), 1) def test_xml_pm_is_audited_without_required_field_or_price_review(self): outcome = self.coordinator.submit("source.xml", xml_document(pm_row(), reservation(2)).encode()) self.assertEqual(outcome["status"], "succeeded") self.assertEqual(self.sql("SELECT result_schema_version,source_rows,retained_rows,excluded_pm_rows FROM finance.daily_versions"), [("4.0", 2, 1, 1)]) self.assertEqual(self.sql("SELECT outcome FROM finance.daily_records ORDER BY source_sequence"), [("excluded_pm",), ("retained",)]) self.assertEqual(self.count("finance.v_active_daily_facts"), 1) self.assertEqual(self.monthly_worker().process_next().status, "published") def test_xml_failed_batch_keeps_pm_audit_without_monthly_commit(self): outcome = self.coordinator.submit("source.xml", xml_document(pm_row(), reservation(2, departure="2026-07-26")).encode()) self.assertEqual(outcome["status"], "failed") self.assertEqual(self.sql("SELECT version_status,source_rows,excluded_pm_rows,validation_failed_rows FROM finance.daily_versions"), [("rejected", 2, 1, 1)]) self.assertEqual(self.count("finance.current_daily_versions"), 0) self.assertEqual(self.monthly_worker().process_next().status, "idle") def test_database_pm_record_classification_guard_rejects_wrong_room_or_price(self): import psycopg self.make_first_pm() self.assertEqual(self.execute().status, "succeeded") with self.database.connect(autocommit=True) as connection: for statement in ( "UPDATE finance.daily_records SET room_category_label='PM-SUITE' WHERE source_sequence=1", "UPDATE finance.daily_records SET real_price=0 WHERE source_sequence=1", "UPDATE finance.daily_records SET channel_key='Group' WHERE source_sequence=1", "UPDATE finance.daily_records SET decision_codes=ARRAY['RATE_CODE_EXCLUDED'] WHERE source_sequence=1", ): with self.subTest(statement=statement), self.assertRaises(psycopg.errors.CheckViolation): connection.execute(statement) if __name__ == "__main__": unittest.main()