feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -0,0 +1,227 @@
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")