# -*- coding: utf-8 -*- """票据合规预审规则引擎金标准用例(离线,无网络无浏览器)。""" from __future__ import annotations import unittest from datetime import date, timedelta from _support import IsolatedDataRoot _PASS = "pass" _WARN = "warn" _REJECT = "reject" def _levels(items): return {i["rule_id"]: i["level"] for i in items} def _verdict_of(result): return {r["invoice_number"]: r["verdict"] for r in result["reports"]} class PrereviewRuleTestBase(unittest.TestCase): def setUp(self) -> None: self._root = IsolatedDataRoot() self._root.__enter__() from db.connection import init_db init_db() def tearDown(self) -> None: self._root.__exit__(None, None, None) def _weekday(self, offset: int = 0) -> str: """返回一个周一~周四且不在节假日种子的日期(避免 R3 误触发);从今天往回找,保证不是未来日期。""" from db.connection import get_conn conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT holiday_date FROM holiday_calendar") holidays = {str(r[0]) for r in cur.fetchall()} finally: conn.close() base = date.today() - timedelta(days=max(offset, 0)) for delta in range(0, 30): d = base - timedelta(days=delta) if d.weekday() <= 3 and d.isoformat() not in holidays: return d.isoformat() raise AssertionError("30 天内找不到工作日测试日期") def _saturday(self) -> str: base = date.today() for delta in range(0, 14): d = base + timedelta(days=delta) if d.weekday() == 5: return d.isoformat() raise AssertionError("两周内找不到周六") def _run(self, invoices, **context): from service.prereview_service import run_prereview ctx = { "purpose": "", "project_no": "", "trip_start": "", "trip_end": "", "trip_city": "", "staff_title": "", } ctx.update(context) return run_prereview(invoices, ctx) class TestCnyAmount(PrereviewRuleTestBase): def test_parse_common_upper_amounts(self) -> None: from util.cny_amount import parse_cny_upper cases = { "壹仟陆佰伍拾元整": 1650.0, "壹佰贰拾叁元肆角伍分": 123.45, "拾元整": 10.0, "壹万贰仟零壹佰元整": 12100.0, "人民币叁元整": 3.0, } for text, expected in cases.items(): with self.subTest(text=text): self.assertEqual(parse_cny_upper(text), expected) def test_parse_garbage_returns_none(self) -> None: from util.cny_amount import parse_cny_upper self.assertIsNone(parse_cny_upper("")) self.assertIsNone(parse_cny_upper("abc")) self.assertIsNone(parse_cny_upper("123.45")) class TestFieldCompleteness(PrereviewRuleTestBase): def test_f1_missing_fields_warn(self) -> None: result = self._run([{"invoiceNumber": "24312000000012345678"}]) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["F1"], _WARN) self.assertEqual(result["reports"][0]["verdict"], "待确认") def test_f2_unknown_length_warn(self) -> None: result = self._run([{"invoiceNumber": "12345", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}]) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["F2"], _WARN) def test_f2_dianpiao_20_digits_pass(self) -> None: result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}]) items = _levels(result["reports"][0]["items"]) # 号码位数合法时不额外出 F2 项(无异常即通过) self.assertEqual(items.get("F2", _PASS), _PASS) def test_f3_future_date_reject(self) -> None: future = (date.today() + timedelta(days=400)).isoformat() result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": future, "amount": 10, "sellerName": "x"}]) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["F3"], _REJECT) self.assertEqual(result["reports"][0]["verdict"], "驳回") def test_f4_words_mismatch_reject(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 1650.0, "amountInWords": "壹佰元整", "sellerName": "x", } ] ) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["F4"], _REJECT) def test_f4_words_match_pass(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 1650.0, "amountInWords": "壹仟陆佰伍拾元整", "sellerName": "x", } ] ) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["F4"], _PASS) class TestCrossVerification(PrereviewRuleTestBase): def test_v1_unverified_warn(self) -> None: result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}]) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["V1"], _WARN) def test_v1_manual_fail_rejects(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x", "manualVerifyResult": "fail", } ] ) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["V1"], _REJECT) self.assertEqual(result["reports"][0]["verdict"], "驳回") def test_v2_whitelist_pass_and_reject(self) -> None: good = "91440101MA9XXYYY0A" ok = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x", "buyerTaxNo": good}]) self.assertEqual(_levels(ok["reports"][0]["items"])["V2"], _PASS) bad = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x", "buyerTaxNo": "00000000000000000X"}]) self.assertEqual(_levels(bad["reports"][0]["items"])["V2"], _REJECT) def test_v3_outside_trip_window_warn(self) -> None: from db.connection import get_conn conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT holiday_date FROM holiday_calendar") holidays = {str(r[0]) for r in cur.fetchall()} finally: conn.close() trip_start = date.today() + timedelta(days=10) trip_start = trip_start + timedelta(days=(7 - trip_start.weekday()) % 7) # 移到周一 issue = trip_start - timedelta(days=30) for _ in range(10): if issue.weekday() <= 3 and issue.isoformat() not in holidays: break issue = issue - timedelta(days=1) result = self._run( [{"invoiceNumber": "24312000000012345678", "issueDate": issue.isoformat(), "amount": 10, "sellerName": "x"}], trip_start=trip_start.isoformat(), trip_end=(trip_start + timedelta(days=2)).isoformat(), ) self.assertEqual(_levels(result["reports"][0]["items"])["V3"], _WARN) def test_v3_inside_trip_window_pass(self) -> None: start = self._weekday() end_date = date.fromisoformat(start) + timedelta(days=2) issue = date.fromisoformat(start) + timedelta(days=1) result = self._run( [{"invoiceNumber": "24312000000012345678", "issueDate": issue.isoformat(), "amount": 10, "sellerName": "x"}], trip_start=start, trip_end=end_date.isoformat(), ) self.assertEqual(_levels(result["reports"][0]["items"])["V3"], _PASS) class TestExpenseStandards(PrereviewRuleTestBase): def test_s1_over_lodging_cap_rejects(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 2400.0, "sellerName": "酒店", "expenseCategory": "住宿费", "city": "北京", "days": 2, } ], staff_title="副高及以上", ) items = _levels(result["reports"][0]["items"]) self.assertEqual(items["S1"], _REJECT) # 1200 元/天 > 550 self.assertIn("550", result["reports"][0]["items"][_level_index(result, "S1")]["detail"]) def test_s1_within_cap_passes(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 1080.0, "sellerName": "酒店", "expenseCategory": "住宿费", "city": "北京", "days": 2, } ], staff_title="副高及以上", ) self.assertEqual(_levels(result["reports"][0]["items"])["S1"], _PASS) # 540 元/天 ≤ 550 def test_s1_unknown_city_warns(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 900.0, "sellerName": "酒店", "expenseCategory": "住宿费", "city": "未知城镇", "days": 1, } ], staff_title="副高及以上", ) self.assertEqual(_levels(result["reports"][0]["items"])["S1"], _WARN) def test_s2_ticket_class_above_title_warns(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 100.0, "sellerName": "铁路", "expenseCategory": "交通费", "ticketClass": "商务座", } ], staff_title="中级及以下", ) self.assertEqual(_levels(result["reports"][0]["items"])["S2"], _WARN) def test_s3_project_hint_pass(self) -> None: result = self._run( [{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}], project_no="KYGX2026001", ) self.assertEqual(_levels(result["reports"][0]["items"])["S3"], _PASS) class TestRiskFlags(PrereviewRuleTestBase): def test_r1_consecutive_numbers_warn(self) -> None: issue = self._weekday() result = self._run( [ {"invoiceNumber": "24312000000012345678", "issueDate": issue, "amount": 10, "sellerName": "a"}, {"invoiceNumber": "24312000000012345679", "issueDate": issue, "amount": 10, "sellerName": "b"}, ] ) for report in result["reports"]: self.assertEqual(_levels(report["items"])["R1"], _WARN) def test_r2_batch_concentration_warn(self) -> None: issue = self._weekday() result = self._run( [ {"invoiceNumber": f"243120000000123450{i:02d}", "issueDate": issue, "amount": 10, "sellerName": "同一商家", "sellerTaxNo": "91110000TEST00001X"} for i in range(1, 4) ] ) for report in result["reports"]: self.assertEqual(_levels(report["items"])["R2"], _WARN) def test_r3_weekend_invoicing_warns(self) -> None: result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._saturday(), "amount": 10, "sellerName": "x"}]) self.assertEqual(_levels(result["reports"][0]["items"])["R3"], _WARN) class TestVerdictAggregation(PrereviewRuleTestBase): def test_clean_invoice_passes(self) -> None: result = self._run( [ { "invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10.0, "amountInWords": "拾元整", "sellerName": "商店", "manualVerifyResult": "pass", "buyerTaxNo": "91440101MA9XXYYY0A", } ] ) self.assertEqual(result["reports"][0]["verdict"], "通过") self.assertEqual(result["verdict_counts"], {"通过": 1, "待确认": 0, "驳回": 0}) def test_batch_counts_and_batch_no_shared(self) -> None: issue = self._weekday() result = self._run( [ {"invoiceNumber": "24312000000012345678", "issueDate": issue, "amount": 10, "sellerName": "a", "manualVerifyResult": "pass", "buyerTaxNo": "91440101MA9XXYYY0A"}, {"invoiceNumber": "24312000000012345999", "issueDate": issue, "amount": 10, "sellerName": "b"}, ] ) self.assertEqual(result["count"], 2) self.assertEqual(result["verdict_counts"], {"通过": 1, "待确认": 1, "驳回": 0}) batch_nos = {r["batch_no"] for r in result["reports"]} self.assertEqual(len(batch_nos), 1) self.assertEqual(len(result["batch_no"]), 16) def test_report_file_written(self) -> None: result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}]) path = result["reports"][0]["report_file"] self.assertTrue(path.endswith(".html")) with open(path, encoding="utf-8") as f: html_text = f.read() self.assertIn("初审意见单", html_text) self.assertIn("不构成最终报销结论", html_text) def test_empty_input_raises(self) -> None: with self.assertRaises(ValueError): self._run([]) def _level_index(result, rule_id: str) -> int: items = result["reports"][0]["items"] for idx, item in enumerate(items): if item["rule_id"] == rule_id: return idx raise AssertionError(f"rule {rule_id} not found") class TestMarkVerified(unittest.TestCase): def test_fail_upgrades_verdict_to_reject(self) -> None: with IsolatedDataRoot(): from db.connection import init_db from service.prereview_service import run_prereview from service.standards_service import get_report, mark_verified init_db() result = run_prereview( [{"invoiceNumber": "24312000000012345678", "issueDate": "2099-01-01", "amount": 10, "sellerName": "x"}], {"purpose": "", "project_no": "", "trip_start": "", "trip_end": "", "trip_city": "", "staff_title": ""}, ) report_id = result["reports"][0]["report_id"] payload = mark_verified(report_id, "fail", "查验不符") self.assertEqual(payload["verify_status"], "已查验不符") report = get_report(report_id) self.assertEqual(report["verdict"], "驳回") self.assertEqual(report["reject_json"][-1]["rule_id"], "V1") class TestWhitelistAndStandardsServices(unittest.TestCase): def test_whitelist_add_duplicate_remove(self) -> None: with IsolatedDataRoot(): from db.connection import init_db from service.standards_service import add_whitelist, list_whitelist, remove_whitelist init_db() add_whitelist("91440101TESTAAAA01", "测试单位", "n") with self.assertRaises(ValueError): add_whitelist("91440101TESTAAAA01", "重复税号") self.assertEqual(len(list_whitelist()), 2) # 种子 1 条 + 新增 1 条 remove_whitelist("91440101TESTAAAA01") with self.assertRaises(ValueError): remove_whitelist("91440101TESTAAAA01") def test_set_standard_upsert(self) -> None: with IsolatedDataRoot(): from db.connection import init_db from service.standards_service import list_standards, upsert_standard init_db() payload = upsert_standard( category="住宿费", city_tier="一线城市", staff_title="副高及以上", max_amount=600.0, unit="元/人/天", basis_doc="《差旅费管理办法》", clause="第十条", ) self.assertEqual(payload["action"], "updated") row = next(s for s in list_standards("住宿费") if s["city_tier"] == "一线城市" and s["staff_title"] == "副高及以上") self.assertEqual(row["max_amount"], 600.0) if __name__ == "__main__": unittest.main()