feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import hashlib
import re
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
@@ -16,6 +15,11 @@ from app.schemas.user_agent import (
UserAgentSuggestedAction,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.user_agent_application_locations import normalize_application_location
APPLICATION_CONTEXT_VALUES = {
"application",
@@ -31,35 +35,6 @@ APPLICATION_TRANSPORT_KEYWORDS = {
"火车": ("火车", "高铁", "动车", "铁路", "列车"),
"轮船": ("轮船", "", "客轮", "邮轮", "坐船"),
}
APPLICATION_DESTINATION_PREFIXES = (
"上海",
"北京",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"武汉",
"西安",
"天津",
"宁波",
"青岛",
"长沙",
"郑州",
"济南",
"合肥",
"福州",
"厦门",
"昆明",
"南昌",
"沈阳",
"大连",
"无锡",
"佛山",
"东莞",
)
APPLICATION_REASON_VERBS = (
"支撑",
"支持",
@@ -189,7 +164,7 @@ class UserAgentApplicationMixin:
f"申请单号:{application_no}",
"申请信息:\n" + self._build_application_summary_table(facts),
f"当前状态:{manager_name}审核中。",
"预算处理:预计总费用已作为预算占用参考,等待领导审核确认。",
"预算处理:用户预估费用已作为预算占用参考,等待领导审核确认。",
]
)
@@ -210,6 +185,14 @@ class UserAgentApplicationMixin:
"transport_mode": "",
"amount": "",
"application_type": "",
"grade": "",
"lodging_daily_cap": "",
"subsidy_daily_cap": "",
"transport_policy": "",
"policy_estimate": "",
"matched_city": "",
"rule_name": "",
"rule_version": "",
}
for message, is_current in self._iter_application_user_messages(payload):
partial = {
@@ -225,6 +208,10 @@ class UserAgentApplicationMixin:
if value:
facts[key] = value
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
if value:
facts[key] = value
if not facts["application_type"]:
facts["application_type"] = self._infer_application_type(facts)
facts["time"] = self._expand_application_time_with_days(
@@ -233,6 +220,40 @@ class UserAgentApplicationMixin:
)
return facts
@staticmethod
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
preview = context_json.get("application_preview")
if not isinstance(preview, dict):
return {}
fields = preview.get("fields")
if not isinstance(fields, dict):
return {}
def pick(*keys: str) -> str:
for key in keys:
value = str(fields.get(key) or "").strip()
if value:
return value
return ""
return {
"application_type": pick("applicationType", "application_type"),
"time": pick("time", "timeRange", "time_range"),
"location": pick("location"),
"reason": pick("reason"),
"days": pick("days"),
"transport_mode": pick("transportMode", "transport_mode"),
"amount": pick("amount"),
"grade": pick("grade"),
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
"transport_policy": pick("transportPolicy", "transport_policy"),
"policy_estimate": pick("policyEstimate", "policy_estimate"),
"matched_city": pick("matchedCity", "matched_city"),
"rule_name": pick("ruleName", "rule_name"),
"rule_version": pick("ruleVersion", "rule_version"),
}
def _resolve_expense_application_step(
self,
payload: UserAgentRequest,
@@ -335,23 +356,6 @@ class UserAgentApplicationMixin:
)
return match.group("value").strip() if match else ""
def _resolve_application_entity_or_label(
self,
payload: UserAgentRequest,
entity_type: str,
labels: tuple[str, ...],
) -> str:
entity_value = next(
(
str(item.normalized_value or item.value or "").strip()
for item in payload.ontology.entities
if item.type == entity_type
and str(item.normalized_value or item.value or "").strip()
),
"",
)
return entity_value or self._resolve_application_labeled_value(payload.message, labels)
def _resolve_application_location(
self,
payload: UserAgentRequest,
@@ -359,12 +363,24 @@ class UserAgentApplicationMixin:
message: str,
use_entities: bool,
) -> str:
entity_or_labeled = (
self._resolve_application_entity_or_label(payload, "location", ("地点", "业务地点", "发生地点"))
if use_entities
else self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
)
return entity_or_labeled or self._resolve_application_location_from_text(message)
labeled = self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
if labeled:
return normalize_application_location(labeled)
if use_entities:
entity_value = next(
(
str(item.normalized_value or item.value or "").strip()
for item in payload.ontology.entities
if item.type == "location"
and str(item.normalized_value or item.value or "").strip()
),
"",
)
if entity_value:
return normalize_application_location(entity_value)
return self._resolve_application_location_from_text(message)
@staticmethod
def _resolve_application_location_from_text(message: str) -> str:
@@ -380,30 +396,11 @@ class UserAgentApplicationMixin:
if not match:
continue
target = str(match.group("target") or "").strip()
location = UserAgentApplicationMixin._normalize_application_location_target(target)
location = normalize_application_location(target)
if location:
return location
return ""
@staticmethod
def _normalize_application_location_target(target: str) -> str:
text = str(target or "").strip(":,。;;")
if not text:
return ""
known = next((item for item in APPLICATION_DESTINATION_PREFIXES if text.startswith(item)), "")
if known:
return known
verb_indexes = [
index
for keyword in APPLICATION_REASON_VERBS
for index in [text.find(keyword)]
if index > 0
]
if verb_indexes:
return text[: min(verb_indexes)]
return text[:12]
@staticmethod
def _resolve_application_days(message: str) -> str:
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
@@ -445,7 +442,7 @@ class UserAgentApplicationMixin:
return ""
text = re.sub(
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|预计总费用|预计费用|预计金额|申请金额|预算|金额)[:]\s*",
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)[:]\s*",
"",
text,
)
@@ -569,7 +566,7 @@ class UserAgentApplicationMixin:
def _resolve_application_amount_from_text(message: str) -> str:
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
("用户预估费用", "预估费用", "预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
)
if labeled:
return UserAgentApplicationMixin._normalize_application_amount(labeled)
@@ -625,7 +622,7 @@ class UserAgentApplicationMixin:
def _display_application_slot_label(slot: str) -> str:
return {
"expense_type": "申请类型",
"amount": "预计金额/预算",
"amount": "用户预估费用",
"time_range": "发生时间",
"time": "发生时间",
"location": "地点",
@@ -670,7 +667,7 @@ class UserAgentApplicationMixin:
"reason": ("补充申请事由", "事由:"),
"days": ("补充天数", "天数:"),
"transport_mode": ("补充出行方式", "出行方式:"),
"amount": ("补充预计总费用", "预计总费用:"),
"amount": ("补充预费用", "用户预估费用:"),
}
return config.get(field, ("补充申请信息", ""))
@@ -718,7 +715,12 @@ class UserAgentApplicationMixin:
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("预计总费用", facts.get("amount", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", facts.get("amount", "")),
)
)
@@ -735,7 +737,12 @@ class UserAgentApplicationMixin:
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("预计总费用", facts.get("amount", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", 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:
@@ -790,13 +797,38 @@ class UserAgentApplicationMixin:
submitted_at=datetime.now(UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
risk_flags_json=[self._build_application_detail_flag(facts)],
)
self.db.add(claim)
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(),
},
}
def _resolve_application_manager_name(
self,
payload: UserAgentRequest,
@@ -930,29 +962,15 @@ class UserAgentApplicationMixin:
*,
fallback_seed: str = "",
) -> str:
raw_date = str(facts.get("time") or "")
match = re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", raw_date)
date_text = match.group(0) if match else datetime.now().strftime("%Y-%m-%d")
digits = re.sub(r"\D", "", date_text)[:8].ljust(8, "0")
seed = re.sub(r"[^A-Za-z0-9]", "", fallback_seed)[-6:] or "SIM001"
return f"APP-{digits}-{seed.upper()}"
return build_document_number("application", timestamp=datetime.now(UTC))
def _build_application_claim_no(
self,
payload: UserAgentRequest,
facts: dict[str, str],
) -> str:
context_json = payload.context_json or {}
seed_source = "|".join(
str(item or "").strip()
for item in (
context_json.get("conversation_id"),
payload.user_id,
facts.get("time"),
facts.get("location"),
facts.get("reason"),
facts.get("amount"),
)
return generate_unique_expense_claim_no(
self.db,
"application",
timestamp=datetime.now(UTC),
)
digest = hashlib.sha1(seed_source.encode("utf-8")).hexdigest()[:6]
return self._build_simulated_application_no_from_facts(facts, fallback_seed=digest)