Files
X-Financial/server/src/app/services/expense_claim_approval_routing.py
caoxiaozhu 92444e7eae feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
2026-06-01 17:07:14 +08:00

228 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_constants import AI_REVIEW_LOOKBACK_DAYS
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
risk_flag_business_stage,
with_risk_business_stage,
)
class ExpenseClaimApprovalRoutingMixin:
_BUDGET_REVIEW_RATINGS = {"block"}
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
_ROUTE_RISK_SOURCES = {
"attachment_analysis",
"budget",
"budget_control",
"manual_return",
"platform_risk",
"platform_risk_rule",
"policy_review",
"risk_rule",
"scene_policy",
"submission_review",
"travel_policy",
}
_ROUTE_IGNORED_SOURCES = {
"application_detail",
"application_handoff",
"approval_routing",
"budget_approval",
"finance_approval",
"manual_approval",
"payment",
}
_ROUTE_RISK_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
"platform_risk_rule_hit",
"risk_rule_hit",
}
_BUDGET_ROUTE_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
}
def _build_approval_route_decision(
self,
claim: ExpenseClaim,
*,
is_application_claim: bool,
) -> dict[str, Any]:
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
budget_reasons = self._collect_budget_route_reasons(budget_result)
current_risk_reasons = self._collect_current_route_risk_reasons(
claim.risk_flags_json,
business_stage=business_stage,
)
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
historical_risk_reasons = (
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
if historical_risk_count > 0
else []
)
reasons = self._dedupe_reasons(
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
)
requires_budget_review = bool(reasons)
route = (
"budget_manager"
if requires_budget_review
else "approval_done"
if is_application_claim
else "finance"
)
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
return with_risk_business_stage(
{
"source": "approval_routing",
"event_type": (
"expense_application_route_decision"
if is_application_claim
else "expense_claim_route_decision"
),
"severity": "medium" if requires_budget_review else "info",
"label": label,
"message": message,
"requires_budget_review": requires_budget_review,
"route": route,
"reasons": reasons,
"budget_result": self._compact_budget_result(budget_result),
"current_risk_count": len(current_risk_reasons),
"historical_risk_count": historical_risk_count,
"created_at": datetime.now(UTC).isoformat(),
},
business_stage,
)
def _collect_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
rating = str(budget_result.get("rating") or "").strip().lower()
risk_level = str(budget_result.get("risk_level") or "").strip().lower()
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)
else {}
)
reasons: list[str] = []
if context.get("budget_applicable") is True and context.get("matched") is False:
reasons.append("未匹配到可用预算池")
if rating in self._BUDGET_REVIEW_RATINGS:
summary = str(budget_result.get("summary") or "").strip()
reasons.append(summary or f"预算测算评级为 {rating}")
if risk_level in self._BUDGET_REVIEW_RISK_LEVELS:
reasons.append(f"预算风险等级为 {risk_level}")
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
reasons.append(f"预计超预算 {over_budget_amount}")
return self._dedupe_reasons(reasons)
def _collect_current_route_risk_reasons(
self,
risk_flags: list[Any] | None,
*,
business_stage: str,
) -> list[str]:
reasons: list[str] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
continue
flag_stage = risk_flag_business_stage(flag)
if flag_stage and flag_stage != business_stage:
continue
if not self._is_substantive_route_risk_flag(flag):
continue
label = str(flag.get("label") or flag.get("event_type") or "风险标记").strip()
message = str(flag.get("message") or "").strip()
reasons.append(f"{label}{message}" if message else label)
return self._dedupe_reasons(reasons)
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
filters = []
if claim.employee_id:
filters.append(ExpenseClaim.employee_id == claim.employee_id)
elif claim.employee_name:
filters.append(ExpenseClaim.employee_name == claim.employee_name)
if not filters:
return 0
since = datetime.now(UTC) - timedelta(days=AI_REVIEW_LOOKBACK_DAYS)
stmt = (
select(ExpenseClaim)
.where(or_(*filters))
.where(ExpenseClaim.id != claim.id)
.where(ExpenseClaim.occurred_at >= since)
)
return sum(
1
for item in self.db.scalars(stmt).all()
if any(
self._is_substantive_route_risk_flag(flag)
for flag in list(item.risk_flags_json or [])
if isinstance(flag, dict)
)
)
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
source = str(flag.get("source") or "").strip().lower()
if source in self._ROUTE_IGNORED_SOURCES:
return False
event_type = str(flag.get("event_type") or "").strip().lower()
severity = str(flag.get("severity") or "").strip().lower()
if source in {"budget", "budget_control"}:
return event_type in self._BUDGET_ROUTE_EVENT_TYPES or severity in {"high", "critical", "danger"}
if event_type in self._ROUTE_RISK_EVENT_TYPES:
return True
if severity in self._ROUTE_RISK_SEVERITIES:
return source in self._ROUTE_RISK_SOURCES or bool(source)
return source in self._ROUTE_RISK_SOURCES and bool(flag.get("triggered"))
@staticmethod
def _compact_budget_result(budget_result: dict[str, Any]) -> dict[str, Any]:
return {
"score": budget_result.get("score"),
"rating": budget_result.get("rating"),
"risk_level": budget_result.get("risk_level"),
"summary": budget_result.get("summary"),
"metrics": budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {},
}
@staticmethod
def _dedupe_reasons(reasons: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for reason in reasons:
text = str(reason or "").strip()
if not text or text in seen:
continue
seen.add(text)
deduped.append(text)
return deduped
@staticmethod
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")