feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
199
server/src/app/services/risk_rule_manifest_normalizer.py
Normal file
199
server/src/app/services/risk_rule_manifest_normalizer.py
Normal 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无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
)
|
||||
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()]
|
||||
Reference in New Issue
Block a user