feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
227
server/src/app/services/expense_claim_approval_routing.py
Normal file
227
server/src/app/services/expense_claim_approval_routing.py
Normal 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")
|
||||
Reference in New Issue
Block a user