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

@@ -15,12 +15,14 @@ from app.schemas.user_agent import (
UserAgentSuggestedAction,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.user_agent_application_dates import expand_application_time_with_days
from app.services.user_agent_application_locations import normalize_application_location
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
APPLICATION_CONTEXT_VALUES = {
"application",
@@ -152,7 +154,7 @@ class UserAgentApplicationMixin:
"我已按「费用申请 / 事前审批」来处理这条内容。",
"已识别信息:\n" + recognized_table,
f"当前还需要补充:{missing_text}",
"请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。",
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
]
)
@@ -170,7 +172,7 @@ class UserAgentApplicationMixin:
return "\n\n".join(
[
"这是模拟的费用申请结果,请核对:",
"这是费用申请核对结果,请核对:",
self._build_application_summary_table(facts),
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
]
@@ -185,7 +187,11 @@ class UserAgentApplicationMixin:
"transport_mode": "",
"amount": "",
"application_type": "",
"applicant": "",
"grade": "",
"department": "",
"position": "",
"manager_name": "",
"lodging_daily_cap": "",
"subsidy_daily_cap": "",
"transport_policy": "",
@@ -193,6 +199,12 @@ class UserAgentApplicationMixin:
"matched_city": "",
"rule_name": "",
"rule_version": "",
"hotel_amount": "",
"allowance_amount": "",
"transport_estimated_amount": "",
"transport_estimate_source": "",
"transport_estimate_confidence": "",
"policy_total_amount": "",
}
for message, is_current in self._iter_application_user_messages(payload):
partial = {
@@ -212,6 +224,41 @@ class UserAgentApplicationMixin:
if value:
facts[key] = value
context_json = payload.context_json or {}
current_user = getattr(payload, "current_user", None)
if not facts["applicant"]:
facts["applicant"] = str(
context_json.get("name")
or context_json.get("user_name")
or context_json.get("applicant")
or getattr(current_user, "name", "")
or ""
).strip()
if not facts["department"]:
facts["department"] = str(
context_json.get("department")
or context_json.get("department_name")
or context_json.get("departmentName")
or getattr(current_user, "department_name", "")
or ""
).strip()
if not facts["position"]:
facts["position"] = str(
context_json.get("position")
or context_json.get("employee_position")
or context_json.get("employeePosition")
or ""
).strip()
if not facts["manager_name"]:
facts["manager_name"] = str(
context_json.get("manager_name")
or context_json.get("managerName")
or context_json.get("direct_manager_name")
or context_json.get("directManagerName")
or getattr(current_user, "manager_name", "")
or ""
).strip()
if not facts["application_type"]:
facts["application_type"] = self._infer_application_type(facts)
facts["time"] = self._expand_application_time_with_days(
@@ -219,6 +266,7 @@ class UserAgentApplicationMixin:
facts.get("days", ""),
payload.context_json or {},
)
apply_application_system_estimate_to_facts(facts)
return facts
@staticmethod
@@ -245,7 +293,11 @@ class UserAgentApplicationMixin:
"days": pick("days"),
"transport_mode": pick("transportMode", "transport_mode"),
"amount": pick("amount"),
"applicant": pick("applicant", "name", "userName", "user_name"),
"grade": pick("grade"),
"department": pick("department", "departmentName", "department_name"),
"position": pick("position", "employeePosition", "employee_position"),
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
"transport_policy": pick("transportPolicy", "transport_policy"),
@@ -253,6 +305,12 @@ class UserAgentApplicationMixin:
"matched_city": pick("matchedCity", "matched_city"),
"rule_name": pick("ruleName", "rule_name"),
"rule_version": pick("ruleVersion", "rule_version"),
"hotel_amount": pick("hotelAmount", "hotel_amount"),
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
}
def _resolve_expense_application_step(
@@ -294,7 +352,7 @@ class UserAgentApplicationMixin:
def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]:
return [
field
for field in ("transport_mode", "amount")
for field in ("transport_mode",)
if not str(facts.get(field) or "").strip()
]
@@ -558,7 +616,7 @@ class UserAgentApplicationMixin:
def _display_application_slot_label(slot: str) -> str:
return {
"expense_type": "申请类型",
"amount": "用户预估费用",
"amount": "系统预估费用",
"time_range": "发生时间",
"time": "发生时间",
"location": "地点",
@@ -603,7 +661,7 @@ class UserAgentApplicationMixin:
"reason": ("补充申请事由", "事由:"),
"days": ("补充天数", "天数:"),
"transport_mode": ("补充出行方式", "出行方式:"),
"amount": ("补充预估费用", "用户预估费用:"),
"amount": ("补充系统预估费用", "系统预估费用:"),
}
return config.get(field, ("补充申请信息", ""))
@@ -646,17 +704,21 @@ class UserAgentApplicationMixin:
f"{label}{value or '待补充'}"
for label, value in (
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", facts.get("amount", "")),
("系统预估费用", facts.get("amount", "")),
)
)
@@ -668,17 +730,21 @@ class UserAgentApplicationMixin:
) -> str:
rows = [
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", facts.get("amount", "")),
("系统预估费用", facts.get("amount", "")),
]
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
if not visible_rows:
@@ -736,34 +802,53 @@ class UserAgentApplicationMixin:
risk_flags_json=[self._build_application_detail_flag(facts)],
)
self.db.add(claim)
self.db.flush()
from app.services.expense_claims import ExpenseClaimService
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
claim,
business_stage="expense_application",
)
platform_flags = list(platform_review.get("flags") or [])
if platform_flags:
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
self.db.commit()
self.db.refresh(claim)
return claim
@staticmethod
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
return {
"source": "application_detail",
"severity": "info",
"label": "申请详情",
"application_detail": {
"application_type": str(facts.get("application_type") or "").strip(),
"time": str(facts.get("time") or "").strip(),
"location": str(facts.get("location") or "").strip(),
"reason": str(facts.get("reason") or "").strip(),
"days": str(facts.get("days") or "").strip(),
"transport_mode": str(facts.get("transport_mode") or "").strip(),
"amount": str(facts.get("amount") or "").strip(),
"grade": str(facts.get("grade") or "").strip(),
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
"transport_policy": str(facts.get("transport_policy") or "").strip(),
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
"matched_city": str(facts.get("matched_city") or "").strip(),
"rule_name": str(facts.get("rule_name") or "").strip(),
"rule_version": str(facts.get("rule_version") or "").strip(),
return with_risk_business_stage(
{
"source": "application_detail",
"severity": "info",
"label": "申请详情",
"application_detail": {
"application_type": str(facts.get("application_type") or "").strip(),
"time": str(facts.get("time") or "").strip(),
"location": str(facts.get("location") or "").strip(),
"reason": str(facts.get("reason") or "").strip(),
"days": str(facts.get("days") or "").strip(),
"transport_mode": str(facts.get("transport_mode") or "").strip(),
"amount": str(facts.get("amount") or "").strip(),
"grade": str(facts.get("grade") or "").strip(),
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
"transport_policy": str(facts.get("transport_policy") or "").strip(),
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
"matched_city": str(facts.get("matched_city") or "").strip(),
"rule_name": str(facts.get("rule_name") or "").strip(),
"rule_version": str(facts.get("rule_version") or "").strip(),
"hotel_amount": str(facts.get("hotel_amount") or "").strip(),
"allowance_amount": str(facts.get("allowance_amount") or "").strip(),
"transport_estimated_amount": str(facts.get("transport_estimated_amount") or "").strip(),
"transport_estimate_source": str(facts.get("transport_estimate_source") or "").strip(),
"transport_estimate_confidence": str(facts.get("transport_estimate_confidence") or "").strip(),
"policy_total_amount": str(facts.get("policy_total_amount") or "").strip(),
},
},
}
"expense_application",
)
def _resolve_application_manager_name(
self,
@@ -810,6 +895,13 @@ class UserAgentApplicationMixin:
or context_json.get("departmentName")
or ""
).strip(),
manager_name=str(
context_json.get("manager_name")
or context_json.get("managerName")
or context_json.get("direct_manager_name")
or context_json.get("directManagerName")
or ""
).strip(),
)
@staticmethod