feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user