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

@@ -0,0 +1,199 @@
from __future__ import annotations
from typing import Any
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramField,
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
)
from app.services.risk_rule_generation_semantics import (
CITY_ATTACHMENT_FIELDS,
CITY_CONSISTENCY_SEMANTIC_TYPE,
CITY_CONSISTENCY_SEMANTIC_TYPES,
CITY_EXCEPTION_FIELDS,
CITY_EXCEPTION_KEYWORDS,
CITY_HOME_FIELDS,
CITY_REFERENCE_FIELDS,
build_city_consistency_params,
is_city_consistency_rule,
)
RISK_LEVEL_LABELS = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
"critical": "极高风险",
}
CITY_ROUTE_CONDITION_SUMMARY = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。"
)
CITY_ROUTE_FLOW_DECISION = (
"附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市"
)
CITY_ROUTE_FLOW_EVIDENCE = (
"读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
)
def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
"""把历史误编译的城市一致性规则规范为受控语义 DSL。"""
if not isinstance(manifest, dict) or not _looks_like_city_consistency_manifest(manifest):
return manifest
payload = dict(manifest)
metadata = dict(payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {})
params = dict(payload.get("params") if isinstance(payload.get("params"), dict) else {})
exception_keywords = _read_string_list(
payload.get("exception_keywords") or params.get("exception_keywords")
) or list(CITY_EXCEPTION_KEYWORDS)
field_keys = _resolve_city_field_keys(payload, params)
severity = _resolve_severity(payload)
severity_label = RISK_LEVEL_LABELS.get(severity, "中风险")
payload["template_key"] = "field_compare_v1"
payload["semantic_type"] = CITY_CONSISTENCY_SEMANTIC_TYPE
payload["keywords"] = []
payload["exception_keywords"] = exception_keywords
params.update(
build_city_consistency_params(
{
"exception_keywords": exception_keywords,
}
)
)
params["template_key"] = "field_compare_v1"
params["field_keys"] = field_keys
params["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
payload["params"] = params
payload["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
flow = dict(metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {})
flow["evidence"] = CITY_ROUTE_FLOW_EVIDENCE
flow["decision"] = CITY_ROUTE_FLOW_DECISION
flow.setdefault(
"start",
"差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
)
flow.setdefault(
"pass",
"票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
)
flow["fail"] = (
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"
)
metadata["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
metadata["flow"] = flow
payload["metadata"] = metadata
payload["flow_diagram_svg"] = _build_city_flow_svg(payload, field_keys, severity, severity_label)
return payload
def _looks_like_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
semantic_type = str(manifest.get("semantic_type") or params.get("semantic_type") or "").strip()
if semantic_type in CITY_CONSISTENCY_SEMANTIC_TYPES:
return True
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
text = "\n".join(
str(value or "")
for value in (
metadata.get("natural_language"),
params.get("natural_language"),
manifest.get("description"),
metadata.get("condition_summary"),
params.get("condition_summary"),
)
)
if is_city_consistency_rule(text):
return True
field_keys = set(_resolve_city_field_keys(manifest, params))
has_attachment_city = bool(field_keys & set(CITY_ATTACHMENT_FIELDS))
has_reference_city = bool(field_keys & set(CITY_REFERENCE_FIELDS))
return has_attachment_city and has_reference_city and "风险关键词" in text
def _resolve_city_field_keys(manifest: dict[str, Any], params: dict[str, Any]) -> list[str]:
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
known = {
str(item.get("key") or "").strip()
for item in input_fields
if isinstance(item, dict) and str(item.get("key") or "").strip()
}
candidates = [
*_read_string_list(manifest.get("field_keys")),
*_read_string_list(params.get("field_keys") or params.get("search_fields")),
*CITY_ATTACHMENT_FIELDS,
*CITY_REFERENCE_FIELDS,
*CITY_HOME_FIELDS,
*CITY_EXCEPTION_FIELDS,
]
resolved: list[str] = []
for key in candidates:
if known and key not in known and key not in {
*CITY_ATTACHMENT_FIELDS,
*CITY_REFERENCE_FIELDS,
*CITY_HOME_FIELDS,
*CITY_EXCEPTION_FIELDS,
}:
continue
if key not in resolved:
resolved.append(key)
return resolved
def _build_city_flow_svg(
payload: dict[str, Any],
field_keys: list[str],
severity: str,
severity_label: str,
) -> str:
inputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
label_by_key = {
str(item.get("key") or "").strip(): str(item.get("label") or item.get("key") or "").strip()
for item in input_fields
if isinstance(item, dict) and str(item.get("key") or "").strip()
}
fields = tuple(
RiskRuleFlowDiagramField(key=key, label=label_by_key.get(key) or key)
for key in field_keys[:8]
)
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
return RiskRuleFlowDiagramRenderer().render(
RiskRuleFlowDiagramSpec(
title=str(payload.get("name") or "风险规则判断流程").strip(),
domain_label=str(payload.get("risk_category") or "差旅费").strip(),
severity=severity,
severity_label=severity_label,
fields=fields,
start=str(flow.get("start") or "差旅报销单据提交").strip(),
evidence=CITY_ROUTE_FLOW_EVIDENCE,
decision=CITY_ROUTE_FLOW_DECISION,
basis=CITY_ROUTE_CONDITION_SUMMARY,
pass_text=str(flow.get("pass") or "未命中风险,继续流转").strip(),
fail_text=str(flow.get("fail") or f"命中{severity_label},进入人工复核").strip(),
)
)
def _resolve_severity(payload: dict[str, Any]) -> str:
outcomes = payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {}
fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
severity = str(fail.get("severity") or payload.get("severity") or "").strip().lower()
return severity if severity in RISK_LEVEL_LABELS else "medium"
def _read_string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item or "").strip() for item in value if str(item or "").strip()]