feat: add daily manual price review workflow

This commit is contained in:
Wyndham ARR
2026-08-06 22:40:18 +08:00
parent aae3d8e1db
commit ca3e8e18fa
77 changed files with 7750 additions and 602 deletions

View File

@@ -109,18 +109,39 @@ def success_xml() -> str:
)
def run_processor(xml_text: str, root: Path):
def run_processor(
xml_text: str,
root: Path,
*,
manual_override: dict[str, object] | None = None,
review_job_id: str | None = None,
review_case_id: str | None = None,
):
root.mkdir(parents=True, exist_ok=True)
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")
manual_override_path: Path | None = None
if manual_override is not None:
if review_job_id is None or review_case_id is None:
raise ValueError("manual override requires job and review case identifiers")
manual_override_path = root / "manual-override.json"
manual_override_path.write_bytes(core.canonical_json_bytes(manual_override))
args = argparse.Namespace(
xml=str(xml_path.resolve()),
output_dir=str(output_dir.resolve()),
result_json=str(result_path.resolve()),
structured_result_json=str(structured_path.resolve()),
manual_override_json=(
str(manual_override_path.resolve()) if manual_override_path is not None else None
),
review_job_id=review_job_id,
review_case_id=review_case_id,
manual_override_sha256=(
core.sha256_file(manual_override_path) if manual_override_path is not None else None
),
)
with contextlib.redirect_stdout(io.StringIO()):
exit_code = core.process(args)
@@ -129,6 +150,46 @@ def run_processor(xml_text: str, root: Path):
return exit_code, xml_path, output_dir, result, structured
def manual_override_manifest(
xml_path: Path,
review_issues: list[dict[str, object]],
*,
job_id: str,
review_case_id: str,
real_price: str,
) -> dict[str, object]:
business_date, _reservations = core.read_xml(xml_path)
def issue_key(issue: dict[str, object]) -> dict[str, str]:
return {
"company_key": str(issue["company_key"]),
"rate_code": str(issue["rate_code"]),
"effective_rate_amount": core.canonical_decimal_text(
core.parse_decimal(issue["effective_rate_amount"])
),
}
issues = sorted(
(issue_key(issue) for issue in review_issues),
key=lambda item: (
item["company_key"],
item["rate_code"],
item["effective_rate_amount"],
),
)
return {
"review_version": core.REVIEW_VERSION,
"review_case_id": review_case_id,
"job_id": job_id,
"source_sha256": core.sha256_file(xml_path),
"business_date": business_date.isoformat(),
"processor_version": core.PROCESSOR_VERSION,
"rule_set_sha256": core.rule_set_sha256(),
"issues": issues,
"overrides": [{**issue, "real_price": real_price} for issue in issues],
}
class ArrOperaDailyIngestTests(unittest.TestCase):
def test_package_is_daily_only_and_self_contained(self):
expected = {
@@ -140,6 +201,7 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
"references/codex-result.schema.json",
"references/error-contract.md",
"references/field-contracts.md",
"references/manual-override.schema.json",
"references/structured-output.md",
"references/structured-result.schema.json",
"references/价格对照.xlsx",
@@ -166,9 +228,9 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
"MONTHLY_",
):
self.assertNotIn(forbidden, scripts)
self.assertEqual(core.RESULT_VERSION, "3.0")
self.assertEqual(core.STRUCTURED_RESULT_SCHEMA_VERSION, "3.0")
self.assertEqual(core.PROCESSOR_VERSION, "3.0.0")
self.assertEqual(core.RESULT_VERSION, "4.0")
self.assertEqual(core.STRUCTURED_RESULT_SCHEMA_VERSION, "4.0")
self.assertEqual(core.PROCESSOR_VERSION, "4.0.0")
self.assertEqual(len(core.DAILY_HEADERS), 19)
self.assertEqual(len(core.RATE_WHITELIST), 20)
@@ -233,12 +295,13 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
],
)
self.assertEqual(payload["result_schema_version"], "3.0")
self.assertEqual(payload["processor_version"], "3.0.0")
self.assertEqual(payload["result_schema_version"], "4.0")
self.assertEqual(payload["processor_version"], "4.0.0")
self.assertEqual(payload["source_rows"], 5)
self.assertEqual(
payload["outcome_counts"],
{
"candidate": 0,
"duplicate": 1,
"excluded_rate_code": 1,
"price_unmatched": 0,
@@ -278,7 +341,13 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
self.assertEqual(
set(payload["artifacts"]),
{"source_xml", "daily_report", "result_json", "exception_report"},
{
"source_xml",
"daily_report",
"result_json",
"exception_report",
"manual_override_json",
},
)
for name, expected_path in (
("source_xml", xml_path),
@@ -291,6 +360,7 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
self.assertEqual(artifact["sha256"], core.sha256_file(expected_path))
self.assertEqual(artifact["byte_size"], expected_path.stat().st_size)
self.assertIsNone(payload["artifacts"]["exception_report"])
self.assertIsNone(payload["artifacts"]["manual_override_json"])
self.assertFalse(
any(
path.suffix.lower() == ".xlsx"
@@ -359,31 +429,154 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
)
)
def test_price_failure_is_atomic_and_keeps_every_outcome(self):
price_failure = xml_document(
reservation(1, rate_code="GRPA4", rate_amount="1800"),
reservation(2, rate_code="NOT-ALLOWED", rate_amount="INVALID"),
def test_price_unmatched_enters_review_and_aggregates_safe_keys(self):
price_review = xml_document(
reservation(1, rate_code="GRPA1", rate_amount="900"),
reservation(2, rate_code="GRPA1", rate_amount="1800", rooms="2"),
reservation(3, rate_code="GRPA1", rate_amount="1800", rooms="1"),
reservation(4, rate_code="NOT-ALLOWED", rate_amount="INVALID"),
)
with tempfile.TemporaryDirectory() as temp_dir:
exit_code, _xml, output_dir, result, payload = run_processor(
price_failure, Path(temp_dir)
price_review, Path(temp_dir)
)
self.assertEqual(exit_code, 2)
self.assertEqual(result["status"], "failed")
self.assertEqual(exit_code, 0)
self.assertEqual(result["status"], "review_required")
self.assertIsNone(result["outputs"]["daily_report"])
self.assertEqual(
[row["outcome"] for row in payload["records"]],
["price_unmatched", "excluded_rate_code"],
["candidate", "price_unmatched", "price_unmatched", "excluded_rate_code"],
)
self.assertFalse(payload["activation_eligible"])
self.assertEqual(payload["output_rows"], 0)
self.assertEqual(payload["channels"], [])
self.assertEqual(payload["candidate_rows"], 1)
self.assertEqual(payload["review_required_rows"], 2)
self.assertEqual(payload["review_issue_count"], 1)
self.assertIsNone(payload["artifacts"]["daily_report"])
self.assertIsNotNone(payload["artifacts"]["exception_report"])
self.assertIsNone(payload["artifacts"]["exception_report"])
self.assertIsNone(payload["artifacts"]["manual_override_json"])
self.assertEqual(
payload["review_issues"],
[
{
"company_key": "QBD",
"rate_code": "GRPA1",
"effective_rate_amount": 1800,
"candidate_prices": [
{"effective_rate_amount": 900, "real_price": 1800},
{"effective_rate_amount": 1150, "real_price": 2300},
{"effective_rate_amount": 1300, "real_price": 2600},
{"effective_rate_amount": 1400, "real_price": 2600},
{"effective_rate_amount": 1725, "real_price": 3450},
{"effective_rate_amount": 2300, "real_price": 4600},
],
"affected_records": 2,
"affected_rooms": 3,
"affected_room_nights": 3,
}
],
)
self.assertEqual(
{path.name for path in output_dir.iterdir()},
{"result.json", "structured-result.json", "异常清单.xlsx"},
{"result.json", "structured-result.json"},
)
errors = validator.validate(
argparse.Namespace(
xml=str(_xml.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()),
review_only=True,
)
)
self.assertEqual(errors, [])
def test_final_manual_zero_price_replays_original_xml_and_manifest(self):
source = xml_document(
reservation(1, rate_code="GRPA1", rate_amount="900"),
reservation(2, rate_code="GRPA1", rate_amount="1800", rooms="2"),
reservation(3, rate_code="GRPA1", rate_amount="1800", rooms="1"),
)
job_id = "job-synthetic-review-001"
review_case_id = "dailyreview-0123456789abcdef0123456789abcdef"
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
review = run_processor(source, root / "review")
self.assertEqual(review[3]["status"], "review_required")
manifest = manual_override_manifest(
review[1],
review[4]["review_issues"],
job_id=job_id,
review_case_id=review_case_id,
real_price="0.00",
)
exit_code, xml_path, output_dir, result, payload = run_processor(
source,
root / "final",
manual_override=manifest,
review_job_id=job_id,
review_case_id=review_case_id,
)
self.assertEqual(exit_code, 0)
self.assertEqual(result["status"], "success")
self.assertEqual(payload["review_case_id"], review_case_id)
manifest_path = root / "final" / "manual-override.json"
self.assertEqual(payload["manual_override_sha256"], core.sha256_file(manifest_path))
self.assertEqual(payload["manually_priced_rows"], 2)
self.assertEqual([row["outcome"] for row in payload["records"]], ["retained"] * 3)
self.assertEqual(
[row["pricing_method"] for row in payload["records"]],
["price_reference_exact", "manual_review", "manual_review"],
)
self.assertTrue(
all(
"MANUAL_PRICE_APPLIED" in row["decision_codes"]
for row in payload["records"][1:]
)
)
self.assertEqual([row["real_price"] for row in payload["records"][1:]], [0, 0])
self.assertEqual([row["total_price"] for row in payload["records"][1:]], [0, 0])
self.assertIsNotNone(payload["artifacts"]["manual_override_json"])
self.assertEqual(
payload["artifacts"]["manual_override_json"]["sha256"],
core.sha256_file(manifest_path),
)
errors = validator.validate(
argparse.Namespace(
xml=str(xml_path.resolve()),
daily=str((output_dir / result["outputs"]["daily_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()),
manual_override_json=str(manifest_path.resolve()),
review_job_id=job_id,
review_case_id=review_case_id,
manual_override_sha256=core.sha256_file(manifest_path),
)
)
self.assertEqual(errors, [])
def test_price_unmatched_with_other_validation_error_fails(self):
mixed = xml_document(
reservation(1, rate_code="GRPA1", rate_amount="1800"),
reservation(2, departure="2026-07-26"),
)
with tempfile.TemporaryDirectory() as temp_dir:
exit_code, _xml, output_dir, result, payload = run_processor(mixed, Path(temp_dir))
self.assertEqual(exit_code, 2)
self.assertEqual(result["status"], "failed")
self.assertIsNone(result["outputs"]["daily_report"])
self.assertIsNotNone(result["outputs"]["exception_report"])
self.assertEqual(payload["status"], "failed")
self.assertEqual(payload["review_issues"], [])
self.assertEqual(payload["review_issue_count"], 0)
self.assertIsNotNone(payload["artifacts"]["exception_report"])
self.assertIn("异常清单.xlsx", {path.name for path in output_dir.iterdir()})
def test_independent_validator_rejects_channel_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
@@ -425,14 +618,14 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
).read_text(encoding="utf-8")
)
self.assertFalse(result_schema["additionalProperties"])
self.assertEqual(result_schema["properties"]["version"]["const"], "3.0")
self.assertEqual(result_schema["properties"]["version"]["const"], "4.0")
self.assertEqual(
set(result_schema["properties"]["outputs"]["properties"]),
{"daily_report", "structured_result", "exception_report"},
)
self.assertFalse(structured_schema["additionalProperties"])
self.assertEqual(
structured_schema["properties"]["result_schema_version"]["const"], "3.0"
structured_schema["properties"]["result_schema_version"]["const"], "4.0"
)
artifact_properties = structured_schema["$defs"]["artifact"]["properties"]
self.assertNotIn("path", artifact_properties)
@@ -596,11 +789,12 @@ class ArrOperaDailyIngestTests(unittest.TestCase):
"application/json",
),
"exception_report": None,
"manual_override_json": None,
},
}
)
self.assertEqual(delivery.processor_version, "3.0.0")
self.assertEqual(delivery.result_schema_version, "3.0")
self.assertEqual(delivery.processor_version, "4.0.0")
self.assertEqual(delivery.result_schema_version, "4.0")
self.assertEqual(delivery.business_date, date(2026, 7, 27))
def test_same_input_rerun_has_identical_business_records(self):