from __future__ import annotations import argparse import contextlib import io import json import sys import tempfile import unittest from datetime import date from pathlib import Path from openpyxl import load_workbook PROJECT_ROOT = Path(__file__).resolve().parents[1] SKILL_ROOT = PROJECT_ROOT / "opera-daily-channel-report" SCRIPTS = SKILL_ROOT / "scripts" sys.path.insert(0, str(SCRIPTS)) import process_reports as core # noqa: E402 import validate_outputs as validator # noqa: E402 def reservation( sequence: int, *, company: str = "T / Q.B.D. TRAVEL GROUP", rate_code: str = "GRPA1", rate_amount: str = "900", room: str | None = None, confirmation: str | None = None, full_name: str = "SYNTHETIC GUEST", block_code: str = "", res_comment: str | None = None, rooms: str = "1", departure: str = "2026-07-28", ) -> str: room = room or f"SYNTHETIC-ROOM-{sequence}" confirmation = confirmation or f"SYNTHETIC-CONF-{sequence}" comment_xml = "" if res_comment is not None: comment_xml = ( "" f"{res_comment}" "" ) return f""" 2{block_code}0 {company} {confirmation}{room} {rate_amount}{full_name} {comment_xml} {rooms}SYNTHETIC{rate_code} SYNTHETIC ROOM TYPE 2026-07-27{departure} """ def xml_document(*rows: str) -> str: return f""" 20260727 27-07-26 {''.join(rows)} """ def run_processor( xml_text: str, root: Path, monthly_base: Path | None = None, *, mode: str = "daily-monthly", ): xml_path = root / "synthetic.xml" output_dir = root / "output" result_path = output_dir / "result.json" structured_path = output_dir / "structured-result.json" xml_path.write_text(xml_text, encoding="utf-8") args = argparse.Namespace( xml=str(xml_path.resolve()), monthly_base=str(monthly_base.resolve()) if monthly_base else None, output_dir=str(output_dir.resolve()), result_json=str(result_path.resolve()), structured_result_json=str(structured_path.resolve()), mode=mode, ) with contextlib.redirect_stdout(io.StringIO()): exit_code = core.process(args) result = json.loads(result_path.read_text(encoding="utf-8")) structured = json.loads(structured_path.read_text(encoding="utf-8")) return exit_code, xml_path, output_dir, result, structured class StructuredOutputTests(unittest.TestCase): def success_xml(self) -> str: return xml_document( reservation(1, rate_code="NOT-ALLOWED", rate_amount="INVALID"), reservation(2, rate_code="grpa1", res_comment=" ab-123 ", rooms="2", departure="2026-07-30"), reservation( 3, room="SYNTHETIC-ROOM-2", confirmation="SYNTHETIC-DUPLICATE", res_comment="AB-123", ), reservation( 4, company="T- Rainbow Holiday Service", rate_code="LBMS", rate_amount="98765", block_code="MUST-NOT-BECOME-GROUP-CODE", res_comment=None, departure="2026-07-29", ), ) def test_success_payload_preserves_every_source_outcome_and_lineage(self): with tempfile.TemporaryDirectory() as temp_dir: exit_code, xml_path, output_dir, result, payload = run_processor( self.success_xml(), Path(temp_dir) ) self.assertEqual(exit_code, 0) self.assertEqual(set(result), { "version", "status", "business_date", "month_key", "message", "metrics", "outputs", "errors" }) self.assertNotIn("structured_result", result["outputs"]) self.assertEqual(payload["status"], "success") self.assertTrue(payload["activation_eligible"]) self.assertEqual(payload["processor_version"], core.PROCESSOR_VERSION) self.assertEqual(payload["rule_set_sha256"], core.rule_set_sha256()) self.assertEqual(payload["source_rows"], 4) self.assertEqual(payload["removed_by_rate_code"], 1) self.assertEqual(payload["removed_as_duplicates"], 1) self.assertEqual(payload["output_rows"], 2) self.assertEqual( payload["outcome_counts"], { "duplicate": 1, "excluded_rate_code": 1, "price_unmatched": 0, "retained": 2, "validation_failed": 0, }, ) records = payload["records"] self.assertEqual([row["source_sequence"] for row in records], [1, 2, 3, 4]) self.assertEqual( [row["outcome"] for row in records], ["excluded_rate_code", "retained", "duplicate", "retained"], ) self.assertEqual(records[2]["duplicate_of_source_sequence"], 2) self.assertEqual(records[1]["rate_code"], "grpa1") self.assertEqual(records[1]["normalized_rate_code"], "GRPA1") self.assertEqual(records[1]["group_code_key"], "AB-123") self.assertEqual(records[1]["booking_source_match_status"], "not_checked") self.assertIsNone(records[3]["group_code_key"]) self.assertEqual(records[3]["booking_source_match_status"], "missing_group_code") self.assertNotEqual(records[3]["block_code"], records[3]["group_code_key"]) self.assertEqual(records[1]["real_price"], 1800) self.assertEqual(records[1]["total_price"], 10800) self.assertEqual(records[1]["pricing_method"], "price_reference_exact") self.assertEqual(records[3]["real_price"], 0) self.assertEqual(records[3]["total_price"], 0) self.assertEqual(records[3]["kb_amount"], 100) self.assertEqual(records[3]["pricing_method"], "zero_price_exception") self.assertTrue(all(row["source_worksheet"] is None for row in records)) self.assertTrue(all(row["source_row_no"] is None for row in records)) artifact_paths = { "source_xml": xml_path, "daily_report": output_dir / result["outputs"]["daily_report"], "monthly_report": output_dir / result["outputs"]["monthly_report"], "result_json": output_dir / "result.json", } for name, path in artifact_paths.items(): with self.subTest(artifact=name): artifact = payload["artifacts"][name] self.assertEqual(artifact["path"], str(path.resolve())) self.assertEqual(artifact["sha256"], core.sha256_file(path)) self.assertEqual(artifact["byte_size"], path.stat().st_size) self.assertIsNone(payload["artifacts"]["exception_report"]) core.validate_structured_completeness(payload) def test_same_business_date_rerun_replaces_monthly_rows_without_duplication(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) first_root = root / "first" second_root = root / "second" first_root.mkdir() second_root.mkdir() first = run_processor(self.success_xml(), first_root) self.assertEqual(first[0], 0) first_monthly = first[2] / first[3]["outputs"]["monthly_report"] second = run_processor(self.success_xml(), second_root, monthly_base=first_monthly) self.assertEqual(second[0], 0) self.assertEqual(first[4]["records"], second[4]["records"]) second_monthly = second[2] / second[3]["outputs"]["monthly_report"] workbook = load_workbook(second_monthly, data_only=False) try: current_rows = 0 for sheet in workbook.worksheets: for row_number in range(2, sheet.max_row + 1): if sheet.cell(row_number, 1).value is not None: current_rows += 1 self.assertEqual(current_rows, 2) finally: workbook.close() def test_daily_mode_produces_no_monthly_workbook_and_keeps_channel_lineage(self): with tempfile.TemporaryDirectory() as temp_dir: exit_code, xml_path, output_dir, result, payload = run_processor( self.success_xml(), Path(temp_dir), mode="daily" ) self.assertEqual(exit_code, 0) self.assertEqual(result["version"], core.DAILY_RESULT_VERSION) self.assertEqual(result["processing_mode"], "daily") self.assertIsNone(result["outputs"]["monthly_report"]) self.assertEqual( result["metrics"]["channels"], [{"worksheet": "QBD", "rows": 1}, {"worksheet": core.KB_SHEET, "rows": 1}], ) self.assertEqual( payload["result_schema_version"], core.DAILY_STRUCTURED_RESULT_SCHEMA_VERSION, ) self.assertEqual(payload["processing_mode"], "daily") self.assertIsNone(payload["artifacts"]["monthly_report"]) self.assertEqual( [record["channel_key"] for record in payload["records"]], [None, "QBD", None, core.KB_SHEET], ) self.assertEqual(payload["records"][3]["kb_amount"], 100) self.assertFalse( any( path.suffix.lower() == ".xlsx" and path.name != result["outputs"]["daily_report"] for path in output_dir.iterdir() ) ) core.validate_structured_completeness(payload) errors = validator.validate( argparse.Namespace( mode="daily", xml=str(xml_path.resolve()), daily=str((output_dir / result["outputs"]["daily_report"]).resolve()), monthly=None, result_json=str((output_dir / "result.json").resolve()), structured_result_json=str( (output_dir / "structured-result.json").resolve() ), price_reference=str(core.PRICE_REFERENCE.resolve()), ) ) self.assertEqual(errors, []) def test_daily_mode_rejects_monthly_base_without_creating_formal_outputs(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) monthly_base = SKILL_ROOT / "assets" / "channel-report-template.xlsx" exit_code, _xml_path, output_dir, result, payload = run_processor( self.success_xml(), root, monthly_base=monthly_base, mode="daily" ) self.assertEqual(exit_code, 3) self.assertEqual(result["version"], core.DAILY_RESULT_VERSION) self.assertEqual(result["processing_mode"], "daily") self.assertEqual(result["status"], "failed") self.assertIn("INPUT_MONTHLY_FORBIDDEN", {item["code"] for item in result["errors"]}) self.assertIsNone(payload["artifacts"]["daily_report"]) self.assertIsNone(payload["artifacts"]["monthly_report"]) self.assertFalse(payload["activation_eligible"]) self.assertFalse( any( path.suffix.lower() == ".xlsx" and path.name != "异常清单.xlsx" for path in output_dir.iterdir() ) ) def test_daily_validator_rejects_channel_tampering(self): with tempfile.TemporaryDirectory() as temp_dir: exit_code, xml_path, output_dir, result, payload = run_processor( self.success_xml(), Path(temp_dir), mode="daily" ) self.assertEqual(exit_code, 0) structured_path = output_dir / "structured-result.json" payload["records"][1]["channel_key"] = "FENGRUN" structured_path.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) errors = validator.validate( argparse.Namespace( mode="daily", xml=str(xml_path.resolve()), daily=str((output_dir / result["outputs"]["daily_report"]).resolve()), monthly=None, result_json=str((output_dir / "result.json").resolve()), structured_result_json=str(structured_path.resolve()), price_reference=str(core.PRICE_REFERENCE.resolve()), ) ) self.assertIn( "OUTPUT_STRUCTURED_RECORD_MISMATCH", {error.code for error in errors}, ) def test_price_failure_keeps_price_unmatched_and_excluded_rows_but_no_formal_outputs(self): failure_xml = xml_document( reservation(1, rate_code="GRPA4", rate_amount="1800", res_comment="GROUP-FAIL"), reservation(2, rate_code="NOT-ALLOWED", rate_amount="INVALID"), ) with tempfile.TemporaryDirectory() as temp_dir: exit_code, _xml_path, output_dir, result, payload = run_processor( failure_xml, Path(temp_dir) ) self.assertEqual(exit_code, 2) self.assertEqual(result["status"], "failed") self.assertFalse(payload["activation_eligible"]) self.assertEqual(payload["output_rows"], 0) self.assertEqual( [row["outcome"] for row in payload["records"]], ["price_unmatched", "excluded_rate_code"], ) self.assertIn("PRICE_UNMATCHED", payload["records"][0]["decision_codes"]) self.assertIsNone(payload["artifacts"]["daily_report"]) self.assertIsNone(payload["artifacts"]["monthly_report"]) self.assertIsNotNone(payload["artifacts"]["exception_report"]) self.assertFalse(any(path.suffix == ".xlsx" and path.name != "异常清单.xlsx" for path in output_dir.iterdir())) core.validate_structured_completeness(payload) def test_row_validation_failure_marks_other_pending_rows_not_validated(self): failure_xml = xml_document( reservation(1, full_name=""), reservation(2, res_comment="VALID-GROUP"), ) with tempfile.TemporaryDirectory() as temp_dir: exit_code, _xml_path, _output_dir, _result, payload = run_processor( failure_xml, Path(temp_dir) ) self.assertEqual(exit_code, 2) self.assertEqual( [row["outcome"] for row in payload["records"]], ["validation_failed", "validation_failed"], ) self.assertIn("XML_FULL_NAME_MISSING", payload["records"][0]["decision_codes"]) self.assertIn("BATCH_NOT_VALIDATED", payload["records"][1]["decision_codes"]) self.assertEqual(payload["outcome_counts"]["validation_failed"], 2) self.assertEqual(payload["output_rows"], 0) core.validate_structured_completeness(payload) def test_zero_night_rows_are_retained_in_daily_monthly_and_structured_outputs(self): zero_night_xml = xml_document( reservation(1, departure="2026-07-27", res_comment="ZERO-NIGHT-QBD"), reservation( 2, company="T- Rainbow Holiday Service", rate_code="LBMS", rate_amount="98765", departure="2026-07-27", res_comment="ZERO-NIGHT-KB", ), ) with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) first_root = root / "first" second_root = root / "second" first_root.mkdir() second_root.mkdir() exit_code, _xml_path, output_dir, result, payload = run_processor( zero_night_xml, first_root ) self.assertEqual(exit_code, 0) self.assertTrue(payload["activation_eligible"]) self.assertEqual( [record["outcome"] for record in payload["records"]], ["retained", "retained"], ) self.assertEqual([record["nights"] for record in payload["records"]], [0, 0]) self.assertEqual([record["total_price"] for record in payload["records"]], [0, 0]) self.assertEqual(payload["records"][0]["real_price"], 1800) self.assertEqual(payload["records"][1]["real_price"], 0) self.assertEqual(payload["records"][1]["kb_amount"], 100) daily = load_workbook(output_dir / result["outputs"]["daily_report"], data_only=False) try: nights_col = core.DAILY_HEADERS.index("NIGHTS") + 1 total_col = core.DAILY_HEADERS.index("TOTAL PRICE") + 1 self.assertEqual( [daily.active.cell(row, nights_col).value for row in (2, 3)], [0, 0], ) self.assertEqual( [daily.active.cell(row, total_col).value for row in (2, 3)], [0, 0], ) finally: daily.close() monthly_path = output_dir / result["outputs"]["monthly_report"] monthly = load_workbook(monthly_path, data_only=False) try: self.assertEqual(monthly["QBD"].cell(2, 3).value, 0) self.assertEqual(monthly["QBD"].cell(2, 19).value, 0) self.assertEqual(monthly[core.KB_SHEET].cell(2, 3).value, 0) self.assertEqual(monthly[core.KB_SHEET].cell(2, 19).value, 0) self.assertEqual(monthly[core.KB_SHEET].cell(2, 20).value, 100) finally: monthly.close() rerun = run_processor(zero_night_xml, second_root, monthly_base=monthly_path) self.assertEqual(rerun[0], 0) self.assertTrue(rerun[4]["activation_eligible"]) def test_negative_night_remains_a_validation_failure(self): negative_night_xml = xml_document( reservation(1, departure="2026-07-26", res_comment="NEGATIVE-NIGHT") ) with tempfile.TemporaryDirectory() as temp_dir: exit_code, _xml_path, _output_dir, _result, payload = run_processor( negative_night_xml, Path(temp_dir) ) self.assertEqual(exit_code, 2) self.assertFalse(payload["activation_eligible"]) self.assertEqual(payload["records"][0]["outcome"], "validation_failed") self.assertEqual(payload["records"][0]["nights"], -1) self.assertIn("XML_NEGATIVE_NIGHTS", payload["records"][0]["decision_codes"]) def test_independent_validator_rejects_negative_nights(self): zero_night_xml = xml_document( reservation(1, departure="2026-07-27", res_comment="VALIDATOR-ZERO-NIGHT") ) with tempfile.TemporaryDirectory() as temp_dir: exit_code, xml_path, output_dir, result, _payload = run_processor( zero_night_xml, Path(temp_dir) ) self.assertEqual(exit_code, 0) daily_path = output_dir / result["outputs"]["daily_report"] daily = load_workbook(daily_path, data_only=False) try: departure_col = core.DAILY_HEADERS.index("DEPARTURE") + 1 nights_col = core.DAILY_HEADERS.index("NIGHTS") + 1 daily.active.cell(2, departure_col).value = date(2026, 7, 26) daily.active.cell(2, nights_col).value = -1 daily.save(daily_path) finally: daily.close() errors = validator.validate( argparse.Namespace( xml=str(xml_path.resolve()), daily=str(daily_path.resolve()), monthly=str((output_dir / result["outputs"]["monthly_report"]).resolve()), result_json=str((output_dir / "result.json").resolve()), structured_result_json=str((output_dir / "structured-result.json").resolve()), price_reference=str(core.PRICE_REFERENCE.resolve()), ) ) self.assertIn("OUTPUT_NIGHTS_MISMATCH", {error.code for error in errors}) def test_independent_validator_rejects_structured_artifact_hash_tampering(self): with tempfile.TemporaryDirectory() as temp_dir: exit_code, xml_path, output_dir, result, payload = run_processor( self.success_xml(), Path(temp_dir) ) self.assertEqual(exit_code, 0) structured_path = output_dir / "structured-result.json" payload["artifacts"]["daily_report"]["sha256"] = "0" * 64 structured_path.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) errors = validator.validate( argparse.Namespace( xml=str(xml_path.resolve()), daily=str((output_dir / result["outputs"]["daily_report"]).resolve()), monthly=str((output_dir / result["outputs"]["monthly_report"]).resolve()), result_json=str((output_dir / "result.json").resolve()), structured_result_json=str(structured_path.resolve()), price_reference=str(core.PRICE_REFERENCE.resolve()), ) ) self.assertIn( "OUTPUT_STRUCTURED_ARTIFACT_MISMATCH", {error.code for error in errors} ) def test_schema_is_strict_and_covers_finance_contract_fields(self): schema = json.loads( (SKILL_ROOT / "references" / "structured-result.schema.json").read_text( encoding="utf-8" ) ) self.assertFalse(schema["additionalProperties"]) self.assertEqual( schema["properties"]["result_schema_version"]["enum"], ["1.0", "2.0"] ) self.assertEqual(schema["properties"]["processing_mode"]["const"], "daily") daily_condition = next( condition for condition in schema["allOf"] if condition.get("if", {}) .get("properties", {}) .get("result_schema_version", {}) .get("const") == "2.0" ) self.assertEqual( daily_condition["then"]["properties"]["artifacts"]["properties"][ "monthly_report" ], {"type": "null"}, ) required = set(schema["required"]) self.assertTrue( { "business_date", "processor_version", "rule_set_sha256", "result_schema_version", "source_rows", "removed_by_rate_code", "removed_as_duplicates", "output_rows", "channels", "artifacts", "records", }.issubset(required) ) record_schema = schema["$defs"]["record"] self.assertFalse(record_schema["additionalProperties"]) retained_properties = record_schema["allOf"][1]["then"]["properties"] self.assertEqual(retained_properties["nights"]["minimum"], 0) for field in ( "source_sequence", "source_location", "source_worksheet", "source_row_no", "outcome", "decision_codes", "duplicate_of_source_sequence", "effective_rate_amount", "room_category_label", "real_price", "total_price", "kb_amount", "channel_key", "pricing_method", "normalized_rate_code", "group_code_key", "company_key", ): self.assertIn(field, record_schema["required"]) if __name__ == "__main__": unittest.main(verbosity=2)