568 lines
25 KiB
Python
568 lines
25 KiB
Python
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 = (
|
|
"<LIST_G_COMMENT_RESV_NAME_ID><G_COMMENT_RESV_NAME_ID>"
|
|
f"<RES_COMMENT>{res_comment}</RES_COMMENT>"
|
|
"</G_COMMENT_RESV_NAME_ID></LIST_G_COMMENT_RESV_NAME_ID>"
|
|
)
|
|
return f"""
|
|
<G_RESERVATION>
|
|
<ADULTS>2</ADULTS><BLOCK_CODE>{block_code}</BLOCK_CODE><CF_CHILDREN>0</CF_CHILDREN>
|
|
<COMPANY_NAME>{company}</COMPANY_NAME>
|
|
<CONFIRMATION_NO>{confirmation}</CONFIRMATION_NO><DISP_ROOM_NO>{room}</DISP_ROOM_NO>
|
|
<EFFECTIVE_RATE_AMOUNT>{rate_amount}</EFFECTIVE_RATE_AMOUNT><FULL_NAME>{full_name}</FULL_NAME>
|
|
{comment_xml}
|
|
<NO_OF_ROOMS>{rooms}</NO_OF_ROOMS><PRODUCTS>SYNTHETIC</PRODUCTS><RATE_CODE>{rate_code}</RATE_CODE>
|
|
<ROOM_CATEGORY_LABEL>SYNTHETIC ROOM TYPE</ROOM_CATEGORY_LABEL>
|
|
<TRUNC_BEGIN>2026-07-27</TRUNC_BEGIN><TRUNC_END>{departure}</TRUNC_END>
|
|
</G_RESERVATION>
|
|
"""
|
|
|
|
|
|
def xml_document(*rows: str) -> str:
|
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<RES_DETAIL>
|
|
<LIST_G_GROUP_BY1>
|
|
<G_GROUP_BY1>
|
|
<GROUPBY1_SORT_COL>20260727</GROUPBY1_SORT_COL>
|
|
<GROUPBY1_COL>27-07-26</GROUPBY1_COL>
|
|
<LIST_G_RESERVATION>{''.join(rows)}</LIST_G_RESERVATION>
|
|
</G_GROUP_BY1>
|
|
</LIST_G_GROUP_BY1>
|
|
</RES_DETAIL>
|
|
"""
|
|
|
|
|
|
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)
|