rules.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """可解释规则只处理显式字段与上下文,绝不从缺失数据推测金额或法定税率。"""
  2. from datetime import date, timedelta
  3. from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
  4. from typing import Any
  5. from app.shared.common import cents
  6. CORE_RULES = {"invoice_date_valid", "invoice_amount_valid", "invoice_header_valid", "invoice_no_duplicate"}
  7. def result(rule: dict, passed: bool, message: str, severity: str | None = None,
  8. details: dict | None = None, execution_status: str = "success") -> dict:
  9. return {"rule_code": rule["code"], "rule_version": rule.get("version", 1), "passed": passed,
  10. "message": message, "severity": severity or rule.get("severity", "warning"),
  11. "details": details or {}, "execution_status": execution_status}
  12. def check_rule(rule: dict, invoice: dict, context: dict) -> dict:
  13. """每个分支保留实际值和阈值,历史记录能解释为什么触发,而非只留布尔值。"""
  14. code, params = rule["code"], rule.get("params", {})
  15. if not rule.get("enabled", True):
  16. return result(rule, True, "管理员停用了可选检查", execution_status="not_applicable")
  17. scope = rule.get("scope", {})
  18. if scope.get("types") and context.get("type") not in scope["types"] or scope.get("departments") and context.get("department_id") not in scope["departments"]:
  19. return result(rule, True, "不在该规则适用范围", execution_status="not_applicable")
  20. if code == "invoice_date_valid":
  21. value = invoice.get("invoice_date")
  22. if not value:
  23. return result(rule, False, "发票日期缺失,无法确认有效期", "error")
  24. try:
  25. actual = date.fromisoformat(str(value))
  26. except (ValueError, TypeError):
  27. return result(rule, False, "日期格式必须为 YYYY-MM-DD", "error")
  28. today = date.fromisoformat(context["today"]) if context.get("today") else date.today()
  29. if actual > today:
  30. return result(rule, False, "发票日期晚于审核业务日期", "error", {"actual": value, "today": str(today)})
  31. age = (today - actual).days
  32. if age > params.get("max_age_days", 365):
  33. return result(rule, False, "发票超过配置的有效期,需复核", "warning", {"age_days": age, "limit": params.get("max_age_days", 365)})
  34. return result(rule, True, "发票日期未触发异常", details={"age_days": age})
  35. if code == "invoice_amount_valid":
  36. try:
  37. amount = cents(invoice.get("amount"), False)
  38. tax = cents(invoice.get("tax_amount"))
  39. except Exception:
  40. return result(rule, False, "金额/税额缺失或精度非法", "error")
  41. if amount > cents(params.get("large_amount", "100000.00")):
  42. return result(rule, False, "单张发票超过大额提醒阈值", "warning", {"actual": invoice["amount"], "limit": params.get("large_amount")})
  43. return result(rule, True, "金额为正且税额精度有效", details={"net_cents": amount, "tax_cents": tax})
  44. if code == "invoice_header_valid":
  45. if not str(invoice.get("buyer_name") or "").strip() or not str(invoice.get("seller_name") or "").strip():
  46. return result(rule, False, "买方或卖方抬头缺失", "error")
  47. company = params.get("company_name", "").strip()
  48. if company and invoice["buyer_name"].strip() != company:
  49. return result(rule, False, "买方名称与配置企业全称不同", "error", {"actual": invoice["buyer_name"], "expected": company})
  50. return result(rule, True, "抬头完整" + ("且符合企业全称" if company else ";未配置企业全称,仅核对完整性"))
  51. if code == "invoice_no_duplicate":
  52. file_hash = invoice.get("file_hash")
  53. if not file_hash:
  54. return result(rule, False, "缺少文件指纹,无法完成重复校验", "error")
  55. duplicate = file_hash in context.get("existing_hashes", []) or context.get("same_hash_count", {}).get(file_hash, 0) > 1
  56. return result(rule, not duplicate, "发现相同文件内容的有效报销" if duplicate else "未发现同内容重复", "critical", {"file_hash": file_hash, "method": "sha256_content_only"})
  57. if code == "tax_calculation_valid":
  58. if invoice.get("tax_rate") is None or invoice.get("amount") is None or invoice.get("tax_amount") is None:
  59. return result(rule, False, "未填税率/金额/税额,税额待人工核验", "warning", execution_status="skipped")
  60. try:
  61. rate = Decimal(str(invoice["tax_rate"]))
  62. if not rate.is_finite() or not 0 <= rate <= 1:
  63. raise ValueError()
  64. expected = (Decimal(str(invoice["amount"])) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
  65. actual = Decimal(str(invoice["tax_amount"]))
  66. passed = abs(expected - actual) <= Decimal(str(params.get("tolerance", "0.01")))
  67. return result(rule, passed, "税额符合填报税率" if passed else "税额与填报税率计算不符", details={"expected": str(expected), "actual": str(actual), "tax_rate": str(rate), "tolerance": params.get("tolerance", "0.01")})
  68. except (InvalidOperation, ValueError, TypeError):
  69. return result(rule, False, "税率或金额非法,不能进行计算")
  70. if code == "tax_number_valid":
  71. value = str(invoice.get("tax_number") or "")
  72. valid = len(value) in params.get("lengths", [15, 18, 20]) and value.isalnum() and value.isascii()
  73. return result(rule, valid, "税号格式符合示例长度规则,未进行税务验真" if valid else "税号为空或长度/字符不符合配置", details={"length": len(value), "allowed_lengths": params.get("lengths", [15, 18, 20])})
  74. raise ValueError("政策规则必须经检索服务执行")
  75. def compliance(results: list[dict]) -> str:
  76. """critical 与 error 一样失败;跳过和未知检查不能被误汇总为全通过。"""
  77. if not results:
  78. return "fail"
  79. if any(not item["passed"] and item["severity"] in {"error", "critical"} for item in results):
  80. return "fail"
  81. if any(not item["passed"] or item.get("execution_status") in {"skipped", "failed", "partial"} for item in results):
  82. return "partial"
  83. return "pass"
  84. def risk_score(invoices: list[dict], context: dict, config: dict) -> dict:
  85. """整数权重避免 0.3/0.6 边界的浮点累加误差;缺统计样本不加虚构分数。"""
  86. amounts = [cents(item["amount"]) + cents(item["tax_amount"]) for item in invoices if item.get("amount") is not None and item.get("tax_amount") is not None]
  87. total = sum(amounts)
  88. confidences = [item.get("confidence", 0) for item in invoices]
  89. sellers = [str(item.get("seller_name") or "").strip() for item in invoices if item.get("seller_name")]
  90. concentration = max([sellers.count(x) / len(sellers) for x in set(sellers)], default=0)
  91. minimum = min(confidences, default=0)
  92. dept_average = context.get("dept_average_cents")
  93. average_decimal = Decimal(str(dept_average)) if dept_average is not None else None
  94. ratio = float(Decimal(total) / average_decimal) if average_decimal is not None and average_decimal > 0 else None
  95. features = {"total_amount": str(Decimal(total) / 100), "invoice_count": len(invoices),
  96. "avg_amount": str(Decimal(total) / max(len(amounts), 1) / 100), "max_single_amount": str(Decimal(max(amounts, default=0)) / 100),
  97. "avg_confidence": sum(confidences) / max(len(confidences), 1), "min_confidence": minimum,
  98. "seller_concentration": concentration, "type_diversity": len(set(item.get("invoice_type") for item in invoices)),
  99. "user_submission_frequency": context.get("recent_submissions"), "dept_avg_amount_ratio": ratio,
  100. "statistic_status": "available" if dept_average else "insufficient_data", "window_days": config.get("window_days", 30)}
  101. score, reasons = 0, []
  102. if total > cents(config.get("amount_high", "50000.00")):
  103. score += 40
  104. reasons.append("含税总额超过高额阈值 +0.40")
  105. elif total > cents(config.get("amount_medium", "20000.00")):
  106. score += 20
  107. reasons.append("含税总额超过中额阈值 +0.20")
  108. if minimum < 0.6:
  109. score += 30
  110. reasons.append("提取置信度标记低于0.60 +0.30")
  111. elif minimum < 0.8:
  112. score += 15
  113. reasons.append("提取置信度标记低于0.80 +0.15")
  114. if concentration > 0.8 and len(invoices) > 3:
  115. score += 20
  116. reasons.append("多张发票卖方高度集中 +0.20")
  117. if context.get("recent_submissions") is not None and context["recent_submissions"] > config.get("frequency_limit", 10):
  118. score += 15
  119. reasons.append("近期实际提交次数偏高 +0.15")
  120. if ratio is not None and ratio > config.get("dept_ratio_limit", 3):
  121. score += 20
  122. reasons.append("超过部门历史平均倍数阈值 +0.20")
  123. return {"level": 3 if score >= 60 else 2 if score >= 30 else 1, "score": str(Decimal(min(score, 100)) / 100),
  124. "features": features, "reasons": reasons or ["未触发规则风险阈值"]}
  125. def decide(state: dict) -> dict:
  126. """路由只读取规范化结果;没有依据或上游未评估时不能使用低风险默认值。"""
  127. invoices = state.get("extracted_invoices", [])
  128. missing = not invoices or any(item.get("amount") is None or not item.get("invoice_date") for item in invoices)
  129. level, status = state.get("risk_level"), state.get("compliance_status")
  130. if missing or level not in {1, 2, 3} or status not in {"pass", "partial", "fail"} or state.get("core_failure"):
  131. decision, reason = "to_manual", "关键依据缺失或核心环节未完成,转人工复核"
  132. elif level == 3 or status == "fail":
  133. decision, reason = "to_finance", "高风险或存在严重合规失败,转财务审核"
  134. elif level == 2 or status == "partial":
  135. decision, reason = "to_supervisor", "中风险或部分检查待核验,转主管复核"
  136. else:
  137. decision, reason = "auto_approve", "明确低风险且必需检查全部通过,自动批准"
  138. return {"decision": decision, "decision_reason": reason}