后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
366 lines
15 KiB
Python
366 lines
15 KiB
Python
# ruff: noqa: E501
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RiskRuleFlowDiagramField:
|
||
key: str
|
||
label: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RiskRuleFlowDiagramSpec:
|
||
title: str
|
||
domain_label: str
|
||
severity: str
|
||
severity_label: str
|
||
fields: tuple[RiskRuleFlowDiagramField, ...]
|
||
start: str
|
||
evidence: str
|
||
decision: str
|
||
basis: str
|
||
pass_text: str
|
||
fail_text: str
|
||
fact_lines: tuple[str, ...] = ()
|
||
condition_lines: tuple[str, ...] = ()
|
||
hit_logic: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RiskRuleFlowDiagramPalette:
|
||
accent: str
|
||
accent_dark: str
|
||
border: str
|
||
surface: str
|
||
|
||
|
||
class RiskRuleFlowDiagramRenderer:
|
||
"""按 fireworks-tech-graph Style 7 OpenAI Official 生成只读流程 SVG。"""
|
||
|
||
_FONT = (
|
||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, "
|
||
"'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
|
||
)
|
||
_TEXT = "#0d0d0d"
|
||
_MUTED = "#6e6e80"
|
||
_NEUTRAL_LINE = "#cbd5e1"
|
||
_NEUTRAL_BORDER = "#e2e8f0"
|
||
_NEUTRAL_SURFACE = "#ffffff"
|
||
_PALETTES = {
|
||
"low": RiskRuleFlowDiagramPalette(
|
||
accent="#2563eb",
|
||
accent_dark="#1d4ed8",
|
||
border="#bfdbfe",
|
||
surface="#eff6ff",
|
||
),
|
||
"medium": RiskRuleFlowDiagramPalette(
|
||
accent="#f97316",
|
||
accent_dark="#c2410c",
|
||
border="#fed7aa",
|
||
surface="#fff7ed",
|
||
),
|
||
"high": RiskRuleFlowDiagramPalette(
|
||
accent="#dc2626",
|
||
accent_dark="#b91c1c",
|
||
border="#fecaca",
|
||
surface="#fef2f2",
|
||
),
|
||
"critical": RiskRuleFlowDiagramPalette(
|
||
accent="#991b1b",
|
||
accent_dark="#7f1d1d",
|
||
border="#fca5a5",
|
||
surface="#fff1f2",
|
||
),
|
||
}
|
||
|
||
def render(self, spec: RiskRuleFlowDiagramSpec) -> str:
|
||
title = self._truncate(spec.title, 26)
|
||
palette = self._palette(spec.severity)
|
||
fact_lines = spec.fact_lines or self._field_lines(spec.fields)
|
||
condition_lines = spec.condition_lines or (spec.basis,)
|
||
hit_logic = spec.hit_logic or spec.basis
|
||
|
||
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-labelledby="risk-flow-title risk-flow-desc">
|
||
<title id="risk-flow-title">{self._escape(title)}流程说明</title>
|
||
<desc id="risk-flow-desc">风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。</desc>
|
||
<defs>
|
||
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||
<polygon points="0 0, 10 3.5, 0 7" fill="{self._NEUTRAL_LINE}"/>
|
||
</marker>
|
||
<marker id="arrow-risk" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||
<polygon points="0 0, 10 3.5, 0 7" fill="{palette.accent}"/>
|
||
</marker>
|
||
</defs>
|
||
<rect width="860" height="360" fill="#ffffff"/>
|
||
<rect x="18" y="18" width="824" height="324" rx="8" ry="8" fill="none" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
|
||
<text x="34" y="43" fill="{self._MUTED}" font-family="{self._FONT}" font-size="11" font-weight="500">RULE FLOW</text>
|
||
{self._node("业务输入", spec.start, 38, 142, 120, 62)}
|
||
{self._panel("字段事实", fact_lines, 196, 64, 240, 128)}
|
||
{self._panel("判断条件", condition_lines, 196, 216, 382, 104)}
|
||
{self._diamond("命中逻辑", hit_logic, 494, 80, 122, 122)}
|
||
{self._node("继续流转", spec.pass_text, 688, 76, 122, 60)}
|
||
{self._node("进入复核", spec.fail_text, 688, 226, 122, 68, palette=palette)}
|
||
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
|
||
<line x1="316" y1="192" x2="316" y2="216" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow-neutral)"/>
|
||
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
|
||
<line x1="555" y1="216" x2="555" y2="202" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow-neutral)"/>
|
||
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
|
||
<text x="651" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="500">否</text>
|
||
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="{palette.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
|
||
<text x="651" y="214" text-anchor="middle" fill="{palette.accent_dark}" font-family="{self._FONT}" font-size="10.5" font-weight="700">是</text>
|
||
</svg>"""
|
||
|
||
def _node(
|
||
self,
|
||
title: str,
|
||
body: str,
|
||
x: int,
|
||
y: int,
|
||
width: int,
|
||
height: int,
|
||
palette: RiskRuleFlowDiagramPalette | None = None,
|
||
) -> str:
|
||
body_lines = self._wrap(body, 10 if width <= 126 else 11, 1)
|
||
border = palette.border if palette else self._NEUTRAL_BORDER
|
||
stripe = palette.accent if palette else self._NEUTRAL_LINE
|
||
surface = palette.surface if palette else self._NEUTRAL_SURFACE
|
||
return f"""<g>
|
||
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="{surface}" stroke="{border}" stroke-width="1.2"/>
|
||
<rect x="{x}" y="{y}" width="3.5" height="{height}" rx="1.75" ry="1.75" fill="{stripe}"/>
|
||
<text x="{x + width / 2:.0f}" y="{y + 24}" text-anchor="middle" fill="{self._TEXT}" font-family="{self._FONT}" font-size="13" font-weight="600">{self._escape(title)}</text>
|
||
{self._text_lines(body_lines, x + width / 2, y + 43, "middle", self._MUTED, 11)}
|
||
</g>"""
|
||
|
||
def _diamond(
|
||
self,
|
||
title: str,
|
||
body: str,
|
||
x: int,
|
||
y: int,
|
||
width: int,
|
||
height: int,
|
||
) -> str:
|
||
cx = x + width / 2
|
||
cy = y + height / 2
|
||
points = f"{cx},{y} {x + width},{cy} {cx},{y + height} {x},{cy}"
|
||
body_lines = self._wrap(body, 8, 2)
|
||
return f"""<g>
|
||
<polygon points="{points}" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1.25"/>
|
||
<text x="{cx:.0f}" y="{cy - 10:.0f}" text-anchor="middle" fill="{self._TEXT}" font-family="{self._FONT}" font-size="12.5" font-weight="600">{self._escape(title)}</text>
|
||
{self._text_lines(body_lines, cx, cy + 11, "middle", self._MUTED, 10.2)}
|
||
</g>"""
|
||
|
||
def _panel(
|
||
self,
|
||
title: str,
|
||
lines: tuple[str, ...],
|
||
x: int,
|
||
y: int,
|
||
width: int,
|
||
height: int,
|
||
) -> str:
|
||
visible = [self._truncate(line, 36) for line in list(lines)[:4]]
|
||
if not visible:
|
||
visible = ["读取规则字段并归一化为判断事实"]
|
||
rows = "\n ".join(
|
||
f'<text x="{x + 16}" y="{y + 48 + index * 18}" fill="{self._TEXT}" font-family="{self._FONT}" font-size="11" font-weight="400">{self._escape(line)}</text>'
|
||
for index, line in enumerate(visible)
|
||
)
|
||
return f"""<g>
|
||
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1.2"/>
|
||
<text x="{x + 16}" y="{y + 26}" fill="{self._TEXT}" font-family="{self._FONT}" font-size="13" font-weight="650">{self._escape(title)}</text>
|
||
{rows}
|
||
</g>"""
|
||
|
||
def _note(
|
||
self,
|
||
body: str,
|
||
x: int,
|
||
y: int,
|
||
width: int,
|
||
height: int,
|
||
) -> str:
|
||
lines = self._wrap(body, 22, 1)
|
||
return f"""<g>
|
||
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
|
||
<text x="{x + 12}" y="{y + 22}" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10" font-weight="500">BASIS</text>
|
||
{self._text_lines(lines, x + 54, y + 22, "start", self._TEXT, 10.2)}
|
||
</g>"""
|
||
|
||
def _field_lines(self, fields: tuple[RiskRuleFlowDiagramField, ...]) -> tuple[str, ...]:
|
||
rows = []
|
||
for index, field in enumerate(fields[:4]):
|
||
label = field.label or field.key
|
||
rows.append(f"{chr(65 + index)}={label}[{field.key}]")
|
||
return tuple(rows)
|
||
|
||
def _text_lines(
|
||
self,
|
||
lines: list[str],
|
||
x: float,
|
||
y: float,
|
||
anchor: str,
|
||
color: str,
|
||
font_size: float,
|
||
) -> str:
|
||
return "\n ".join(
|
||
f'<text x="{x:.0f}" y="{y + index * (font_size + 5):.0f}" text-anchor="{anchor}" fill="{color}" font-family="{self._FONT}" font-size="{font_size}" font-weight="400">{self._escape(line)}</text>'
|
||
for index, line in enumerate(lines)
|
||
)
|
||
|
||
@staticmethod
|
||
def _wrap(value: str, width: int, max_lines: int) -> list[str]:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return [""]
|
||
lines = [text[index : index + width] for index in range(0, len(text), width)]
|
||
if len(lines) > max_lines:
|
||
lines = lines[:max_lines]
|
||
lines[-1] = f"{lines[-1][: max(0, width - 1)]}…"
|
||
return lines
|
||
|
||
@staticmethod
|
||
def _truncate(value: str, length: int) -> str:
|
||
text = str(value or "").strip()
|
||
return text if len(text) <= length else f"{text[: length - 1]}…"
|
||
|
||
@staticmethod
|
||
def _escape(value: str) -> str:
|
||
return html.escape(str(value or ""), quote=True)
|
||
|
||
@classmethod
|
||
def _palette(cls, severity: str) -> RiskRuleFlowDiagramPalette:
|
||
return cls._PALETTES.get(str(severity or "").strip().lower(), cls._PALETTES["medium"])
|
||
|
||
|
||
def build_risk_rule_flow_diagram_details(
|
||
payload: dict[str, Any],
|
||
fields: list[RiskRuleFlowDiagramField],
|
||
) -> dict[str, tuple[str, ...] | str]:
|
||
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
||
rule_ir = params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {}
|
||
facts = rule_ir.get("facts") if isinstance(rule_ir.get("facts"), list) else []
|
||
fact_lines = _build_fact_lines(facts, fields)
|
||
condition_lines = _build_condition_lines(params, fields)
|
||
hit_logic = _format_hit_logic(params.get("hit_logic")) or str(
|
||
params.get("formula") or params.get("condition_summary") or ""
|
||
).strip()
|
||
return {
|
||
"fact_lines": tuple(fact_lines),
|
||
"condition_lines": tuple(condition_lines),
|
||
"hit_logic": hit_logic,
|
||
}
|
||
|
||
|
||
def _build_fact_lines(
|
||
facts: list[Any],
|
||
fields: list[RiskRuleFlowDiagramField],
|
||
) -> list[str]:
|
||
label_by_key = {field.key: field.label or field.key for field in fields}
|
||
rows: list[str] = []
|
||
for fact in facts[:4]:
|
||
if not isinstance(fact, dict):
|
||
continue
|
||
fact_id = str(fact.get("id") or "").strip()
|
||
label = str(fact.get("label") or fact_id or "事实").strip()
|
||
field_keys = _read_string_list(fact.get("fields"))
|
||
field_text = "∪".join(label_by_key.get(key, key) for key in field_keys[:3])
|
||
rows.append(f"{fact_id + '=' if fact_id else ''}{label}: {field_text or '规则字段'}")
|
||
if rows:
|
||
return rows
|
||
return [
|
||
f"{chr(65 + index)}={field.label or field.key}[{field.key}]"
|
||
for index, field in enumerate(fields[:4])
|
||
]
|
||
|
||
|
||
def _build_condition_lines(
|
||
params: dict[str, Any],
|
||
fields: list[RiskRuleFlowDiagramField],
|
||
) -> list[str]:
|
||
label_by_key = {field.key: field.label or field.key for field in fields}
|
||
rows: list[str] = []
|
||
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||
for index, condition in enumerate(conditions[:4], start=1):
|
||
if not isinstance(condition, dict):
|
||
continue
|
||
rows.append(_format_condition(condition, label_by_key, index))
|
||
if rows:
|
||
return rows
|
||
summary = str(params.get("condition_summary") or "").strip()
|
||
return [summary] if summary else []
|
||
|
||
|
||
def _format_condition(condition: dict[str, Any], label_by_key: dict[str, str], index: int) -> str:
|
||
operator = str(condition.get("operator") or "").strip()
|
||
condition_id = str(condition.get("id") or f"C{index}").strip()
|
||
prefix = f"{condition_id}: "
|
||
if operator in {"not_in_scope", "not_in_set", "not_overlap"}:
|
||
left = _field_group(condition.get("left_fields"), label_by_key)
|
||
right = _field_group(condition.get("right_fields"), label_by_key)
|
||
return f"{prefix}{left} ∩ {right} = ∅"
|
||
if operator in {"in_scope", "overlap"}:
|
||
left = _field_group(condition.get("left_fields"), label_by_key)
|
||
right = _field_group(condition.get("right_fields"), label_by_key)
|
||
return f"{prefix}{left} ∩ {right} ≠ ∅"
|
||
if operator == "date_outside_range":
|
||
dates = _field_group(condition.get("date_fields"), label_by_key)
|
||
start = _field_group(condition.get("range_start_fields"), label_by_key)
|
||
end = _field_group(condition.get("range_end_fields"), label_by_key)
|
||
return f"{prefix}{dates} 不在 [{start}, {end}]"
|
||
if operator in {"contains_any", "not_contains_any"}:
|
||
fields = _field_group(condition.get("fields"), label_by_key)
|
||
keywords = "、".join(_read_string_list(condition.get("keywords"))[:4])
|
||
verb = "不含" if operator == "not_contains_any" else "包含"
|
||
return f"{prefix}{fields} {verb} {keywords or '关键词'}"
|
||
if operator in {"exists_any", "exists_all", "all_present"}:
|
||
fields = _field_group(condition.get("fields"), label_by_key)
|
||
verb = "任一有值" if operator == "exists_any" else "全部有值"
|
||
return f"{prefix}{fields} {verb}"
|
||
left = str(condition.get("left") or "").strip()
|
||
right = str(condition.get("right") or "").strip()
|
||
if left or right:
|
||
return f"{prefix}{label_by_key.get(left, left)} {operator or 'compare'} {label_by_key.get(right, right)}"
|
||
return f"{prefix}{operator or '规则条件'}"
|
||
|
||
|
||
def _field_group(value: Any, label_by_key: dict[str, str]) -> str:
|
||
keys = _read_string_list(value)
|
||
if not keys:
|
||
return "字段集合"
|
||
return "∪".join(label_by_key.get(key, key) for key in keys[:3])
|
||
|
||
|
||
def _format_hit_logic(value: Any) -> str:
|
||
if isinstance(value, str):
|
||
return value.strip()
|
||
if isinstance(value, list):
|
||
return " AND ".join(_format_hit_logic(item) for item in value if _format_hit_logic(item))
|
||
if not isinstance(value, dict):
|
||
return ""
|
||
if isinstance(value.get("all"), list):
|
||
return " AND ".join(_wrap_logic_part(item) for item in value["all"])
|
||
if isinstance(value.get("any"), list):
|
||
return " OR ".join(_wrap_logic_part(item) for item in value["any"])
|
||
if "not" in value:
|
||
return f"NOT {_wrap_logic_part(value.get('not'))}"
|
||
return ""
|
||
|
||
|
||
def _wrap_logic_part(value: Any) -> str:
|
||
text = _format_hit_logic(value)
|
||
if isinstance(value, dict) and text:
|
||
return f"({text})"
|
||
return text
|
||
|
||
|
||
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()]
|