feat: add daily manual price review workflow
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user