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)