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

@@ -3,6 +3,7 @@ from __future__ import annotations
from app.models.agent_asset import AgentAsset
from app.schemas.agent_asset import AgentAssetRuleJsonRead, AgentAssetRuleJsonWrite
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY, RULE_LIBRARY_NAMES
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
class AgentAssetJsonRuleMixin:
@@ -35,6 +36,7 @@ class AgentAssetJsonRuleMixin:
library=rule_library,
file_name=file_name,
)
payload = normalize_risk_rule_manifest(payload)
return AgentAssetRuleJsonRead(
file_name=file_name,
rule_code=str(payload.get("rule_code") or asset.code or ""),

View File

@@ -0,0 +1,250 @@
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from typing import Any
from app.core.agent_enums import AgentAssetType
from app.models.agent_asset import AgentAsset
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramField,
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
build_risk_rule_flow_diagram_details,
)
RISK_RULE_LEVEL_LABELS = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
"critical": "极高风险",
}
class AgentAssetRiskRuleLevelMixin:
_RUNTIME_JSON_BLOCK_PATTERN = re.compile(r"```json\s*\n.*?\n```", re.DOTALL)
_RISK_LEVEL_TEXT_PATTERN = re.compile(r"(低|中|高)风险")
def set_risk_rule_level(
self,
asset_id: str,
*,
risk_level: str,
actor: str,
request_id: str | None = None,
) -> AgentAsset:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
normalized_level = self._normalize_risk_rule_level(risk_level)
before = {
**self._asset_snapshot(asset),
"risk_level": self._resolve_asset_risk_level(asset),
}
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
manifest = self.rule_library_manager.read_rule_library_json(
library=rule_library,
file_name=file_name,
)
updated_manifest = self._apply_risk_level_to_manifest(asset, manifest, normalized_level)
self.rule_library_manager.write_rule_library_json(
library=rule_library,
file_name=file_name,
payload=updated_manifest,
)
config_json = dict(asset.config_json or {})
config_json["severity"] = normalized_level
config_json["risk_level"] = normalized_level
config_json["risk_level_label"] = RISK_RULE_LEVEL_LABELS[normalized_level]
asset.config_json = config_json
self._sync_current_version_runtime_json(asset, updated_manifest)
updated = self.repository.save_asset(asset)
self.audit_service.log_action(
actor=actor,
action="set_risk_rule_level",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json={
**self._asset_snapshot(updated),
"risk_level": normalized_level,
},
request_id=request_id,
)
return updated
def _apply_risk_level_to_manifest(
self,
asset: AgentAsset,
manifest: dict[str, Any],
risk_level: str,
) -> dict[str, Any]:
label = RISK_RULE_LEVEL_LABELS[risk_level]
payload = dict(manifest or {})
payload["severity"] = risk_level
outcomes = dict(payload.get("outcomes") or {})
fail_outcome = dict(outcomes.get("fail") or {})
fail_outcome["severity"] = risk_level
outcomes["fail"] = fail_outcome
outcomes.setdefault("pass", {"severity": "none", "action": "continue"})
payload["outcomes"] = outcomes
metadata = dict(payload.get("metadata") or {})
metadata["risk_level"] = risk_level
metadata["risk_level_label"] = label
metadata["risk_level_updated_at"] = datetime.now(UTC).isoformat()
if metadata.get("business_explanation"):
metadata["business_explanation"] = self._replace_risk_level_text(
metadata["business_explanation"],
label,
)
flow = dict(metadata.get("flow") or {})
flow["fail"] = self._replace_risk_level_text(
str(flow.get("fail") or f"命中{label},进入人工复核"),
label,
)
metadata["flow"] = flow
payload["metadata"] = metadata
if payload.get("description"):
payload["description"] = self._replace_risk_level_text(payload["description"], label)
params = dict(payload.get("params") or {})
params_flow = params.get("flow")
if isinstance(params_flow, dict):
next_params_flow = dict(params_flow)
next_params_flow["fail"] = self._replace_risk_level_text(
str(next_params_flow.get("fail") or f"命中{label},进入人工复核"),
label,
)
params["flow"] = next_params_flow
payload["params"] = params
payload["flow_diagram_svg"] = self._build_risk_level_flow_diagram_svg(
asset,
payload,
risk_level=risk_level,
)
return payload
def _build_risk_level_flow_diagram_svg(
self,
asset: AgentAsset,
manifest: dict[str, Any],
*,
risk_level: str,
) -> str:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
fields = self._resolve_risk_rule_diagram_fields(manifest)
details = build_risk_rule_flow_diagram_details(manifest, fields)
risk_label = RISK_RULE_LEVEL_LABELS[risk_level]
return RiskRuleFlowDiagramRenderer().render(
RiskRuleFlowDiagramSpec(
title=self._clean_risk_level_text(manifest.get("name")) or asset.name,
domain_label=self._resolve_risk_rule_domain_label(asset, manifest),
severity=risk_level,
severity_label=risk_label,
fields=tuple(fields),
start=self._clean_risk_level_text(flow.get("start")) or "业务单据提交",
evidence=self._clean_risk_level_text(flow.get("evidence")) or "读取规则字段",
decision=(
self._clean_risk_level_text(flow.get("decision"))
or self._clean_risk_level_text(params.get("condition_summary"))
or "判断是否命中风险"
),
basis=(
self._clean_risk_level_text(metadata.get("condition_summary"))
or self._clean_risk_level_text(params.get("condition_summary"))
or "根据规则字段判断是否命中风险"
),
pass_text=self._clean_risk_level_text(flow.get("pass")) or "未命中风险,继续流转",
fail_text=self._clean_risk_level_text(flow.get("fail"))
or f"命中{risk_label},进入人工复核",
fact_lines=details["fact_lines"],
condition_lines=details["condition_lines"],
hit_logic=str(details["hit_logic"] or ""),
)
)
def _resolve_risk_rule_diagram_fields(
self,
manifest: dict[str, Any],
) -> list[RiskRuleFlowDiagramField]:
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
rows = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
fields: list[RiskRuleFlowDiagramField] = []
for row in rows:
if not isinstance(row, dict):
continue
key = self._clean_risk_level_text(row.get("key"))
label = self._clean_risk_level_text(row.get("label")) or key
if key or label:
fields.append(RiskRuleFlowDiagramField(key=key, label=label))
return fields
def _resolve_risk_rule_domain_label(
self,
asset: AgentAsset,
manifest: dict[str, Any],
) -> str:
config_json = dict(asset.config_json or {})
return (
self._clean_risk_level_text(manifest.get("risk_category"))
or self._clean_risk_level_text(config_json.get("risk_category"))
or self._clean_risk_level_text(config_json.get("expense_category_label"))
or "报销"
)
def _sync_current_version_runtime_json(
self,
asset: AgentAsset,
manifest: dict[str, Any],
) -> None:
version_name = self._resolve_working_version(asset)
if not version_name:
return
version = self.repository.get_version(asset.id, version_name)
if version is None or version.content_type != "markdown":
return
runtime_block = f"```json\n{json.dumps(manifest, ensure_ascii=False, indent=2)}\n```"
content = str(version.content or "")
if self._RUNTIME_JSON_BLOCK_PATTERN.search(content):
version.content = self._RUNTIME_JSON_BLOCK_PATTERN.sub(
lambda _: runtime_block,
content,
count=1,
)
else:
version.content = f"{content.rstrip()}\n\n## 运行时 JSON\n\n{runtime_block}"
self.db.add(version)
def _resolve_asset_risk_level(self, asset: AgentAsset) -> str:
config_json = dict(asset.config_json or {})
return self._normalize_risk_rule_level(config_json.get("severity") or "medium")
@classmethod
def _replace_risk_level_text(cls, value: Any, risk_label: str) -> str:
text = str(value or "").strip()
if not text:
return text
if cls._RISK_LEVEL_TEXT_PATTERN.search(text):
return cls._RISK_LEVEL_TEXT_PATTERN.sub(risk_label, text)
return text
@staticmethod
def _normalize_risk_rule_level(value: Any) -> str:
normalized = str(value or "").strip().lower()
if normalized not in RISK_RULE_LEVEL_LABELS:
raise ValueError("风险等级仅支持 low、medium、high、critical。")
return normalized
@staticmethod
def _clean_risk_level_text(value: Any) -> str:
return str(value or "").strip()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import re
from datetime import UTC, date, datetime
from datetime import UTC, datetime
from typing import Any
from app.schemas.agent_asset import (
@@ -9,6 +8,19 @@ from app.schemas.agent_asset import (
AgentAssetRiskRuleSimulationRead,
AgentAssetRiskRuleSimulationRequest,
)
from app.services.agent_asset_risk_rule_simulation_fields import (
derive_attachment_field_value,
extract_amount,
extract_city_mentions,
extract_invoice_no,
extract_iso_date,
field_matches_simulation_key,
has_meaningful_value,
infer_goods_name,
infer_item_type,
looks_like_city_field,
refine_simulation_values_with_model,
)
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -106,6 +118,7 @@ class AgentAssetRiskRuleSimulationMixin:
safe_explicit_values = explicit_values if isinstance(explicit_values, dict) else {}
corpus = self._build_simulation_corpus(message, attachments)
city_mentions = self._extract_city_mentions(corpus)
message_city_mentions = self._extract_city_mentions(message)
for field in fields:
key = field["key"]
@@ -123,17 +136,41 @@ class AgentAssetRiskRuleSimulationMixin:
values[key] = attachment_value
source_map[key] = "ocr"
continue
inferred = self._infer_simulation_value(
key,
field.get("label") or key,
corpus=corpus,
city_mentions=city_mentions,
)
if attachments and key.startswith("attachment.") and (
"city" in key or "location" in key
):
inferred = None
else:
inferred = self._infer_simulation_value(
key,
field.get("label") or key,
corpus=corpus,
city_mentions=(
message_city_mentions
if key.startswith(("claim.", "item."))
else city_mentions
),
)
if self._has_meaningful_value(inferred):
values[key] = inferred
source_map[key] = "inferred"
self._apply_compare_city_hints(manifest, values, source_map, city_mentions)
allowed_keys = {field["key"] for field in fields}
model_values = refine_simulation_values_with_model(
getattr(self, "db", None),
fields,
message=message,
attachments=attachments,
)
for key, value in model_values.items():
if not self._has_meaningful_value(value) or key not in allowed_keys:
continue
if source_map.get(key) == "manual":
continue
values[key] = value
source_map[key] = "model_refined"
self._apply_compare_city_hints(manifest, values, source_map, message_city_mentions)
recognized_fields = self._build_recognized_fields(fields, values, source_map)
return values, source_map, recognized_fields
@@ -163,7 +200,14 @@ class AgentAssetRiskRuleSimulationMixin:
return city_mentions[0] if city_mentions else ""
if field_key.endswith("amount"):
return self._extract_amount(corpus)
if field_key.endswith("issue_date") or field_key.endswith("item_date"):
if (
field_key.endswith("issue_date")
or field_key.endswith("item_date")
or field_key.endswith("trip_start_date")
or field_key.endswith("trip_end_date")
or field_key.endswith("stay_start_date")
or field_key.endswith("stay_end_date")
):
return self._extract_iso_date(corpus)
if field_key.endswith("invoice_no"):
return self._extract_invoice_no(corpus)
@@ -195,12 +239,18 @@ class AgentAssetRiskRuleSimulationMixin:
right = str(condition.get("right") or "").strip()
if not left or not right:
continue
if self._looks_like_city_field(left):
if self._looks_like_city_field(left) and (
not self._has_meaningful_value(values.get(left))
or source_map.get(left) == "inferred"
):
values[left] = city_mentions[0]
source_map[left] = source_map.get(left) or "inferred"
if self._looks_like_city_field(right):
source_map[left] = "inferred"
if self._looks_like_city_field(right) and (
not self._has_meaningful_value(values.get(right))
or source_map.get(right) == "inferred"
):
values[right] = city_mentions[1]
source_map[right] = source_map.get(right) or "inferred"
source_map[right] = "inferred"
@staticmethod
def _normalize_simulation_attachments(
@@ -263,6 +313,9 @@ class AgentAssetRiskRuleSimulationMixin:
) -> Any:
short_key = field_key.removeprefix("attachment.")
for attachment in attachments:
derived_value = derive_attachment_field_value(short_key, attachment)
if self._has_meaningful_value(derived_value):
return derived_value
if short_key == "ocr_text":
value = attachment.get("ocr_text") or attachment.get("summary")
if self._has_meaningful_value(value):
@@ -272,34 +325,12 @@ class AgentAssetRiskRuleSimulationMixin:
continue
candidate_key = str(field.get("key") or "").strip().lower()
candidate_label = str(field.get("label") or "").strip()
if self._field_matches_simulation_key(
if field_matches_simulation_key(
candidate_key, candidate_label, short_key, label
):
return field.get("value")
return None
@staticmethod
def _field_matches_simulation_key(
candidate_key: str,
candidate_label: str,
short_key: str,
target_label: str,
) -> bool:
compact_candidate = candidate_key.replace("_", "")
compact_target = short_key.replace("_", "").lower()
if compact_target and compact_target in compact_candidate:
return True
label_text = f"{candidate_label} {target_label}"
label_map = {
"invoice_no": ("发票号", "发票号码", "票号"),
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
"route_cities": ("行程", "路线", "目的地", "出差城市"),
"goods_name": ("品名", "商品", "服务名称"),
"amount": ("金额", "价税合计", "合计"),
"issue_date": ("日期", "开票日期", "发票日期"),
}
return any(token in label_text for token in label_map.get(short_key, ()))
def _extract_execution_field_keys(self, manifest: dict[str, Any]) -> list[str]:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
@@ -308,9 +339,22 @@ class AgentAssetRiskRuleSimulationMixin:
conditions = (
params.get("conditions") if isinstance(params.get("conditions"), list) else []
)
for group_name in (
"attachment_city_fields",
"reference_city_fields",
"home_city_fields",
"exception_fields",
):
for key in self._read_string_list(params.get(group_name)):
if key not in keys:
keys.append(key)
for condition in conditions:
if not isinstance(condition, dict):
continue
for group_name in ("left_group", "right_group", "exception_fields"):
for key in self._read_string_list(condition.get(group_name)):
if key not in keys:
keys.append(key)
for side in ("left", "right"):
key = str(condition.get(side) or "").strip()
if key and key not in keys:
@@ -323,6 +367,24 @@ class AgentAssetRiskRuleSimulationMixin:
keys.append(key)
elif template_key == "field_required_v1":
return []
elif template_key == "composite_rule_v1":
conditions = (
params.get("conditions") if isinstance(params.get("conditions"), list) else []
)
for condition in conditions:
if not isinstance(condition, dict):
continue
for group_name in (
"fields",
"left_fields",
"right_fields",
"date_fields",
"range_start_fields",
"range_end_fields",
):
for key in self._read_string_list(condition.get(group_name)):
if key not in keys:
keys.append(key)
return keys
def _build_missing_fields(
@@ -334,6 +396,28 @@ class AgentAssetRiskRuleSimulationMixin:
required_keys: list[str],
) -> list[dict[str, Any]]:
labels = {field["key"]: field["label"] for field in self._extract_manifest_fields(manifest)}
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
if self._is_city_consistency_manifest(manifest):
declared_keys = self._read_string_list(
params.get("search_fields") or params.get("field_keys")
)
candidate_keys = [*required_keys, *declared_keys]
reference_keys = [
key for key in candidate_keys if key in {"claim.location", "item.item_location"}
]
attachment_keys = [
key
for key in candidate_keys
if key in {"attachment.route_cities", "attachment.hotel_city"}
]
missing_groups: list[dict[str, Any]] = []
if not any(self._has_meaningful_value(field_values.get(key)) for key in reference_keys):
missing_groups.append({"key": "claim.location", "label": labels.get("claim.location", "申报地点")})
if not any(self._has_meaningful_value(field_values.get(key)) for key in attachment_keys):
missing_groups.append({"key": "attachment.route_cities", "label": labels.get("attachment.route_cities", "行程城市")})
return missing_groups
if str(params.get("template_key") or manifest.get("template_key") or "") == "composite_rule_v1":
return []
missing: list[dict[str, Any]] = []
for key in required_keys:
value = field_values.get(key)
@@ -341,6 +425,29 @@ class AgentAssetRiskRuleSimulationMixin:
missing.append({"key": key, "label": labels.get(key, key)})
return missing
@staticmethod
def _is_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
if str(params.get("semantic_type") or "").strip() in {
"travel_city_consistency",
"travel_route_city_consistency",
}:
return True
field_keys = AgentAssetRiskRuleSimulationMixin._read_string_list(
params.get("search_fields") or params.get("field_keys")
)
has_reference = any(key in {"claim.location", "item.item_location"} for key in field_keys)
has_attachment_city = any(
key in {"attachment.route_cities", "attachment.hotel_city"} for key in field_keys
)
if not (has_reference and has_attachment_city):
return False
text = "\n".join(
str(params.get(key) or "")
for key in ("natural_language", "condition_summary", "message_template")
)
return any(term in text for term in ("一致", "不一致", "匹配", "不符", "对应", "出现在"))
def _resolve_simulation_block(
self,
manifest: dict[str, Any],
@@ -454,87 +561,35 @@ class AgentAssetRiskRuleSimulationMixin:
@staticmethod
def _extract_city_mentions(text: str) -> list[str]:
city_names = [
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"成都",
"武汉",
"重庆",
"天津",
"苏州",
"西安",
]
pattern = "|".join(re.escape(city) for city in city_names)
found: list[str] = []
for match in re.finditer(pattern, text):
city = match.group(0)
if city not in found:
found.append(city)
return found
return extract_city_mentions(text)
@staticmethod
def _extract_amount(text: str) -> str:
match = re.search(r"(\d{2,8}(?:\.\d{1,2})?)\s*(?:元|块|人民币|CNY)?", text, re.IGNORECASE)
return match.group(1) if match else ""
return extract_amount(text)
@staticmethod
def _extract_iso_date(text: str) -> str:
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
if not match:
return ""
year, month, day = (int(part) for part in match.groups())
try:
return date(year, month, day).isoformat()
except ValueError:
return ""
return extract_iso_date(text)
@staticmethod
def _extract_invoice_no(text: str) -> str:
match = re.search(r"(?:发票号|发票号码|票号)[:\s]*([A-Z0-9-]{6,32})", text, re.IGNORECASE)
return match.group(1) if match else ""
return extract_invoice_no(text)
@staticmethod
def _infer_item_type(text: str) -> str:
if not text:
return ""
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
return "住宿费"
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
return "交通费"
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
return "餐饮费"
return "差旅费"
return infer_item_type(text)
@staticmethod
def _infer_goods_name(text: str) -> str:
if not text:
return ""
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
return "住宿服务"
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
return "交通服务"
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
return "餐饮服务"
return "报销服务"
return infer_goods_name(text)
@staticmethod
def _looks_like_city_field(field_key: str) -> bool:
lowered = field_key.lower()
return "city" in lowered or "location" in lowered or lowered.endswith("route_cities")
return looks_like_city_field(field_key)
@staticmethod
def _has_meaningful_value(value: Any) -> bool:
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple, set, dict)):
return bool(value)
return True
return has_meaningful_value(value)
@staticmethod
def _risk_severity_label(severity: str) -> str:

View File

@@ -0,0 +1,385 @@
from __future__ import annotations
import json
import re
from datetime import date
from typing import Any
from app.services.runtime_chat import RuntimeChatService
CITY_NAMES = [
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"成都",
"武汉",
"重庆",
"天津",
"苏州",
"西安",
"长沙",
"郑州",
"青岛",
"厦门",
"福州",
"合肥",
"济南",
"沈阳",
"大连",
"宁波",
"温州",
"无锡",
"常州",
"昆明",
"贵阳",
"南宁",
"南昌",
"太原",
"石家庄",
"哈尔滨",
"长春",
"兰州",
"银川",
"西宁",
"乌鲁木齐",
"呼和浩特",
"海口",
"三亚",
"佛山",
"东莞",
"珠海",
"惠州",
"嘉兴",
"绍兴",
"金华",
"台州",
"泉州",
"烟台",
"徐州",
"扬州",
"南通",
"镇江",
"洛阳",
"襄阳",
"宜昌",
]
def refine_simulation_values_with_model(
db: Any,
fields: list[dict[str, str]],
*,
message: str,
attachments: list[dict[str, Any]],
) -> dict[str, Any]:
if not fields or not attachments or db is None:
return {}
compact_attachments = [
{
"name": item.get("name") or "",
"document_type_label": item.get("document_type_label") or "",
"scene_label": item.get("scene_label") or "",
"summary": item.get("summary") or "",
"ocr_text": str(item.get("ocr_text") or "")[:2500],
"document_fields": list(item.get("document_fields") or [])[:20],
}
for item in attachments[:4]
]
messages = [
{
"role": "system",
"content": (
"你是风险规则仿真测试的票据字段过滤器。只根据用户输入、OCR 文本和结构化字段,"
"把信息归一化到给定字段 key。不要判断风险不要编造不存在的信息。只输出 JSON。"
),
},
{
"role": "user",
"content": json.dumps(
{
"available_fields": fields,
"user_message": str(message or ""),
"attachments": compact_attachments,
"output_shape": {
"field_values": {
"claim.location": "城市或地点",
"attachment.route_cities": ["出发城市", "到达城市"],
}
},
"rules": [
"字段值必须来自 OCR 或用户输入。",
"车票路线要拆成城市数组,例如 上海虹桥-武汉 -> [\"上海\", \"武汉\"]。",
"没有把握的字段不要返回。",
],
},
ensure_ascii=False,
),
},
]
try:
answer = RuntimeChatService(db).complete(
messages,
max_tokens=700,
temperature=0.0,
timeout_seconds=8,
max_attempts=1,
)
except Exception:
return {}
payload = extract_json_payload(answer)
if not isinstance(payload, dict):
return {}
field_values = payload.get("field_values") if isinstance(payload.get("field_values"), dict) else {}
allowed = {field["key"] for field in fields}
return {
key: value
for key, value in field_values.items()
if key in allowed and has_meaningful_value(value)
}
def derive_attachment_field_value(short_key: str, attachment: dict[str, Any]) -> Any:
fields = [field for field in list(attachment.get("document_fields") or []) if isinstance(field, dict)]
text_parts = [
str(attachment.get("summary") or ""),
str(attachment.get("ocr_text") or ""),
*[str(field.get("value") or "") for field in fields],
]
corpus = "\n".join(part for part in text_parts if part)
if short_key == "route_cities":
for field in fields:
key = str(field.get("key") or "").strip().lower()
label = str(field.get("label") or "").strip()
if looks_like_route_field(key, label):
cities = extract_route_cities(str(field.get("value") or ""))
if cities:
return cities
cities = extract_route_cities(corpus)
return cities or None
if short_key in {"hotel_city", "city"}:
for field in fields:
key = str(field.get("key") or "").strip().lower()
label = str(field.get("label") or "").strip()
if looks_like_route_field(key, label):
continue
if looks_like_city_field(f"{key} {label}"):
cities = extract_city_mentions(str(field.get("value") or ""))
if cities:
return cities[0]
value = str(field.get("value") or "").strip()
if value:
return value
return None
alias_keys = {
"invoice_no": {"invoice_no", "invoice_number", "ticket_number", "order_no", "order_number"},
"issue_date": {"issue_date", "invoice_date", "date", "trip_date", "travel_date"},
"stay_start_date": {"stay_start_date", "check_in_date", "arrival_date", "入住日期"},
"stay_end_date": {"stay_end_date", "check_out_date", "departure_date", "离店日期"},
"amount": {"amount", "total_amount", "payment_amount", "paid_amount"},
"goods_name": {"goods_name", "service_name", "item_name", "merchant_name"},
}.get(short_key, set())
if alias_keys:
for field in fields:
key = str(field.get("key") or "").strip().lower()
label = str(field.get("label") or "").strip()
if (
key in alias_keys
or field_matches_simulation_key(key, label, short_key, short_key)
) and has_meaningful_value(field.get("value")):
return field.get("value")
return None
def field_matches_simulation_key(
candidate_key: str,
candidate_label: str,
short_key: str,
target_label: str,
) -> bool:
compact_candidate = candidate_key.replace("_", "")
compact_target = short_key.replace("_", "").lower()
if compact_target and compact_target in compact_candidate:
return True
label_text = f"{candidate_label} {target_label}"
label_map = {
"invoice_no": ("发票号", "发票号码", "票号"),
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
"route_cities": ("行程", "路线", "目的地", "出差城市"),
"goods_name": ("品名", "商品", "服务名称"),
"amount": ("金额", "价税合计", "合计"),
"issue_date": ("日期", "开票日期", "发票日期"),
"stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
"stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
}
return any(token in label_text for token in label_map.get(short_key, ()))
def extract_json_payload(response_text: str | None) -> dict[str, Any] | None:
if not response_text:
return None
cleaned = re.sub(
r"<think>.*?</think>",
"",
str(response_text),
flags=re.DOTALL | re.IGNORECASE,
).strip()
if not cleaned:
return None
candidates: list[str] = []
fenced_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, flags=re.DOTALL)
if fenced_match:
candidates.append(fenced_match.group(1))
start = cleaned.find("{")
end = cleaned.rfind("}")
if start >= 0 and end > start:
candidates.append(cleaned[start : end + 1])
candidates.append(cleaned)
for candidate in candidates:
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
return parsed
return None
def extract_city_mentions(text: str) -> list[str]:
content = str(text or "")
if not content:
return []
pattern = "|".join(re.escape(city) for city in CITY_NAMES)
found: list[str] = []
for match in re.finditer(pattern, content):
city = match.group(0)
if city not in found:
found.append(city)
return found
def extract_route_cities(text: str) -> list[str]:
mentions = extract_city_mentions(text)
if mentions:
return mentions
route_text = str(text or "")
if not route_text:
return []
fragments = re.split(r"(?:->|→|—|--|-|至|到|开往|前往|赴)", route_text)
cities: list[str] = []
for fragment in fragments:
candidate = normalize_city_token(fragment)
if candidate and candidate not in cities:
cities.append(candidate)
return cities[:4]
def normalize_city_token(value: str) -> str:
text = re.sub(r"[^\u4e00-\u9fff]", "", str(value or ""))
if not text:
return ""
for suffix in (
"火车站",
"高铁站",
"汽车站",
"机场",
"虹桥",
"东站",
"西站",
"南站",
"北站",
"",
"",
):
text = text.replace(suffix, "")
if 2 <= len(text) <= 6:
return text
return ""
def extract_amount(text: str) -> str:
match = re.search(r"(\d{2,8}(?:\.\d{1,2})?)\s*(?:元|块|人民币|CNY)?", text, re.IGNORECASE)
return match.group(1) if match else ""
def extract_iso_date(text: str) -> str:
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
if not match:
return ""
year, month, day = (int(part) for part in match.groups())
try:
return date(year, month, day).isoformat()
except ValueError:
return ""
def extract_invoice_no(text: str) -> str:
match = re.search(r"(?:发票号|发票号码|票号)[:\s]*([A-Z0-9-]{6,32})", text, re.IGNORECASE)
return match.group(1) if match else ""
def infer_item_type(text: str) -> str:
if not text:
return ""
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
return "住宿费"
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
return "交通费"
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
return "餐饮费"
return "差旅费"
def infer_goods_name(text: str) -> str:
if not text:
return ""
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
return "住宿服务"
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
return "交通服务"
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
return "餐饮服务"
return "报销服务"
def looks_like_city_field(field_key: str) -> bool:
lowered = field_key.lower()
return "city" in lowered or "location" in lowered or lowered.endswith("route_cities")
def looks_like_route_field(candidate_key: str, candidate_label: str = "") -> bool:
text = f"{candidate_key} {candidate_label}".lower()
return any(
token in text
for token in (
"route",
"trip_route",
"travel_route",
"from_to",
"origin_destination",
"行程",
"路线",
"出发",
"到达",
"目的地",
"车次",
)
)
def has_meaningful_value(value: Any) -> bool:
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple, set, dict)):
return bool(value)
return True

View File

@@ -14,6 +14,7 @@ from app.core.agent_enums import (
AgentReviewStatus,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.agent_asset import (
AgentAssetRiskRuleLatestTestSummary,
@@ -25,6 +26,7 @@ from app.schemas.agent_asset import (
)
from app.services.expense_claims import ExpenseClaimService
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
class AgentAssetRiskRuleTestingMixin:
@@ -333,6 +335,7 @@ class AgentAssetRiskRuleTestingMixin:
library=rule_library,
file_name=file_name,
)
manifest = normalize_risk_rule_manifest(manifest)
return asset, target_version, manifest
def _create_test_run(
@@ -451,6 +454,13 @@ class AgentAssetRiskRuleTestingMixin:
item_amount=self._to_decimal(values.get("item.item_amount") or claim.amount),
)
claim.items = [item]
if values.get("employee.location"):
claim.employee = Employee(
employee_no="TEST-EMPLOYEE",
name=claim.employee_name,
email="risk-rule-test@example.com",
location=str(values.get("employee.location") or ""),
)
attachment_fields = []
document_info: dict[str, Any] = {"fields": attachment_fields}
@@ -585,7 +595,14 @@ class AgentAssetRiskRuleTestingMixin:
def _default_value_for_field(field_key: str) -> Any:
if field_key.endswith("amount"):
return "100.00"
if field_key.endswith("issue_date"):
if (
field_key.endswith("issue_date")
or field_key.endswith("stay_start_date")
or field_key.endswith("stay_end_date")
or field_key.endswith("trip_start_date")
or field_key.endswith("trip_end_date")
or field_key.endswith("item_date")
):
return date.today().isoformat()
if field_key.endswith("route_cities"):
return ["北京"]

View File

@@ -28,6 +28,7 @@ from app.schemas.agent_asset import (
)
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
from app.services.agent_asset_risk_rule_level import AgentAssetRiskRuleLevelMixin
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
@@ -43,6 +44,7 @@ logger = get_logger("app.services.agent_assets")
class AgentAssetService(
AgentAssetOnlyOfficeMixin,
AgentAssetSpreadsheetHelperMixin,
AgentAssetRiskRuleLevelMixin,
AgentAssetRiskRuleTestingMixin,
AgentAssetRiskRuleSimulationMixin,
AgentAssetTimelineMixin,
@@ -106,6 +108,9 @@ class AgentAssetService(
latest_test_summary=self.get_latest_risk_rule_test_summary(asset)
if str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
== "json_risk"
and working_version
and asset.status
not in {AgentAssetStatus.GENERATING.value, AgentAssetStatus.FAILED.value}
else None,
)

View File

@@ -1,62 +1,25 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from datetime import UTC, datetime
from sqlalchemy import inspect, select, text
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
RISK_RULES_LIBRARY,
)
from app.services.agent_foundation_constants import (
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
logger = get_logger("app.services.agent_foundation")
@@ -67,20 +30,51 @@ class AgentFoundationRiskRuleMixin:
manifests: list[tuple[str, dict[str, object]]] = []
for file_name in sorted(manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)):
for file_name in sorted(
manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)
):
payload = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
if payload.get("enabled") is False:
continue
if self._is_user_generated_risk_manifest(payload):
continue
manifests.append((file_name, payload))
return manifests
@staticmethod
def _is_user_generated_risk_manifest(manifest: dict[str, object]) -> bool:
rule_code = str(manifest.get("rule_code") or "").strip().lower()
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
stability = str(metadata.get("stability") or "").strip().lower()
source_ref = str(metadata.get("source_ref") or "").strip()
if stability == "generated_draft":
return True
if source_ref == "自然语言风险规则":
return True
return ".generated_" in rule_code
@staticmethod
def _resolve_platform_risk_category(manifest: dict[str, object]) -> str:
explicit = str(manifest.get("risk_category") or "").strip()
@@ -91,7 +85,9 @@ class AgentFoundationRiskRuleMixin:
rule_code = str(manifest.get("rule_code") or "").strip().lower()
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
applies_to = (
manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
)
domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []}
@@ -133,7 +129,9 @@ class AgentFoundationRiskRuleMixin:
return [category] if category else ["通用"]
def _platform_risk_config_json(self, file_name: str, manifest: dict[str, object]) -> dict[str, object]:
def _platform_risk_config_json(
self, file_name: str, manifest: dict[str, object]
) -> dict[str, object]:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
@@ -191,7 +189,9 @@ class AgentFoundationRiskRuleMixin:
continue
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
metadata = (
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
)
source_ref = str(metadata.get("source_ref") or "").strip()
@@ -255,7 +255,11 @@ class AgentFoundationRiskRuleMixin:
"Platform risk rules synced from library",
extra={"manifest_count": manifest_count, "created_count": synced, "total": len(after_codes)},
extra={
"manifest_count": manifest_count,
"created_count": synced,
"total": len(after_codes),
},
)
@@ -271,7 +275,9 @@ class AgentFoundationRiskRuleMixin:
continue
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
metadata = (
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
)
source_ref = str(metadata.get("source_ref") or "").strip()
@@ -347,7 +353,11 @@ class AgentFoundationRiskRuleMixin:
version="v1.0.0",
content=self._platform_risk_rule_markdown(asset, manifest=manifest, file_name=file_name),
content=self._platform_risk_rule_markdown(
asset,
manifest=manifest,
file_name=file_name,
),
content_type=AgentAssetContentType.MARKDOWN.value,
@@ -389,19 +399,25 @@ class AgentFoundationRiskRuleMixin:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
rule_document = (
config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
)
resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip()
evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip()
ontology_signal = str(config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or "").strip()
ontology_signal = str(
config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or ""
).strip()
source_ref = str(config.get("source_ref") or "").strip()
if not source_ref and isinstance(manifest, dict):
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
metadata = (
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
)
source_ref = str(metadata.get("source_ref") or "").strip()
@@ -457,7 +473,10 @@ class AgentFoundationRiskRuleMixin:
return AgentFoundationRiskRuleMixin._platform_risk_rule_markdown(
AgentAsset(name="申报地点与票据地点一致", config_json={"evaluator": "location_consistency"}),
AgentAsset(
name="申报地点与票据地点一致",
config_json={"evaluator": "location_consistency"},
),
manifest={

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import re
import secrets
from datetime import UTC, datetime
from typing import Callable, Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.financial_record import ExpenseClaim
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
"application": "AP",
"reimbursement": "RE",
"audit": "AD",
}
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
DOCUMENT_NUMBER_PATTERN = re.compile(
rf"^(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}$",
flags=re.IGNORECASE,
)
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
r"|APP-\d{8}-[A-Z0-9]{6}"
r"|EXP-\d{6}-\d{3}",
flags=re.IGNORECASE,
)
def generate_document_token() -> str:
return "".join(
secrets.choice(DOCUMENT_NUMBER_TOKEN_ALPHABET)
for _ in range(DOCUMENT_NUMBER_TOKEN_LENGTH)
)
def build_document_number(
kind: DocumentNumberKind,
*,
timestamp: datetime | None = None,
token: str | None = None,
) -> str:
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
generated_at = timestamp or datetime.now(UTC)
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
normalized_token = (token or generate_document_token()).strip().upper()
if not re.fullmatch(
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
normalized_token,
):
raise ValueError("document number token must be 8 chars from the configured alphabet")
return f"{prefix}-{generated_at.astimezone(UTC):%Y%m%d%H%M%S}-{normalized_token}"
def generate_unique_expense_claim_no(
db: Session,
kind: DocumentNumberKind,
*,
timestamp: datetime | None = None,
token_factory: Callable[[], str] = generate_document_token,
max_attempts: int = 8,
) -> str:
for _ in range(max_attempts):
candidate = build_document_number(
kind,
timestamp=timestamp,
token=token_factory(),
)
exists = db.scalar(
select(ExpenseClaim.id)
.where(ExpenseClaim.claim_no == candidate)
.limit(1)
)
if exists is None:
return candidate
raise RuntimeError(f"failed to generate a unique {kind} document number")
def is_application_claim_no(value: object) -> bool:
normalized = str(value or "").strip().upper()
return normalized.startswith(("AP-", "APP-"))

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from collections import Counter
from datetime import UTC, date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
@@ -29,24 +28,31 @@ from app.schemas.employee import (
EmployeeUpdate,
)
from app.services.employee_import import EmployeeImportCoordinator
from app.services.employee_serialization import (
format_history_datetime as serialize_history_datetime,
serialize_employee,
)
from app.services.employee_serialization import serialize_employee
from app.services.employee_spreadsheet import build_import_template_bytes
from app.services.employee_seed import (
CANONICAL_DEPARTMENT_CODES,
EMPLOYEE_DEFINITIONS,
EMPLOYEE_PROFILE_REPAIRS,
LEGACY_ORGANIZATION_UNIT_CODE_MAP,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
ROLE_PERMISSION_MAP,
normalize_organization_unit_code,
)
from app.services.employee_time import (
format_date,
format_datetime,
format_history_datetime,
normalize_optional_text,
parse_date,
parse_datetime,
)
logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456"
MAX_EMPLOYEE_CHANGE_LOGS = 5
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
STATUS_TONE_MAP = {
"在职": "success",
@@ -85,6 +91,7 @@ class EmployeeService:
self._seed_roles()
self._seed_organization_units()
self._seed_employees()
self._normalize_legacy_employee_departments()
self.db.commit()
except Exception:
self.db.rollback()
@@ -132,6 +139,7 @@ class EmployeeService:
for role in self._sorted_roles(self.repository.list_roles())
]
canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES)
organization_options = [
EmployeeOrganizationRead(
id=unit.id,
@@ -143,7 +151,11 @@ class EmployeeService:
managerName=unit.manager_name,
)
for unit in sorted(
self.repository.list_organization_units(),
(
unit
for unit in self.repository.list_organization_units()
if unit.unit_code in canonical_department_codes
),
key=lambda item: item.name,
)
]
@@ -185,7 +197,8 @@ class EmployeeService:
)
if payload.organization_unit_code:
employee.organization_unit = self.repository.get_organization_by_code(payload.organization_unit_code)
organization_code = normalize_organization_unit_code(payload.organization_unit_code)
employee.organization_unit = self.repository.get_organization_by_code(organization_code)
if payload.manager_employee_no:
employee.manager = self.repository.get_by_employee_no(payload.manager_employee_no)
@@ -224,7 +237,7 @@ class EmployeeService:
changed_fields.append("姓名")
if "gender" in payload.model_fields_set:
gender = self._normalize_optional_text(payload.gender)
gender = normalize_optional_text(payload.gender)
if gender != employee.gender:
employee.gender = gender
changed_fields.append("性别")
@@ -236,7 +249,7 @@ class EmployeeService:
changed_fields.append("出生日期")
if "phone" in payload.model_fields_set:
phone = self._normalize_optional_text(payload.phone)
phone = normalize_optional_text(payload.phone)
if phone != employee.phone:
employee.phone = phone
changed_fields.append("手机号")
@@ -257,7 +270,7 @@ class EmployeeService:
changed_fields.append("入职日期")
if "location" in payload.model_fields_set:
location = self._normalize_optional_text(payload.location)
location = normalize_optional_text(payload.location)
if location != employee.location:
employee.location = location
changed_fields.append("办公地点")
@@ -279,19 +292,21 @@ class EmployeeService:
changed_fields.append("职级")
if "cost_center" in payload.model_fields_set:
cost_center = self._normalize_optional_text(payload.cost_center)
cost_center = normalize_optional_text(payload.cost_center)
if cost_center != employee.cost_center:
employee.cost_center = cost_center
changed_fields.append("成本中心")
if "finance_owner_name" in payload.model_fields_set:
finance_owner_name = self._normalize_optional_text(payload.finance_owner_name)
finance_owner_name = normalize_optional_text(payload.finance_owner_name)
if finance_owner_name != employee.finance_owner_name:
employee.finance_owner_name = finance_owner_name
changed_fields.append("财务归口")
if "organization_unit_code" in payload.model_fields_set:
organization_code = self._normalize_optional_text(payload.organization_unit_code)
organization_code = normalize_organization_unit_code(
normalize_optional_text(payload.organization_unit_code)
)
current_code = (
employee.organization_unit.unit_code if employee.organization_unit else None
)
@@ -306,7 +321,7 @@ class EmployeeService:
changed_fields.append("所属部门")
if "manager_employee_no" in payload.model_fields_set:
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
manager_employee_no = normalize_optional_text(payload.manager_employee_no)
current_manager_no = employee.manager.employee_no if employee.manager else None
if manager_employee_no:
@@ -448,8 +463,8 @@ class EmployeeService:
self.repository,
sorted_roles=self._sorted_roles,
append_change_log=self._append_change_log,
format_date=self._format_date,
format_datetime=self._format_datetime,
format_date=format_date,
format_datetime=format_datetime,
default_password=DEFAULT_EMPLOYEE_PASSWORD,
)
@@ -487,6 +502,12 @@ class EmployeeService:
)
self.db.add(organization)
existing_by_code[organization.unit_code] = organization
else:
organization.name = definition["name"]
organization.unit_type = definition["unit_type"]
organization.cost_center = definition.get("cost_center")
organization.location = definition.get("location")
organization.manager_name = definition.get("manager_name")
self.db.flush()
@@ -496,12 +517,30 @@ class EmployeeService:
continue
organization = existing_by_code[definition["unit_code"]]
if organization.parent_id:
parent = existing_by_code.get(parent_code)
if parent is not None and organization.parent_id != parent.id:
organization.parent = parent
self.db.flush()
def _normalize_legacy_employee_departments(self) -> None:
if not LEGACY_ORGANIZATION_UNIT_CODE_MAP:
return
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for employee in self.repository.list():
current_code = (
employee.organization_unit.unit_code if employee.organization_unit else None
)
next_code = normalize_organization_unit_code(current_code)
if not next_code or next_code == current_code:
continue
parent = existing_by_code.get(parent_code)
if parent is not None:
organization.parent = parent
organization = organizations_by_code.get(next_code)
if organization is not None:
employee.organization_unit = organization
self.db.flush()
@@ -524,9 +563,9 @@ class EmployeeService:
name=definition["name"],
email=definition["email"],
gender=definition.get("gender"),
birth_date=self._parse_date(definition.get("birth_date")),
birth_date=parse_date(definition.get("birth_date")),
phone=definition.get("phone"),
join_date=self._parse_date(definition.get("join_date")),
join_date=parse_date(definition.get("join_date")),
location=definition.get("location"),
position=definition.get("position", "员工"),
grade=definition.get("grade", "P3"),
@@ -535,8 +574,8 @@ class EmployeeService:
employment_status=definition.get("employment_status", "在职"),
sync_state=definition.get("sync_state", "已同步"),
spotlight=bool(definition.get("spotlight")),
last_sync_at=self._parse_datetime(definition.get("last_sync_at")),
updated_at=self._parse_datetime(definition.get("updated_at")),
last_sync_at=parse_datetime(definition.get("last_sync_at")),
updated_at=parse_datetime(definition.get("updated_at")),
)
self.db.add(employee)
employees_by_no[employee_no] = employee
@@ -659,7 +698,7 @@ class EmployeeService:
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = {
(item.action, item.owner, self._format_datetime(item.occurred_at))
(item.action, item.owner, format_datetime(item.occurred_at))
for item in employee.change_logs
}
@@ -674,14 +713,14 @@ class EmployeeService:
]
for history in history_items:
occurred_at = self._parse_datetime(history.get("occurred_at"))
occurred_at = parse_datetime(history.get("occurred_at"))
if occurred_at is None:
continue
identity = (
history["action"],
history["owner"],
self._format_datetime(occurred_at),
format_datetime(occurred_at),
)
if identity in existing_keys:
continue
@@ -743,9 +782,9 @@ class EmployeeService:
employee,
sorted_roles=self._sorted_roles(list(employee.roles)),
sorted_change_logs=self._sorted_change_logs(employee),
format_date=self._format_date,
format_datetime=self._format_datetime,
format_history_datetime=self._format_history_datetime,
format_date=format_date,
format_datetime=format_datetime,
format_history_datetime=format_history_datetime,
role_permission_map=ROLE_PERMISSION_MAP,
status_tone_map=STATUS_TONE_MAP,
max_change_logs=MAX_EMPLOYEE_CHANGE_LOGS,
@@ -753,52 +792,3 @@ class EmployeeService:
def _sorted_roles(self, roles: list[Role]) -> list[Role]:
return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name))
@staticmethod
def _normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
text_value = value.strip()
return text_value or None
@staticmethod
def _parse_date(value: str | None) -> date | None:
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
@staticmethod
def _parse_datetime(value: str | datetime | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return datetime.strptime(value, "%Y-%m-%d %H:%M")
@staticmethod
def _format_date(value: date | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d")
@staticmethod
def _to_display_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.astimezone(DISPLAY_TIMEZONE)
@staticmethod
def _format_datetime(value: datetime | None) -> str | None:
if value is None:
return None
local = EmployeeService._to_display_datetime(value)
return local.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _format_history_datetime(value: datetime | None) -> str:
return serialize_history_datetime(
value,
to_display_datetime=EmployeeService._to_display_datetime,
)

View File

@@ -21,6 +21,7 @@ from app.services.employee_spreadsheet import (
build_export_workbook_bytes,
parse_employee_workbook,
)
from app.services.employee_seed import normalize_organization_unit_code
logger = get_logger("app.services.employee")
@@ -52,6 +53,9 @@ class EmployeeImportCoordinator:
for employee in employees:
organization = employee.organization_unit
role_codes = ",".join(role.role_code for role in self.sorted_roles(list(employee.roles)))
organization_code = (
normalize_organization_unit_code(organization.unit_code) if organization else ""
)
rows.append(
[
employee.employee_no,
@@ -64,7 +68,7 @@ class EmployeeImportCoordinator:
employee.location or "",
employee.position,
employee.grade,
organization.unit_code if organization else "",
organization_code or "",
employee.manager.employee_no if employee.manager else "",
employee.finance_owner_name or "",
employee.cost_center or "",
@@ -167,7 +171,8 @@ class EmployeeImportCoordinator:
)
)
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
organization_code = normalize_organization_unit_code(row.organization_unit_code)
if organization_code and organization_code not in organizations_by_code:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
@@ -266,8 +271,9 @@ class EmployeeImportCoordinator:
employee.sync_state = "已同步"
employee.last_sync_at = now
if row.organization_unit_code:
employee.organization_unit = organizations_by_code[row.organization_unit_code]
organization_code = normalize_organization_unit_code(row.organization_unit_code)
if organization_code:
employee.organization_unit = organizations_by_code[organization_code]
else:
employee.organization_unit = None

View File

@@ -1,7 +1,13 @@
from __future__ import annotations
from app.services.employee_seed_roles import ROLE_DEFINITIONS, ROLE_DISPLAY_ORDER, ROLE_PERMISSION_MAP
from app.services.employee_seed_organizations import EMPLOYEE_PROFILE_REPAIRS, ORGANIZATION_DEFINITIONS
from app.services.employee_seed_organizations import (
CANONICAL_DEPARTMENT_CODES,
EMPLOYEE_PROFILE_REPAIRS,
LEGACY_ORGANIZATION_UNIT_CODE_MAP,
ORGANIZATION_DEFINITIONS,
normalize_organization_unit_code,
)
from app.services.employee_seed_part1 import EMPLOYEE_DEFINITIONS_PART_1
from app.services.employee_seed_part2 import EMPLOYEE_DEFINITIONS_PART_2
@@ -11,7 +17,10 @@ __all__ = [
"ROLE_DISPLAY_ORDER",
"ROLE_DEFINITIONS",
"ROLE_PERMISSION_MAP",
"CANONICAL_DEPARTMENT_CODES",
"ORGANIZATION_DEFINITIONS",
"LEGACY_ORGANIZATION_UNIT_CODE_MAP",
"EMPLOYEE_PROFILE_REPAIRS",
"EMPLOYEE_DEFINITIONS",
"normalize_organization_unit_code",
]

View File

@@ -11,88 +11,89 @@ ORGANIZATION_DEFINITIONS = [
"manager_name": "李文静",
},
{
"unit_code": "EXEC-OFFICE",
"name": "总经办",
"unit_code": "TECH-DEPT",
"name": "技术部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-1001",
"location": "上海",
"manager_name": "李文静",
},
{
"unit_code": "FIN-SSC",
"name": "财务共享中心",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-2108",
"location": "上海",
"manager_name": "张晓晴",
},
{
"unit_code": "HR-OD",
"name": "人力与组织",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-3206",
"location": "杭州",
"manager_name": "陈硕",
},
{
"unit_code": "SALES-SOUTH",
"name": "华南销售部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-4102",
"location": "深圳",
"manager_name": "陈嘉",
},
{
"unit_code": "SALES-EAST",
"name": "华东销售部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-4108",
"location": "上海",
"manager_name": "秦墨然",
},
{
"unit_code": "MKT-BRAND",
"name": "市场品牌部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-5203",
"location": "北京",
"manager_name": "刘思雨",
},
{
"unit_code": "RND-CENTER",
"name": "产品研发中心",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-6105",
"cost_center": "CC-6100",
"location": "北京",
"manager_name": "吴磊",
},
{
"unit_code": "OPS-ADMIN",
"name": "行政采购",
"unit_code": "MARKET-DEPT",
"name": "市场",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-7204",
"cost_center": "CC-4100",
"location": "上海",
"manager_name": "刘思雨",
},
{
"unit_code": "FINANCE-DEPT",
"name": "财务部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-2100",
"location": "上海",
"manager_name": "张晓晴",
},
{
"unit_code": "HR-DEPT",
"name": "人力资源部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-3200",
"location": "杭州",
"manager_name": "陈硕",
},
{
"unit_code": "PRODUCTION-DEPT",
"name": "生产部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-7200",
"location": "南京",
"manager_name": "梁雨辰",
},
{
"unit_code": "AUDIT-RISK",
"name": "风控与审计部",
"unit_code": "PRESIDENT-OFFICE",
"name": "总裁办",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-8102",
"cost_center": "CC-1000",
"location": "上海",
"manager_name": "顾承宇",
"manager_name": "李文静",
},
]
CANONICAL_DEPARTMENT_CODES = (
"TECH-DEPT",
"MARKET-DEPT",
"FINANCE-DEPT",
"HR-DEPT",
"PRODUCTION-DEPT",
"PRESIDENT-OFFICE",
)
LEGACY_ORGANIZATION_UNIT_CODE_MAP = {
"RND-CENTER": "TECH-DEPT",
"SALES-SOUTH": "MARKET-DEPT",
"SALES-EAST": "MARKET-DEPT",
"MKT-BRAND": "MARKET-DEPT",
"FIN-SSC": "FINANCE-DEPT",
"AUDIT-RISK": "FINANCE-DEPT",
"HR-OD": "HR-DEPT",
"OPS-ADMIN": "PRODUCTION-DEPT",
"EXEC-OFFICE": "PRESIDENT-OFFICE",
}
def normalize_organization_unit_code(unit_code: str | None) -> str | None:
if not unit_code:
return unit_code
return LEGACY_ORGANIZATION_UNIT_CODE_MAP.get(unit_code, unit_code)
EMPLOYEE_PROFILE_REPAIRS = [
{
"employee_no": "E90919",
@@ -101,7 +102,7 @@ EMPLOYEE_PROFILE_REPAIRS = [
"location": "武汉",
"position": "财务智能化产品经理",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6112",

View File

@@ -12,7 +12,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "上海",
"position": "高级财务总监",
"grade": "D2",
"organization_unit_code": "EXEC-OFFICE",
"organization_unit_code": "PRESIDENT-OFFICE",
"manager_employee_no": None,
"finance_owner_name": "集团财务",
"cost_center": "CC-1001",
@@ -34,7 +34,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "上海",
"position": "费用运营经理",
"grade": "M3",
"organization_unit_code": "FIN-SSC",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2108",
@@ -68,7 +68,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "上海",
"position": "财务分析师",
"grade": "P5",
"organization_unit_code": "FIN-SSC",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2111",
@@ -90,7 +90,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "上海",
"position": "财务系统专员",
"grade": "P5",
"organization_unit_code": "FIN-SSC",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2112",
@@ -112,7 +112,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "上海",
"position": "差旅合规专员",
"grade": "P4",
"organization_unit_code": "FIN-SSC",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2115",
@@ -134,7 +134,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "杭州",
"position": "组织发展主管",
"grade": "P6",
"organization_unit_code": "HR-OD",
"organization_unit_code": "HR-DEPT",
"manager_employee_no": "E11618",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3206",
@@ -156,7 +156,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "杭州",
"position": "人力资源经理",
"grade": "M2",
"organization_unit_code": "HR-OD",
"organization_unit_code": "HR-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3201",
@@ -178,7 +178,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "杭州",
"position": "HRBP",
"grade": "P4",
"organization_unit_code": "HR-OD",
"organization_unit_code": "HR-DEPT",
"manager_employee_no": "E11618",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3208",
@@ -200,7 +200,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "品牌市场经理",
"grade": "M2",
"organization_unit_code": "MKT-BRAND",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5203",
@@ -222,7 +222,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "品牌策划",
"grade": "P4",
"organization_unit_code": "MKT-BRAND",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5207",
@@ -244,7 +244,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "数字营销专员",
"grade": "P4",
"organization_unit_code": "MKT-BRAND",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5209",
@@ -266,7 +266,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "深圳",
"position": "区域销售经理",
"grade": "M2",
"organization_unit_code": "SALES-SOUTH",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4102",
@@ -288,7 +288,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "深圳",
"position": "销售运营专家",
"grade": "P5",
"organization_unit_code": "SALES-SOUTH",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4106",
@@ -310,7 +310,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "深圳",
"position": "大客户代表",
"grade": "P4",
"organization_unit_code": "SALES-SOUTH",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4109",
@@ -332,7 +332,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "深圳",
"position": "销售协调专员",
"grade": "P3",
"organization_unit_code": "SALES-SOUTH",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4112",
@@ -354,7 +354,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "研发平台主管",
"grade": "M3",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6105",
@@ -376,7 +376,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "产品经理",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6112",
@@ -398,7 +398,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "后端工程师",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6114",
@@ -420,7 +420,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"location": "北京",
"position": "数据工程师",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6116",

View File

@@ -12,7 +12,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "北京",
"position": "测试负责人",
"grade": "P6",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6119",
@@ -34,7 +34,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "南京",
"position": "行政采购专员",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"organization_unit_code": "PRODUCTION-DEPT",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7204",
@@ -56,7 +56,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "南京",
"position": "行政运营经理",
"grade": "M1",
"organization_unit_code": "OPS-ADMIN",
"organization_unit_code": "PRODUCTION-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7201",
@@ -78,7 +78,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "风控审计经理",
"grade": "M2",
"organization_unit_code": "AUDIT-RISK",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "集团财务",
"cost_center": "CC-8102",
@@ -112,7 +112,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "审计专员",
"grade": "P4",
"organization_unit_code": "AUDIT-RISK",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E12661",
"finance_owner_name": "集团财务",
"cost_center": "CC-8105",
@@ -134,7 +134,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "南京",
"position": "采购合规分析师",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"organization_unit_code": "PRODUCTION-DEPT",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7208",
@@ -156,7 +156,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "华东销售总监",
"grade": "M2",
"organization_unit_code": "SALES-EAST",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E10018",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4108",
@@ -178,7 +178,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "重点客户经理",
"grade": "P5",
"organization_unit_code": "SALES-EAST",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E12067",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4111",
@@ -200,7 +200,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "销售代表",
"grade": "P3",
"organization_unit_code": "SALES-EAST",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E12067",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4114",
@@ -222,7 +222,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "北京",
"position": "数据分析师",
"grade": "P4",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6122",
@@ -244,7 +244,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "费用核算专员",
"grade": "P4",
"organization_unit_code": "FIN-SSC",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2118",
@@ -266,7 +266,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "预算控制经理",
"grade": "M1",
"organization_unit_code": "FIN-SSC",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E10234",
"finance_owner_name": "集团财务",
"cost_center": "CC-2120",
@@ -288,7 +288,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "深圳",
"position": "渠道销售经理",
"grade": "P5",
"organization_unit_code": "SALES-SOUTH",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4116",
@@ -310,7 +310,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "北京",
"position": "内容运营经理",
"grade": "P5",
"organization_unit_code": "MKT-BRAND",
"organization_unit_code": "MARKET-DEPT",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5211",
@@ -332,7 +332,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "北京",
"position": "架构工程师",
"grade": "P6",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6125",
@@ -354,7 +354,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "南京",
"position": "供应商管理专员",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"organization_unit_code": "PRODUCTION-DEPT",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7210",
@@ -376,7 +376,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "风控策略分析师",
"grade": "P4",
"organization_unit_code": "AUDIT-RISK",
"organization_unit_code": "FINANCE-DEPT",
"manager_employee_no": "E12661",
"finance_owner_name": "集团财务",
"cost_center": "CC-8108",
@@ -398,7 +398,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"location": "上海",
"position": "合规产品负责人",
"grade": "P7",
"organization_unit_code": "RND-CENTER",
"organization_unit_code": "TECH-DEPT",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6128",

View File

@@ -103,7 +103,7 @@ def build_import_template_bytes() -> bytes:
("办公地点", "可选。"),
("岗位*", "必填。"),
("职级*", "必填,例如 P3、P5。"),
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC"),
("部门编码", "可选,须与系统组织编码一致,例如 FINANCE-DEPT"),
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
("财务归口", "可选。"),
("成本中心", "可选。"),

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo
from app.services.employee_serialization import format_history_datetime as serialize_history_datetime
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
def normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
text_value = value.strip()
return text_value or None
def parse_date(value: str | None) -> date | None:
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
def parse_datetime(value: str | datetime | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return datetime.strptime(value, "%Y-%m-%d %H:%M")
def format_date(value: date | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d")
def to_display_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.astimezone(DISPLAY_TIMEZONE)
def format_datetime(value: datetime | None) -> str | None:
if value is None:
return None
return to_display_datetime(value).strftime("%Y-%m-%d %H:%M")
def format_history_datetime(value: datetime | None) -> str:
return serialize_history_datetime(value, to_display_datetime=to_display_datetime)

View File

@@ -17,6 +17,7 @@ ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed")
class ExpenseClaimAccessPolicy:
@@ -39,9 +40,22 @@ class ExpenseClaimAccessPolicy:
def build_archived_claim_condition() -> Any:
normalized_status = func.lower(func.coalesce(ExpenseClaim.status, ""))
stage = func.coalesce(ExpenseClaim.approval_stage, "")
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
application_condition = or_(
claim_no.like("AP-%"),
claim_no.like("APP-%"),
normalized_type == "application",
normalized_type.like("%\\_application", escape="\\"),
)
return or_(
stage == "归档入账",
stage == "completed",
and_(
application_condition,
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
stage.in_(APPLICATION_ARCHIVED_STAGES),
),
and_(
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
or_(
@@ -59,6 +73,27 @@ class ExpenseClaimAccessPolicy:
return True
return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & CLAIM_DELETE_ROLE_CODES)
@staticmethod
def is_archived_claim(claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip()
if stage in {"归档入账", "completed"}:
return True
normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
is_application_claim = (
claim_no.startswith(("AP-", "APP-"))
or normalized_type == "application"
or normalized_type.endswith("_application")
)
if (
is_application_claim
and normalized_status in ARCHIVED_CLAIM_STATUSES
and stage in APPLICATION_ARCHIVED_STAGES
):
return True
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if self.has_privileged_claim_access(current_user):
return True
@@ -338,6 +373,7 @@ class ExpenseClaimAccessPolicy:
else:
add_condition("employee_id", username)
add_condition("employee_name", username)
add_condition("employee_name", str(current_user.name or "").strip())
return conditions
@@ -422,10 +458,14 @@ class ExpenseClaimAccessPolicy:
return stmt.where(or_(*conditions))
def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
archived_condition = self.build_archived_claim_condition()
if not self.has_archive_center_access(current_user):
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
owned_conditions = self.build_personal_claim_conditions(current_user)
if not owned_conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
return stmt.where(archived_condition, or_(*owned_conditions))
return stmt.where(self.build_archived_claim_condition())
return stmt.where(archived_condition)
@staticmethod
def resolve_claim_manager_name(claim: ExpenseClaim) -> str:

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from app.models.financial_record import ExpenseClaim
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
"travel_application": "travel",
"purchase_application": "office",
"meeting_application": "meeting",
"expense_application": "other",
"application": "other",
}
class ExpenseClaimApplicationHandoffMixin:
@staticmethod
def _resolve_reimbursement_type_from_application(expense_type: str | None) -> str:
normalized = str(expense_type or "").strip().lower()
if normalized in APPLICATION_REIMBURSEMENT_TYPE_MAP:
return APPLICATION_REIMBURSEMENT_TYPE_MAP[normalized]
if normalized.endswith("_application"):
return normalized.removesuffix("_application") or "other"
return normalized or "other"
def _create_reimbursement_draft_from_application(
self,
*,
application_claim: ExpenseClaim,
approval_flag: dict[str, Any],
operator: str,
) -> ExpenseClaim:
occurred_at = application_claim.occurred_at or datetime.now(UTC)
created_at = datetime.now(UTC)
draft_claim = ExpenseClaim(
claim_no=self._generate_claim_no(occurred_at),
employee_id=application_claim.employee_id,
employee_name=application_claim.employee_name,
department_id=application_claim.department_id,
department_name=application_claim.department_name,
project_code=application_claim.project_code,
expense_type=self._resolve_reimbursement_type_from_application(application_claim.expense_type),
reason=application_claim.reason,
location=application_claim.location,
amount=application_claim.amount or Decimal("0.00"),
currency=application_claim.currency or "CNY",
invoice_count=0,
occurred_at=occurred_at,
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[
{
"source": "application_handoff",
"event_type": "expense_application_to_reimbursement_draft",
"handoff_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "申请转报销草稿",
"message": (
f"费用申请 {application_claim.claim_no} 已由 {operator} 确认审核,"
"系统已生成报销草稿。"
),
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
"application_budget_amount": str(application_claim.amount or Decimal("0.00")),
"application_approval_event_id": str(approval_flag.get("approval_event_id") or ""),
"leader_opinion": str(approval_flag.get("opinion") or "").strip(),
"created_at": created_at.isoformat(),
}
],
)
self.db.add(draft_claim)
self.db.flush()
approval_flag["generated_draft_claim_id"] = draft_claim.id
approval_flag["generated_draft_claim_no"] = draft_claim.claim_no
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}"
return draft_claim

View File

@@ -33,6 +33,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import generate_unique_expense_claim_no
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -250,23 +251,11 @@ class ExpenseClaimDraftPersistenceMixin:
)
def _generate_claim_no(self, occurred_at: datetime) -> str:
month_code = occurred_at.strftime("%Y%m")
prefix = f"EXP-{month_code}-"
existing_claim_nos = list(
self.db.scalars(
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{prefix}%"))
)
return generate_unique_expense_claim_no(
self.db,
"reimbursement",
timestamp=datetime.now(UTC),
)
max_suffix = 0
for claim_no in existing_claim_nos:
normalized = str(claim_no or "").strip()
if not normalized.startswith(prefix):
continue
suffix = normalized[len(prefix):]
if not suffix.isdigit():
continue
max_suffix = max(max_suffix, int(suffix))
return f"{prefix}{max_suffix + 1:03d}"
@staticmethod
def _resolve_claim_no_retry_count(context_json: dict[str, Any]) -> int:

View File

@@ -13,6 +13,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.expense_rule_runtime import (
RuntimeTravelPolicy,
)
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -46,7 +47,7 @@ class ExpenseClaimPlatformRiskMixin:
flags.append(flag)
severity = str(flag.get("severity") or "").strip().lower()
action = str(flag.get("action") or "").strip().lower()
if severity == "high" or action == "block":
if severity in {"high", "critical"} or action == "block":
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
@@ -100,6 +101,7 @@ class ExpenseClaimPlatformRiskMixin:
except (FileNotFoundError, ValueError):
continue
payload = normalize_risk_rule_manifest(payload)
manifest_code = str(payload.get("rule_code") or rule_code).strip()
if not manifest_code or (code_filter and manifest_code not in code_filter):
continue
@@ -129,6 +131,7 @@ class ExpenseClaimPlatformRiskMixin:
)
except (FileNotFoundError, ValueError):
continue
payload = normalize_risk_rule_manifest(payload)
rule_code = str(payload.get("rule_code") or "").strip()
if not rule_code or rule_code in manifests_by_code:
continue
@@ -612,7 +615,7 @@ class ExpenseClaimPlatformRiskMixin:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
default_action = "block" if severity == "high" else "manual_review"
default_action = "block" if severity in {"high", "critical"} else "manual_review"
action = str(fail_outcome.get("action") or default_action).strip()
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()

View File

@@ -33,9 +33,11 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
@@ -124,6 +126,7 @@ from app.services.ocr import OcrService
class ExpenseClaimService(
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimAttachmentOperationsMixin,
ExpenseClaimReviewPreviewMixin,
ExpenseClaimDraftFlowMixin,
@@ -153,7 +156,7 @@ class ExpenseClaimService(
or ""
).strip().lower()
return (
claim_no.startswith("APP-")
is_application_claim_no(claim_no)
or expense_type == "application"
or expense_type.endswith("_application")
or document_type in {"application", "expense_application"}
@@ -492,9 +495,28 @@ class ExpenseClaimService(
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None and (
current_user.is_admin or self._access_policy.has_archive_center_access(current_user)
):
candidate_claim = self.db.scalar(
select(ExpenseClaim)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.where(ExpenseClaim.id == claim_id)
)
if candidate_claim is not None and (
current_user.is_admin or self._access_policy.is_archived_claim(candidate_claim)
):
claim = candidate_claim
if claim is None:
return None
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
if not self._access_policy.has_claim_delete_access(current_user):
self._ensure_draft_claim(claim)
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
@@ -642,7 +664,7 @@ class ExpenseClaimService(
label = "领导审批通过"
next_status = "approved"
next_stage = "审批完成"
default_message = "{operator}审批通过,申请流程完成。"
default_message = "{operator}确认审核,申请流程完成并生成报销草稿"
else:
event_type = "expense_claim_approval"
label = "领导审批通过"
@@ -663,9 +685,12 @@ class ExpenseClaimService(
else:
raise ValueError("当前节点不支持审批通过。")
approval_opinion = str(opinion or "").strip()
if previous_stage == "直属领导审批" and not approval_opinion:
raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。")
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
approval_opinion = str(opinion or "").strip()
approval_flag = {
"source": approval_source,
"event_type": event_type,
@@ -692,6 +717,12 @@ class ExpenseClaimService(
claim.approval_stage = next_stage
if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC)
if is_application_claim and previous_stage == "直属领导审批":
generated_draft = self._create_reimbursement_draft_from_application(
application_claim=claim,
approval_flag=approval_flag,
operator=operator,
)
claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag]
self.db.commit()

View File

@@ -399,6 +399,10 @@ class OntologyDetectionMixin:
"missing_slots 使用简短 snake_case例如 expense_type, amount, "
"customer_name, participants, attachments。"
"entity_hints 只填写你比较确定的业务对象;如果不确定,可以返回空数组。"
"费用申请场景下,建议把干净的申请事由放入 type=reason"
"把出行方式放入 type=transport_mode取值优先为飞机、火车、轮船。"
"reason 只能保留真实业务目的,例如“服务美团业务部署”,"
"不要把发生时间、地点、出差天数、交通方式混进 reason。"
)
user_prompt = (
"请根据以下事实输出 JSON\n"
@@ -415,6 +419,9 @@ class OntologyDetectionMixin:
' "entity_hints": [\n'
' {"type": "expense_type", "value": "交通费", '
'"normalized_value": "transport", "role": "filter", '
'"confidence": 0.86},\n'
' {"type": "reason", "value": "服务客户业务部署", '
'"normalized_value": "服务客户业务部署", "role": "target", '
'"confidence": 0.86}\n'
" ]\n"
"}"

View File

@@ -13,6 +13,7 @@ from app.schemas.ontology import (
OntologyPermission,
OntologyTimeRange,
)
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
from app.services.ontology_rules import (
AMOUNT_PATTERN,
DATE_RANGE_PATTERN,
@@ -243,7 +244,8 @@ class OntologyExtractionMixin:
for code in re.findall(r"PRJ-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("project", code, code.upper(), role="filter"))
for code in re.findall(r"EXP-\d{6}-\d{3}", query, flags=re.IGNORECASE):
for match in DOCUMENT_NUMBER_EXTRACT_PATTERN.finditer(query):
code = match.group(0)
upsert(self._make_entity("expense_claim", code, code.upper()))
for code in re.findall(r"AR-\d{6}-\d{3}", query, flags=re.IGNORECASE):
upsert(self._make_entity("receivable", code, code.upper()))

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import html
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
@@ -25,6 +26,9 @@ class RiskRuleFlowDiagramSpec:
basis: str
pass_text: str
fail_text: str
fact_lines: tuple[str, ...] = ()
condition_lines: tuple[str, ...] = ()
hit_logic: str = ""
@dataclass(frozen=True)
@@ -66,35 +70,49 @@ class RiskRuleFlowDiagramRenderer:
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="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-labelledby="risk-flow-title risk-flow-desc">
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>
<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="760" height="280" fill="#ffffff"/>
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<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, 48, 118, 124, 60)}
{self._node("字段取数", "读取字段证据", 214, 118, 132, 60)}
{self._diamond("判断依据", spec.decision, 392, 92, 112, 112)}
{self._node("继续流转", spec.pass_text, 562, 74, 126, 60)}
{self._node("进入复核", spec.fail_text, 562, 190, 126, 62, palette=palette)}
{self._note(spec.basis, 214, 218, 290, 36)}
<line x1="172" y1="148" x2="214" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
<text x="534" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="400">否</text>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
<text x="534" y="195" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="600"></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(
@@ -137,6 +155,28 @@ class RiskRuleFlowDiagramRenderer:
{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,
@@ -152,6 +192,13 @@ class RiskRuleFlowDiagramRenderer:
{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],
@@ -189,3 +236,130 @@ class RiskRuleFlowDiagramRenderer:
@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()]

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
@@ -14,135 +13,34 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.expense_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramField,
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
build_risk_rule_flow_diagram_details,
)
from app.services.risk_rule_generation_ontology import (
BUSINESS_DOMAIN_LABELS,
DOMAIN_FIELD_PREFIXES,
EXPENSE_RISK_CATEGORY_ALIASES,
EXPENSE_RISK_CATEGORY_LABELS,
FIELD_ONTOLOGY,
RISK_LEVEL_LABELS,
RiskRuleField,
)
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
from app.services.risk_rule_generation_semantics import (
CITY_CONSISTENCY_SEMANTIC_TYPE,
CITY_CONSISTENCY_SEMANTIC_TYPES,
build_city_consistency_draft,
build_city_consistency_params,
)
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
from app.services.runtime_chat import RuntimeChatService
@dataclass(frozen=True)
class RiskRuleField:
key: str
label: str
field_type: str
source: str
aliases: tuple[str, ...]
BUSINESS_DOMAIN_LABELS: dict[str, str] = {
AgentAssetDomain.EXPENSE.value: "报销",
AgentAssetDomain.AR.value: "应收",
AgentAssetDomain.AP.value: "应付",
}
RISK_LEVEL_LABELS: dict[str, str] = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
}
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
"travel",
"hotel",
"transport",
"meal",
"meeting",
"office",
"training",
"communication",
"welfare",
)
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
}
EXPENSE_RISK_CATEGORY_ALIASES = {
"entertainment": "meal",
}
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
RiskRuleField(
"claim.location",
"申报地点",
"text",
"claim",
("地点", "城市", "出差地", "申报地点", "申报目的地", "目的地"),
),
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")),
RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")),
RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")),
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")),
RiskRuleField(
"attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号")
),
RiskRuleField(
"attachment.buyer_name", "购买方名称", "text", "attachment", ("抬头", "购买方", "开票单位")
),
RiskRuleField(
"attachment.goods_name",
"商品服务名称",
"text",
"attachment",
("品名", "商品", "服务名称", "摘要"),
),
RiskRuleField(
"attachment.issue_date",
"开票日期",
"date",
"attachment",
("开票日期", "发票日期", "票据日期"),
),
RiskRuleField(
"attachment.hotel_city",
"住宿城市",
"text",
"attachment",
("住宿城市", "酒店城市", "酒店地点", "酒店发票城市", "酒店票城市", "住宿发票城市"),
),
RiskRuleField(
"attachment.route_cities",
"行程城市",
"list",
"attachment",
("行程", "路线", "途经城市", "出差城市", "交通票行程", "交通票城市"),
),
RiskRuleField(
"attachment.ocr_text",
"票据全文",
"text",
"attachment",
("票据内容", "OCR", "全文", "关键字", "关键词"),
),
RiskRuleField(
"receivable.aging_days", "应收账龄", "number", "receivable", ("账龄", "逾期", "应收逾期")
),
RiskRuleField(
"receivable.amount_outstanding",
"应收未收金额",
"number",
"receivable",
("未收金额", "欠款", "应收余额"),
),
RiskRuleField(
"payable.vendor_name", "供应商名称", "text", "payable", ("供应商", "付款方", "往来单位")
),
RiskRuleField(
"payable.amount_outstanding", "应付未付金额", "number", "payable", ("未付金额", "应付余额")
),
)
DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = {
AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment."),
AgentAssetDomain.AR.value: ("receivable.",),
AgentAssetDomain.AP.value: ("payable.",),
}
class RiskRuleGenerationService:
def __init__(
self,
@@ -172,9 +70,10 @@ class RiskRuleGenerationService:
if len(natural_language) < 8:
raise ValueError("请至少输入 8 个字的风险规则描述。")
risk_level = str(body.risk_level or "medium").strip().lower()
if risk_level not in RISK_LEVEL_LABELS:
raise ValueError("风险等级仅支持 low、medium、high")
rule_title = self._clean_text(body.rule_title)
if rule_title and len(rule_title) < 2:
raise ValueError("规则标题至少需要 2 个字")
requires_attachment = bool(body.requires_attachment)
expense_category = self._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
@@ -186,20 +85,30 @@ class RiskRuleGenerationService:
domain=domain,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
fields=fields,
) or self._build_fallback_draft(
natural_language=natural_language,
domain=domain,
expense_category_label=expense_category_label,
risk_level=risk_level,
risk_level="medium",
fields=fields,
)
draft = self._align_draft_fields(
draft,
natural_language=natural_language,
risk_level="medium",
fields=fields,
)
risk_score = calculate_risk_rule_score(
natural_language=natural_language,
draft=draft,
fields=fields,
expense_category=expense_category,
expense_category_label=expense_category_label,
requires_attachment=requires_attachment,
)
risk_level = str(risk_score["level"])
draft = apply_risk_score_to_draft(draft, risk_score)
payload = self._build_rule_payload(
draft,
natural_language=natural_language,
@@ -211,6 +120,8 @@ class RiskRuleGenerationService:
created_at=created_at,
actor=actor,
requires_attachment=requires_attachment,
rule_title=rule_title,
risk_score=risk_score,
)
rule_code = str(payload["rule_code"])
file_name = f"{rule_code}.json"
@@ -236,6 +147,10 @@ class RiskRuleGenerationService:
working_version="v0.1.0",
config_json={
"severity": risk_level,
"risk_score": risk_score["score"],
"risk_level": risk_level,
"risk_level_label": risk_score["level_label"],
"risk_score_detail": risk_score,
"enabled": True,
"requires_attachment": requires_attachment,
"tag": "风险规则",
@@ -260,7 +175,7 @@ class RiskRuleGenerationService:
AgentAssetVersion(
asset_id=asset.id,
version="v0.1.0",
content=self._build_version_markdown(payload),
content=build_risk_rule_version_markdown(payload),
content_type="markdown",
change_note="通过自然语言新建风险规则草稿。",
created_by=actor,
@@ -275,6 +190,7 @@ class RiskRuleGenerationService:
after_json={
"rule_code": rule_code,
"risk_level": risk_level,
"risk_score": risk_score["score"],
"domain": domain,
"expense_category": expense_category,
"requires_attachment": requires_attachment,
@@ -291,7 +207,6 @@ class RiskRuleGenerationService:
domain: str,
expense_category: str | None,
expense_category_label: str,
risk_level: str,
fields: list[RiskRuleField],
) -> dict[str, Any] | None:
field_payload = [
@@ -303,50 +218,17 @@ class RiskRuleGenerationService:
}
for item in fields
]
messages = [
{
"role": "system",
"content": (
"你是 X-Financial 风险规则编译器。只能输出 JSON 对象,不要解释。"
"必须从给定字段本体中选择字段,不允许编造字段。"
"template_key 只能是 field_required_v1、field_compare_v1、keyword_match_v1。"
),
},
{
"role": "user",
"content": json.dumps(
{
"business_domain": domain,
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_level": risk_level,
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
"natural_language": natural_language,
"available_fields": field_payload,
"required_json_shape": {
"name": "规则名称",
"description": "面向业务用户的说明",
"template_key": "field_required_v1",
"field_keys": ["claim.reason"],
"condition_summary": "判断依据",
"keywords": [],
"flow": {
"start": "提交业务单据",
"evidence": "读取字段",
"decision": "判断依据",
"pass": "继续流转",
"fail": "提示风险",
},
},
},
ensure_ascii=False,
),
},
]
messages = build_risk_rule_compiler_messages(
domain=domain,
domain_label=BUSINESS_DOMAIN_LABELS[domain],
expense_category=expense_category,
expense_category_label=expense_category_label,
natural_language=natural_language,
available_fields=field_payload,
)
answer = self.runtime_chat_service.complete(
messages,
max_tokens=700,
max_tokens=1400,
temperature=0.1,
timeout_seconds=12,
max_attempts=1,
@@ -370,7 +252,12 @@ class RiskRuleGenerationService:
) -> dict[str, Any]:
allowed_fields = {item.key for item in fields}
template_key = str(payload.get("template_key") or "").strip()
if template_key not in {"field_required_v1", "field_compare_v1", "keyword_match_v1"}:
if template_key not in {
"field_required_v1",
"field_compare_v1",
"keyword_match_v1",
COMPOSITE_RULE_TEMPLATE_KEY,
}:
template_key = "field_required_v1"
raw_field_keys = payload.get("field_keys")
@@ -389,14 +276,37 @@ class RiskRuleGenerationService:
)
if str(item or "").strip()
]
exception_keywords = [
str(item or "").strip()
for item in (
payload.get("exception_keywords")
if isinstance(payload.get("exception_keywords"), list)
else []
)
if str(item or "").strip()
]
unsupported_fields = [
str(item or "").strip()
for item in (
payload.get("unsupported_fields")
if isinstance(payload.get("unsupported_fields"), list)
else []
)
if str(item or "").strip()
]
flow = payload.get("flow") if isinstance(payload.get("flow"), dict) else {}
return {
rule_ir = payload.get("rule_ir") if isinstance(payload.get("rule_ir"), dict) else {}
draft = {
"name": self._clean_text(payload.get("name"))[:80],
"description": self._clean_text(payload.get("description")),
"template_key": template_key,
"semantic_type": self._clean_text(payload.get("semantic_type")),
"field_keys": field_keys,
"condition_summary": self._clean_text(payload.get("condition_summary")),
"keywords": keywords[:12],
"exception_keywords": exception_keywords[:12],
"unsupported_fields": unsupported_fields[:20],
"rule_ir": rule_ir,
"flow": {
"start": self._clean_text(flow.get("start")) or "提交业务单据",
"evidence": self._clean_text(flow.get("evidence")) or "读取规则字段",
@@ -405,6 +315,18 @@ class RiskRuleGenerationService:
"fail": self._clean_text(flow.get("fail")) or "提示风险并进入复核",
},
}
for key in ("conditions", "hit_logic", "field_groups"):
value = payload.get(key)
if isinstance(value, (list, dict)):
draft[key] = value
scoring_evidence = payload.get("risk_scoring_evidence")
if isinstance(scoring_evidence, dict):
draft["risk_scoring_evidence"] = scoring_evidence
for key in ("formula", "message_template"):
value = self._clean_text(payload.get(key))
if value:
draft[key] = value
return draft
def _build_fallback_draft(
self,
@@ -457,6 +379,8 @@ class RiskRuleGenerationService:
created_at: datetime,
actor: str,
requires_attachment: bool,
rule_title: str = "",
risk_score: dict[str, Any] | None = None,
) -> dict[str, Any]:
created_stamp = created_at.strftime("%Y%m%d%H%M%S%f")
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
@@ -472,6 +396,11 @@ class RiskRuleGenerationService:
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
)
risk_category = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
risk_score_payload = dict(risk_score or {})
risk_score_value = int(risk_score_payload.get("score") or 0)
risk_level_label = str(
risk_score_payload.get("level_label") or RISK_LEVEL_LABELS.get(risk_level, "风险")
)
keywords = list(draft.get("keywords") or [])
field_by_key = {item.key: item for item in fields}
params: dict[str, Any] = {
@@ -480,9 +409,23 @@ class RiskRuleGenerationService:
"condition_summary": condition_summary,
"natural_language": natural_language,
}
semantic_type = str(draft.get("semantic_type") or "").strip()
if semantic_type:
params["semantic_type"] = semantic_type
if template_key == COMPOSITE_RULE_TEMPLATE_KEY and isinstance(draft.get("rule_ir"), dict):
params["rule_ir"] = draft["rule_ir"]
for key in ("conditions", "hit_logic", "field_groups", "formula", "message_template"):
if key in draft:
params[key] = draft[key]
for key in ("keywords", "exception_keywords", "unsupported_fields"):
values = draft.get(key)
if isinstance(values, list):
params[key] = values
if draft.get("semantic_type") == CITY_CONSISTENCY_SEMANTIC_TYPE:
params.update(build_city_consistency_params(draft))
if template_key == "field_required_v1":
params["required_fields"] = field_keys
if template_key == "field_compare_v1":
if template_key == "field_compare_v1" and "conditions" not in params:
params["conditions"] = self._build_compare_conditions(field_keys)
if template_key == "keyword_match_v1":
params["keywords"] = keywords
@@ -494,7 +437,9 @@ class RiskRuleGenerationService:
payload = {
"schema_version": "2.0",
"rule_code": rule_code,
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
"name": rule_title
or self._clean_text(draft.get("name"))
or self._infer_rule_name(natural_language),
"description": self._clean_text(draft.get("description")) or natural_language,
"enabled": True,
"requires_attachment": requires_attachment,
@@ -503,6 +448,7 @@ class RiskRuleGenerationService:
"ontology_signal": "natural_language_risk",
"evaluator": "template_rule",
"template_key": template_key,
"semantic_type": str(draft.get("semantic_type") or "").strip() or None,
"applies_to": applies_to,
"inputs": {
"fields": [
@@ -521,6 +467,7 @@ class RiskRuleGenerationService:
"fail": {
"severity": risk_level,
"action": "manual_review",
"risk_score": risk_score_value,
},
},
"metadata": {
@@ -530,11 +477,18 @@ class RiskRuleGenerationService:
"created_at": created_at.isoformat(),
"created_by": actor,
"requires_attachment": requires_attachment,
"risk_score": risk_score_value,
"risk_level": risk_level,
"risk_level_label": risk_level_label,
"risk_score_model": risk_score_payload.get("model"),
"risk_score_detail": risk_score_payload,
"rule_title": rule_title,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"natural_language": natural_language,
"business_explanation": self._clean_text(draft.get("description")),
"condition_summary": condition_summary,
"rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {},
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
},
}
@@ -559,15 +513,17 @@ class RiskRuleGenerationService:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
condition_summary = self._clean_text(metadata.get("condition_summary"))
diagram_fields = [
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
]
details = build_risk_rule_flow_diagram_details(payload, diagram_fields)
return self.flow_diagram_renderer.render(
RiskRuleFlowDiagramSpec(
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
domain_label=domain_label or BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
severity=risk_level,
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
fields=tuple(
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
),
fields=tuple(diagram_fields),
start=self._clean_text(flow.get("start")) or "业务单据提交",
evidence=self._clean_text(flow.get("evidence")) or "读取规则字段",
decision=self._clean_text(flow.get("decision"))
@@ -581,6 +537,9 @@ class RiskRuleGenerationService:
pass_text=self._clean_text(flow.get("pass")) or "未命中风险,继续流转",
fail_text=self._clean_text(flow.get("fail"))
or f"命中{RISK_LEVEL_LABELS.get(risk_level, '风险')},进入人工复核",
fact_lines=details["fact_lines"],
condition_lines=details["condition_lines"],
hit_logic=str(details["hit_logic"] or ""),
)
)
@@ -615,7 +574,19 @@ class RiskRuleGenerationService:
(10, field)
for field in candidates
if field.key
in {"claim.location", "attachment.hotel_city", "attachment.route_cities"}
in {
"claim.reason",
"claim.location",
"employee.location",
"item.item_date",
"item.item_reason",
"item.item_location",
"attachment.hotel_city",
"attachment.route_cities",
"attachment.issue_date",
"attachment.stay_start_date",
"attachment.stay_end_date",
}
)
if any(keyword in text for keyword in ("发票", "票据", "品名", "抬头", "开票")):
matched.extend(
@@ -639,7 +610,7 @@ class RiskRuleGenerationService:
seen.add(field.key)
deduped.append(field)
if deduped:
return deduped[:8]
return deduped[:10]
return candidates[:4]
@staticmethod
@@ -657,6 +628,14 @@ class RiskRuleGenerationService:
term in text for term in ("行程", "交通票", "路线", "途经")
):
score += 10
if field.key in {
"claim.trip_start_date",
"claim.trip_end_date",
"item.item_date",
"attachment.stay_start_date",
"attachment.stay_end_date",
} and any(term in text for term in ("日期", "时间", "出差开始", "出差结束", "入住", "离店")):
score += 10
if field.key == "claim.location" and any(
term in text for term in ("申报目的地", "申报地点", "目的地", "出差地")
):
@@ -670,14 +649,26 @@ class RiskRuleGenerationService:
draft: dict[str, Any],
*,
natural_language: str,
risk_level: str,
fields: list[RiskRuleField],
) -> dict[str, Any]:
if str(draft.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
return build_city_consistency_draft(
draft,
natural_language=natural_language,
fields=fields,
risk_level=risk_level,
)
field_by_key = {field.key: field for field in fields}
original_keys = [
str(item or "").strip()
for item in list(draft.get("field_keys") or [])
if str(item or "").strip() in field_by_key
]
if draft.get("template_key") == COMPOSITE_RULE_TEMPLATE_KEY:
return {**draft, "field_keys": original_keys or [field.key for field in fields[:8]]}
preferred_keys: list[str] = []
def add_preferred(key: str, *terms: str) -> None:
@@ -783,40 +774,3 @@ class RiskRuleGenerationService:
if start < 0 or end <= start:
raise ValueError("JSON object not found.")
return normalized[start : end + 1]
@staticmethod
def _build_version_markdown(payload: dict[str, Any]) -> str:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
fields = (
payload.get("inputs", {}).get("fields")
if isinstance(payload.get("inputs"), dict)
else []
)
field_labels = [
str(item.get("label") or item.get("key") or "").strip()
for item in fields
if isinstance(item, dict) and str(item.get("label") or item.get("key") or "").strip()
]
return "\n".join(
[
f"# {payload.get('name')}",
"",
"## 业务说明",
"",
str(payload.get("description") or ""),
"",
"## 自然语言原文",
"",
str(metadata.get("natural_language") or ""),
"",
"## 使用字段",
"",
"".join(field_labels) or "未识别字段",
"",
"## 运行时 JSON",
"",
"```json",
json.dumps(payload, ensure_ascii=False, indent=2),
"```",
]
)

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
COMPOSITE_RULE_TEMPLATE_KEY = "composite_rule_v1"
COMPOSITE_RULE_OPERATORS = {
"exists_any",
"exists_all",
"all_present",
"in_scope",
"not_in_scope",
"not_in_set",
"overlap",
"not_overlap",
"date_outside_range",
"contains_any",
"not_contains_any",
}

View File

@@ -0,0 +1,336 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.risk_rule_generation import (
BUSINESS_DOMAIN_LABELS,
EXPENSE_RISK_CATEGORY_LABELS,
RiskRuleGenerationService,
)
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
from app.services.runtime_chat import RuntimeChatService
class RiskRuleGenerationJobService:
"""把自然语言风险规则生成拆成可追踪的后台任务。"""
def __init__(
self,
db: Session,
*,
rule_library_manager: Any | None = None,
runtime_chat_service: RuntimeChatService | None = None,
) -> None:
self.db = db
self.generator = RiskRuleGenerationService(
db,
rule_library_manager=rule_library_manager,
runtime_chat_service=runtime_chat_service,
)
self.audit_service = AuditLogService(db)
def enqueue_rule_asset_generation(
self,
body: AgentAssetRiskRuleGenerateRequest,
*,
actor: str,
request_id: str | None = None,
) -> str:
domain = self._validate_domain(body)
natural_language = self._validate_natural_language(body)
rule_title = self._validate_rule_title(body)
requires_attachment = bool(body.requires_attachment)
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
created_at = datetime.now(UTC)
rule_code = self._build_placeholder_rule_code(
domain=domain,
expense_category=expense_category,
created_at=created_at,
)
category_label = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
display_name = rule_title or self.generator._infer_rule_name(natural_language)
file_name = f"{rule_code}.json"
asset = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name=display_name,
description=natural_language,
domain=domain,
scenario_json=[category_label],
owner=actor,
reviewer=None,
status=AgentAssetStatus.GENERATING.value,
current_version=None,
published_version=None,
working_version=None,
config_json={
"severity": "medium",
"risk_score_status": "pending",
"enabled": True,
"requires_attachment": requires_attachment,
"tag": "风险规则",
"detail_mode": "json_risk",
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_category": category_label,
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"generated_by": "natural_language",
"generation_status": AgentAssetStatus.GENERATING.value,
"generation_started_at": created_at.isoformat(),
"generation_request": self._dump_generation_request(body),
},
)
self.db.add(asset)
self.db.flush()
self.audit_service.log_action(
actor=actor,
action="enqueue_agent_asset_risk_rule_generation",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=None,
after_json={
"rule_code": rule_code,
"domain": domain,
"expense_category": expense_category,
},
request_id=request_id,
)
self.db.refresh(asset)
return asset.id
def complete_rule_asset_generation(
self,
asset_id: str,
body: AgentAssetRiskRuleGenerateRequest,
*,
actor: str,
request_id: str | None = None,
) -> None:
try:
asset = self.db.get(AgentAsset, asset_id)
if asset is None or asset.status != AgentAssetStatus.GENERATING.value:
return
self._complete_rule_asset(asset, body, actor=actor, request_id=request_id)
except Exception as exc: # noqa: BLE001 - 后台任务必须把失败写回资产状态
self.mark_generation_failed(
asset_id,
error_message=str(exc) or exc.__class__.__name__,
actor=actor,
request_id=request_id,
)
def mark_generation_failed(
self,
asset_id: str,
*,
error_message: str,
actor: str,
request_id: str | None = None,
) -> None:
asset = self.db.get(AgentAsset, asset_id)
if asset is None:
return
config_json = dict(asset.config_json or {})
config_json.update(
{
"generation_status": AgentAssetStatus.FAILED.value,
"generation_error": error_message[:1000],
"generation_failed_at": datetime.now(UTC).isoformat(),
}
)
asset.status = AgentAssetStatus.FAILED.value
asset.config_json = config_json
self.db.add(asset)
self.db.flush()
self.audit_service.log_action(
actor=actor,
action="fail_agent_asset_risk_rule_generation",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=None,
after_json={"generation_error": error_message[:1000]},
request_id=request_id,
)
def _complete_rule_asset(
self,
asset: AgentAsset,
body: AgentAssetRiskRuleGenerateRequest,
*,
actor: str,
request_id: str | None,
) -> None:
domain = self._validate_domain(body)
natural_language = self._validate_natural_language(body)
rule_title = self._validate_rule_title(body)
requires_attachment = bool(body.requires_attachment)
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
created_at = asset.created_at or datetime.now(UTC)
fields = self.generator._resolve_fields(natural_language, domain=domain)
draft = self.generator._compile_with_model(
natural_language=natural_language,
domain=domain,
expense_category=expense_category,
expense_category_label=expense_category_label,
fields=fields,
) or self.generator._build_fallback_draft(
natural_language=natural_language,
domain=domain,
expense_category_label=expense_category_label,
risk_level="medium",
fields=fields,
)
draft = self.generator._align_draft_fields(
draft,
natural_language=natural_language,
risk_level="medium",
fields=fields,
)
risk_score = calculate_risk_rule_score(
natural_language=natural_language,
draft=draft,
fields=fields,
expense_category=expense_category,
expense_category_label=expense_category_label,
requires_attachment=requires_attachment,
)
risk_level = str(risk_score["level"])
draft = apply_risk_score_to_draft(draft, risk_score)
payload = self.generator._build_rule_payload(
draft,
natural_language=natural_language,
domain=domain,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
fields=fields,
created_at=created_at,
actor=actor,
requires_attachment=requires_attachment,
rule_title=rule_title,
risk_score=risk_score,
)
rule_code = str(payload["rule_code"])
file_name = f"{rule_code}.json"
self.generator.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=payload,
)
config_json = {
"severity": risk_level,
"risk_score": risk_score["score"],
"risk_level": risk_level,
"risk_level_label": risk_score["level_label"],
"risk_score_detail": risk_score,
"enabled": True,
"requires_attachment": requires_attachment,
"tag": "风险规则",
"detail_mode": "json_risk",
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_category": payload.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"ontology_signal": payload.get("ontology_signal"),
"evaluator": payload.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
"generation_status": "completed",
"generation_completed_at": datetime.now(UTC).isoformat(),
}
asset.code = rule_code
asset.name = str(payload["name"])
asset.description = str(payload["description"])
asset.domain = domain
asset.scenario_json = [str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[domain])]
asset.status = AgentAssetStatus.DRAFT.value
asset.current_version = "v0.1.0"
asset.published_version = None
asset.working_version = "v0.1.0"
asset.config_json = config_json
self.db.add(asset)
self.db.add(
AgentAssetVersion(
asset_id=asset.id,
version="v0.1.0",
content=build_risk_rule_version_markdown(payload),
content_type="markdown",
change_note="通过自然语言新建风险规则草稿。",
created_by=actor,
)
)
self.db.flush()
self.audit_service.log_action(
actor=actor,
action="complete_agent_asset_risk_rule_generation",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=None,
after_json={
"rule_code": rule_code,
"risk_level": risk_level,
"risk_score": risk_score["score"],
"domain": domain,
"expense_category": expense_category,
"requires_attachment": requires_attachment,
},
request_id=request_id,
)
def _validate_domain(self, body: AgentAssetRiskRuleGenerateRequest) -> str:
domain = body.business_domain.value
if domain not in BUSINESS_DOMAIN_LABELS:
raise ValueError("当前仅支持报销、应收、应付业务域的新建风险规则。")
return domain
def _validate_natural_language(self, body: AgentAssetRiskRuleGenerateRequest) -> str:
natural_language = self.generator._clean_text(body.natural_language)
if len(natural_language) < 8:
raise ValueError("请至少输入 8 个字的风险规则描述。")
return natural_language
def _validate_rule_title(self, body: AgentAssetRiskRuleGenerateRequest) -> str:
rule_title = self.generator._clean_text(body.rule_title)
if rule_title and len(rule_title) < 2:
raise ValueError("规则标题至少需要 2 个字。")
return rule_title
@staticmethod
def _build_placeholder_rule_code(
*,
domain: str,
expense_category: str | None,
created_at: datetime,
) -> str:
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
category_slug = f".{expense_category}" if expense_category else ""
return f"risk.{domain_slug}{category_slug}.generating_{created_at.strftime('%Y%m%d%H%M%S%f')}"
@staticmethod
def _dump_generation_request(body: AgentAssetRiskRuleGenerateRequest) -> dict[str, Any]:
return body.model_dump(mode="json")

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import json
from typing import Any
def build_risk_rule_version_markdown(payload: dict[str, Any]) -> str:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
fields = (
payload.get("inputs", {}).get("fields") if isinstance(payload.get("inputs"), dict) else []
)
field_labels = [
str(item.get("label") or item.get("key") or "").strip()
for item in fields
if isinstance(item, dict) and str(item.get("label") or item.get("key") or "").strip()
]
return "\n".join(
[
f"# {payload.get('name')}",
"",
"## 业务说明",
"",
str(payload.get("description") or ""),
"",
"## 自然语言原文",
"",
str(metadata.get("natural_language") or ""),
"",
"## 使用字段",
"",
"".join(field_labels) or "未识别字段",
"",
"## 运行时 JSON",
"",
"```json",
json.dumps(payload, ensure_ascii=False, indent=2),
"```",
]
)

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from dataclasses import dataclass
from app.core.agent_enums import AgentAssetDomain
from app.services.expense_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
@dataclass(frozen=True)
class RiskRuleField:
key: str
label: str
field_type: str
source: str
aliases: tuple[str, ...]
BUSINESS_DOMAIN_LABELS: dict[str, str] = {
AgentAssetDomain.EXPENSE.value: "报销",
AgentAssetDomain.AR.value: "应收",
AgentAssetDomain.AP.value: "应付",
}
RISK_LEVEL_LABELS: dict[str, str] = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
"critical": "极高风险",
}
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
"travel",
"hotel",
"transport",
"meal",
"meeting",
"office",
"training",
"communication",
"welfare",
)
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
}
EXPENSE_RISK_CATEGORY_ALIASES = {
"entertainment": "meal",
}
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
RiskRuleField(
"claim.location",
"申报地点",
"text",
"claim",
("地点", "城市", "出差地", "申报地点", "申报目的地", "目的地"),
),
RiskRuleField(
"claim.trip_start_date",
"出差开始日期",
"date",
"claim",
("出差开始", "行程开始", "开始日期", "出差起始", "出发日期"),
),
RiskRuleField(
"claim.trip_end_date",
"出差结束日期",
"date",
"claim",
("出差结束", "行程结束", "结束日期", "返程日期", "返回日期"),
),
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")),
RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")),
RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")),
RiskRuleField(
"employee.location",
"员工常驻地",
"text",
"employee",
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"),
),
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")),
RiskRuleField("item.item_date", "明细发生日期", "date", "item", ("明细日期", "发生日期", "费用日期")),
RiskRuleField(
"attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号")
),
RiskRuleField(
"attachment.buyer_name", "购买方名称", "text", "attachment", ("抬头", "购买方", "开票单位")
),
RiskRuleField(
"attachment.goods_name",
"商品服务名称",
"text",
"attachment",
("品名", "商品", "服务名称", "摘要"),
),
RiskRuleField(
"attachment.issue_date",
"开票日期",
"date",
"attachment",
("开票日期", "发票日期", "票据日期"),
),
RiskRuleField(
"attachment.stay_start_date",
"住宿开始日期",
"date",
"attachment",
("入住日期", "住宿开始", "入住时间", "住宿开始日期"),
),
RiskRuleField(
"attachment.stay_end_date",
"住宿结束日期",
"date",
"attachment",
("离店日期", "退房日期", "住宿结束", "住宿结束日期"),
),
RiskRuleField(
"attachment.hotel_city",
"住宿城市",
"text",
"attachment",
("住宿城市", "酒店城市", "酒店地点", "酒店发票城市", "酒店票城市", "住宿发票城市"),
),
RiskRuleField(
"attachment.route_cities",
"行程城市",
"list",
"attachment",
("行程", "路线", "途经城市", "出差城市", "交通票行程", "交通票城市"),
),
RiskRuleField(
"attachment.ocr_text",
"票据全文",
"text",
"attachment",
("票据内容", "OCR", "全文", "关键字", "关键词"),
),
RiskRuleField(
"receivable.aging_days", "应收账龄", "number", "receivable", ("账龄", "逾期", "应收逾期")
),
RiskRuleField(
"receivable.amount_outstanding",
"应收未收金额",
"number",
"receivable",
("未收金额", "欠款", "应收余额"),
),
RiskRuleField(
"payable.vendor_name", "供应商名称", "text", "payable", ("供应商", "付款方", "往来单位")
),
RiskRuleField(
"payable.amount_outstanding", "应付未付金额", "number", "payable", ("未付金额", "应付余额")
),
)
DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = {
AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment.", "employee."),
AgentAssetDomain.AR.value: ("receivable.",),
AgentAssetDomain.AP.value: ("payable.",),
}

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import json
from typing import Any
def build_risk_rule_compiler_messages(
*,
domain: str,
domain_label: str,
expense_category: str | None,
expense_category_label: str,
natural_language: str,
available_fields: list[dict[str, Any]],
) -> list[dict[str, str]]:
"""构造自然语言规则编译提示词。
大模型只负责把业务语言拆成“语义计划”,后端会校验字段、操作符和模板。
"""
schema = {
"name": "规则名称,短句",
"description": "面向业务和审核人员的说明,不要写实现细节",
"template_key": "field_required_v1 | field_compare_v1 | keyword_match_v1 | composite_rule_v1",
"semantic_type": (
"可选。可用稳定英文短语描述语义类型;"
"已知差旅票据城市/路线一致性可使用 travel_route_city_consistency其他规则按业务含义命名"
),
"field_keys": ["只能选择 available_fields.key"],
"condition_summary": "用公式化语言描述判断依据,不要写'是否出现风险关键词'",
"rule_ir": {
"facts": "事实变量数组,例如 A=票据事实、B=业务申报事实、E=例外说明",
"conditions": "条件数组,必须能被人解释",
"hit_logic": "命中逻辑,例如 D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT EXCEPTION(E)",
},
"conditions": [
{
"id": "稳定英文标识",
"operator": (
"exists_any | exists_all | in_scope | not_in_scope | overlap | "
"not_overlap | date_outside_range | contains_any | not_contains_any"
),
"fields": ["exists/contains 类操作使用"],
"left_fields": ["集合比较左侧字段"],
"right_fields": ["集合比较右侧字段"],
"date_fields": ["日期字段"],
"range_start_fields": ["日期范围开始字段"],
"range_end_fields": ["日期范围结束字段"],
"keywords": ["例外或风险词"],
}
],
"hit_logic": {"all": ["condition_id", {"any": ["condition_id"]}]},
"formula": "可执行逻辑公式,字段使用事实变量表达",
"message_template": "命中后的业务提示",
"unsupported_fields": ["用户规则提到但 available_fields 中暂时没有的字段"],
"keywords": "仅 keyword_match_v1 使用,且必须是真正风险词,不得把例外说明词当风险词",
"exception_keywords": "例外说明词,例如绕行、跨城办事、临时改签",
"risk_scoring_evidence": {
"impact_level": "low | medium | high | critical",
"violation_certainty": "low | medium | high | critical",
"evidence_strength": "low | medium | high | critical",
"exception_dependence": "low | medium | high | critical",
"control_action": "remind | supplement | manual_review | return | block",
"business_sensitivity": "low | medium | high | critical",
"reason": "用一句话说明这些评分证据来自哪些业务语义",
},
"flow": {
"start": "流程起点",
"evidence": "读取哪些事实",
"decision": "判断公式或分支条件",
"pass": "未命中时说明",
"fail": "命中时说明",
},
}
guardrails = [
"只能输出 JSON 对象,不能输出 Markdown 或解释。",
"字段必须来自 available_fields不能编造字段。",
"多步骤规则要使用 composite_rule_v1先抽取事实变量再写 conditions 和 hit_logic不要压扁成单个关键词判断。",
"城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。",
"涉及多个字段、日期范围、金额范围、集合关系、例外说明的规则必须使用 composite_rule_v1。",
"日期字段必须区分事实日期、票据日期和业务期间;如果只能拿到替代字段,要在 rule_ir 中说明这是 fallback evidence。",
"composite_rule_v1 只能使用受控 operatorexists_any、exists_all、in_scope、not_in_scope、overlap、not_overlap、date_outside_range、contains_any、not_contains_any。",
"差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。",
"申报目的地和明细发生地点属于申报行程城市集合。",
"员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地。",
"绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。",
"如果票据路线出现申报目的地和常驻地之外的额外城市,应描述为中途周转/绕行异常。",
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
"不要直接指定 risk_level 或 risk_score只输出 risk_scoring_evidence后端会按固定评分模型计算 0-100 分和风险等级。",
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
]
examples = [
{
"user_rule": (
"差旅报销时,交通票或住宿票据中的城市均无法与申报目的地、"
"明细地点形成一致关系,且事由未说明绕行或改签原因,则高风险。"
),
"expected": {
"template_key": "field_compare_v1",
"semantic_type": "travel_route_city_consistency",
"field_keys": [
"attachment.route_cities",
"attachment.hotel_city",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理起终点A与B无交集且无合理说明或A中出现BC之外城市时命中。"
),
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
},
}
]
return [
{
"role": "system",
"content": "\n".join(
[
"你是 X-Financial 风险规则语义编译器。",
"你的任务是把自然语言规则转换成可校验 JSON 语义计划。",
"后端执行器只接受受控模板和受控字段,所以你必须严格遵守以下约束:",
*[f"- {item}" for item in guardrails],
]
),
},
{
"role": "user",
"content": json.dumps(
{
"business_domain": domain,
"business_domain_label": domain_label,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"natural_language": natural_language,
"available_fields": available_fields,
"required_json_shape": schema,
"examples": examples,
},
ensure_ascii=False,
),
},
]

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from typing import Any
TRAVEL_ROUTE_CITY_SEMANTIC_TYPE = "travel_route_city_consistency"
LEGACY_CITY_CONSISTENCY_SEMANTIC_TYPE = "travel_city_consistency"
CITY_CONSISTENCY_SEMANTIC_TYPES = {
TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
LEGACY_CITY_CONSISTENCY_SEMANTIC_TYPE,
}
CITY_CONSISTENCY_SEMANTIC_TYPE = TRAVEL_ROUTE_CITY_SEMANTIC_TYPE
RISK_LEVEL_LABELS = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
"critical": "极高风险",
}
CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city")
CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location")
CITY_HOME_FIELDS = ("employee.location",)
CITY_EXCEPTION_FIELDS = ("claim.reason", "item.item_reason")
CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更")
def is_city_consistency_rule(text: str) -> bool:
normalized = str(text or "")
has_city_subject = any(
term in normalized
for term in ("交通票", "住宿票", "住宿发票", "票据", "附件", "行程城市", "住宿城市")
)
has_reference = any(
term in normalized
for term in ("申报目的地", "申报地点", "明细地点", "发生地点", "意图城市", "目的地")
)
has_relation = any(
term in normalized
for term in ("一致", "不一致", "形成一致关系", "匹配", "无法与", "对应")
)
has_route_anomaly = any(term in normalized for term in ("绕行", "跨城", "中转", "周转", "改签"))
return has_city_subject and has_reference and (has_relation or has_route_anomaly)
def build_city_consistency_draft(
draft: dict[str, Any],
*,
natural_language: str,
fields: list[Any],
risk_level: str,
) -> dict[str, Any]:
del natural_language
field_by_key = {field.key: field for field in fields}
field_keys = [
key
for key in (
*CITY_ATTACHMENT_FIELDS,
*CITY_REFERENCE_FIELDS,
*CITY_HOME_FIELDS,
*CITY_EXCEPTION_FIELDS,
)
if key in field_by_key
]
risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险")
condition_summary = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。"
)
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
return {
**draft,
"template_key": "field_compare_v1",
"field_keys": field_keys,
"semantic_type": TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
"condition_summary": condition_summary,
"keywords": [],
"exception_keywords": list(CITY_EXCEPTION_KEYWORDS),
"flow": {
**flow,
"start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
"evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市",
"pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
"fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改",
},
}
def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]:
exception_keywords = list(draft.get("exception_keywords") or CITY_EXCEPTION_KEYWORDS)
return {
"semantic_type": TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
"attachment_city_fields": list(CITY_ATTACHMENT_FIELDS),
"reference_city_fields": list(CITY_REFERENCE_FIELDS),
"home_city_fields": list(CITY_HOME_FIELDS),
"exception_fields": list(CITY_EXCEPTION_FIELDS),
"exception_keywords": exception_keywords,
"keywords": [],
"route_anomaly_policy": "flag_unexpected_intermediate_cities",
"exception_handling": "exception_text_is_evidence_not_auto_pass_for_route_anomaly",
"formula": (
"A=UNION(attachment.route_cities, attachment.hotel_city); "
"B=UNION(claim.location, item.item_location); "
"C=UNION(employee.location); "
"HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) "
"OR EXISTS(city IN A WHERE city NOT IN BC)"
),
"conditions": [
{
"left_group": list(CITY_ATTACHMENT_FIELDS),
"operator": "route_city_consistency",
"right_group": list(CITY_REFERENCE_FIELDS),
"home_group": list(CITY_HOME_FIELDS),
"exception_fields": list(CITY_EXCEPTION_FIELDS),
"exception_keywords": exception_keywords,
}
],
}

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()]

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
import re
from typing import Any
RISK_LEVEL_LABELS: dict[str, str] = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
"critical": "极高风险",
}
RISK_SCORE_MODEL_VERSION = "risk_score_v1"
RISK_SCORE_WEIGHTS: dict[str, float] = {
"impact": 0.35,
"certainty": 0.25,
"evidence": 0.15,
"exception": 0.10,
"action": 0.10,
"sensitivity": 0.05,
}
LEVEL_SCORE_MAP: dict[str, int] = {
"none": 0,
"very_low": 12,
"low": 25,
"medium": 55,
"high": 78,
"critical": 94,
}
LEVEL_ALIASES: dict[str, str] = {
"极低": "very_low",
"很低": "very_low",
"": "low",
"低风险": "low",
"": "medium",
"中等": "medium",
"中风险": "medium",
"": "high",
"高风险": "high",
"极高": "critical",
"严重": "critical",
"重大": "critical",
"极高风险": "critical",
"very_low": "very_low",
"low": "low",
"medium": "medium",
"high": "high",
"critical": "critical",
"extreme": "critical",
}
ACTION_SCORE_MAP: dict[str, int] = {
"observe": 20,
"remind": 35,
"supplement": 48,
"manual_review": 65,
"return": 78,
"block": 94,
}
ACTION_ALIASES: dict[str, str] = {
"提示": "remind",
"提醒": "remind",
"补充": "supplement",
"补充说明": "supplement",
"人工复核": "manual_review",
"复核": "manual_review",
"审核": "manual_review",
"退回": "return",
"驳回": "return",
"阻断": "block",
"禁止": "block",
"禁止提交": "block",
"observe": "observe",
"remind": "remind",
"supplement": "supplement",
"manual_review": "manual_review",
"review": "manual_review",
"return": "return",
"reject": "return",
"block": "block",
}
SENSITIVE_CATEGORY_SCORES: dict[str, int] = {
"travel": 70,
"hotel": 76,
"transport": 68,
"meal": 72,
"meeting": 58,
"training": 48,
"communication": 36,
"office": 30,
"welfare": 42,
}
def calculate_risk_rule_score(
*,
natural_language: str,
draft: dict[str, Any],
fields: list[Any],
expense_category: str | None,
expense_category_label: str,
requires_attachment: bool,
) -> dict[str, Any]:
evidence = _read_scoring_evidence(draft)
text = _join_text(
natural_language,
draft.get("description"),
draft.get("condition_summary"),
draft.get("formula"),
draft.get("message_template"),
)
template_key = str(draft.get("template_key") or "").strip()
field_keys = _read_string_list(draft.get("field_keys"))
condition_count = len(draft.get("conditions") if isinstance(draft.get("conditions"), list) else [])
components = {
"impact": _component_score(
evidence.get("impact_level"),
_infer_impact_score(text, template_key=template_key),
),
"certainty": _component_score(
evidence.get("violation_certainty"),
_infer_certainty_score(template_key=template_key, condition_count=condition_count),
),
"evidence": _component_score(
evidence.get("evidence_strength"),
_infer_evidence_score(field_keys, fields=fields, requires_attachment=requires_attachment),
),
"exception": _component_score(
evidence.get("exception_dependence"),
_infer_exception_score(text, draft),
),
"action": _action_score(
evidence.get("control_action"),
_infer_action_score(text, draft),
),
"sensitivity": _component_score(
evidence.get("business_sensitivity"),
_infer_sensitivity_score(text, expense_category=expense_category),
),
}
score = _clamp_score(
round(sum(components[key] * RISK_SCORE_WEIGHTS[key] for key in RISK_SCORE_WEIGHTS))
)
level = risk_level_from_score(score)
return {
"score": score,
"level": level,
"level_label": RISK_LEVEL_LABELS[level],
"model": RISK_SCORE_MODEL_VERSION,
"weights": RISK_SCORE_WEIGHTS,
"components": components,
"ai_evidence": evidence,
"basis": {
"template_key": template_key,
"field_count": len(field_keys),
"condition_count": condition_count,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"requires_attachment": requires_attachment,
},
}
def apply_risk_score_to_draft(draft: dict[str, Any], score_result: dict[str, Any]) -> dict[str, Any]:
level_label = str(score_result.get("level_label") or RISK_LEVEL_LABELS["medium"]).strip()
updated = dict(draft)
flow = dict(updated.get("flow") if isinstance(updated.get("flow"), dict) else {})
flow["fail"] = _replace_or_append_risk_label(
str(flow.get("fail") or "命中风险,进入人工复核"),
level_label,
)
updated["flow"] = flow
description = str(updated.get("description") or "").strip()
if description:
updated["description"] = _replace_or_append_risk_label(description, level_label)
updated["risk_scoring_evidence"] = score_result.get("ai_evidence") or {}
return updated
def risk_level_from_score(score: int | float) -> str:
normalized = _clamp_score(round(float(score or 0)))
if normalized <= 30:
return "low"
if normalized <= 60:
return "medium"
if normalized <= 80:
return "high"
return "critical"
def _read_scoring_evidence(draft: dict[str, Any]) -> dict[str, Any]:
value = draft.get("risk_scoring_evidence")
return dict(value) if isinstance(value, dict) else {}
def _component_score(value: Any, fallback: int) -> int:
normalized = LEVEL_ALIASES.get(str(value or "").strip().lower())
if normalized:
return LEVEL_SCORE_MAP[normalized]
normalized = LEVEL_ALIASES.get(str(value or "").strip())
if normalized:
return LEVEL_SCORE_MAP[normalized]
return _clamp_score(fallback)
def _action_score(value: Any, fallback: int) -> int:
normalized = ACTION_ALIASES.get(str(value or "").strip().lower())
if normalized:
return ACTION_SCORE_MAP[normalized]
normalized = ACTION_ALIASES.get(str(value or "").strip())
if normalized:
return ACTION_SCORE_MAP[normalized]
return _clamp_score(fallback)
def _infer_impact_score(text: str, *, template_key: str) -> int:
if _contains_any(text, "伪造", "虚假", "重复报销", "骗取", "套取", "假票", "发票重复"):
return 94
if _contains_any(text, "禁止", "阻断", "退回", "超预算", "超标准", "高风险", "不一致"):
return 78
if template_key in {"field_compare_v1", "composite_rule_v1"}:
return 70
if _contains_any(text, "缺少", "未上传", "不完整", "补充"):
return 48
return 42
def _infer_certainty_score(*, template_key: str, condition_count: int) -> int:
if template_key == "composite_rule_v1":
return 84 if condition_count >= 2 else 72
if template_key == "field_compare_v1":
return 80
if template_key == "field_required_v1":
return 86
if template_key == "keyword_match_v1":
return 58
return 55
def _infer_evidence_score(
field_keys: list[str],
*,
fields: list[Any],
requires_attachment: bool,
) -> int:
keys = set(field_keys)
if any(key.startswith("attachment.") for key in keys) or requires_attachment:
return 82
field_by_key = {str(getattr(field, "key", "") or "").strip(): field for field in fields}
if any(str(getattr(field_by_key.get(key), "source", "") or "") == "claim" for key in keys):
return 62
return 50
def _infer_exception_score(text: str, draft: dict[str, Any]) -> int:
exception_keywords = _read_string_list(draft.get("exception_keywords"))
if exception_keywords or _contains_any(text, "无说明", "没有说明", "未说明", "补充说明", "合理说明"):
return 66
if _contains_any(text, "人工判断", "疑似", "可能"):
return 74
return 35
def _infer_action_score(text: str, draft: dict[str, Any]) -> int:
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
corpus = _join_text(text, flow.get("fail"), draft.get("message_template"))
if _contains_any(corpus, "禁止提交", "阻断", "不允许提交", "拦截"):
return 94
if _contains_any(corpus, "退回", "驳回", "重新提交"):
return 78
if _contains_any(corpus, "人工复核", "复核", "审核"):
return 65
if _contains_any(corpus, "补充", "说明"):
return 48
return 35
def _infer_sensitivity_score(text: str, *, expense_category: str | None) -> int:
if _contains_any(text, "发票", "票据", "虚假", "重复报销", "伪造"):
return 88
if expense_category in SENSITIVE_CATEGORY_SCORES:
return SENSITIVE_CATEGORY_SCORES[expense_category or ""]
if _contains_any(text, "差旅", "住宿", "招待", "交通"):
return 72
return 45
def _replace_or_append_risk_label(value: str, level_label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
return f"命中{level_label},进入人工复核"
replaced = re.sub(r"(低风险|中风险|高风险|极高风险)", level_label, normalized)
if replaced != normalized:
return replaced
if "风险" in normalized:
return normalized
return f"{normalized}{level_label}"
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()]
def _contains_any(text: str, *keywords: str) -> bool:
return any(keyword and keyword in text for keyword in keywords)
def _join_text(*parts: Any) -> str:
return "\n".join(str(part or "") for part in parts if str(part or "").strip())
def _clamp_score(value: int | float) -> int:
return max(0, min(100, int(round(float(value)))))

View File

@@ -1,9 +1,16 @@
from __future__ import annotations
import re
from datetime import date, datetime, timedelta
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
CITY_CONSISTENCY_SEMANTIC_TYPES = {
"travel_city_consistency",
"travel_route_city_consistency",
}
class RiskRuleTemplateExecutor:
@@ -20,9 +27,17 @@ class RiskRuleTemplateExecutor:
if template_key == "field_required_v1":
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
if template_key == "field_compare_v1":
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
return self._evaluate_city_consistency_rule(
params,
claim=claim,
contexts=contexts,
)
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
if template_key == "keyword_match_v1":
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
return None
def _evaluate_required_fields(
@@ -105,6 +120,13 @@ class RiskRuleTemplateExecutor:
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
if self._looks_like_city_consistency_rule(params):
return self._evaluate_city_consistency_rule(
params,
claim=claim,
contexts=contexts,
)
keywords = self._read_string_list(params.get("keywords"))
search_fields = self._read_string_list(
params.get("search_fields") or params.get("field_keys")
@@ -140,6 +162,331 @@ class RiskRuleTemplateExecutor:
},
}
def _evaluate_city_consistency_rule(
self,
params: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
field_keys = self._read_string_list(params.get("search_fields") or params.get("field_keys"))
reference_keys = self._read_string_list(params.get("reference_city_fields")) or [
key for key in field_keys if key in {"claim.location", "item.item_location"}
] or ["claim.location", "item.item_location"]
attachment_keys = self._read_string_list(params.get("attachment_city_fields")) or [
key
for key in field_keys
if key in {"attachment.route_cities", "attachment.hotel_city"}
] or ["attachment.route_cities", "attachment.hotel_city"]
home_keys = self._read_string_list(params.get("home_city_fields")) or ["employee.location"]
reference_values: list[str] = []
attachment_values: list[str] = []
home_values: list[str] = []
route_values: list[str] = []
for key in reference_keys:
reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
for key in attachment_keys:
resolved = self._resolve_values(key, claim=claim, contexts=contexts)
attachment_values.extend(resolved)
if key == "attachment.route_cities":
route_values.extend(resolved)
for key in home_keys:
home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
reference_values = self._dedupe_values(reference_values)
attachment_values = self._dedupe_values(attachment_values)
home_values = self._dedupe_values(home_values)
route_values = self._dedupe_values(route_values)
if not reference_values or not attachment_values:
return None
explanation_keywords = self._read_string_list(
params.get("exception_keywords") or params.get("keywords")
)
exception_fields = self._read_string_list(params.get("exception_fields")) or [
"claim.reason",
"item.item_reason",
]
explanation_corpus = "\n".join(
value
for key in exception_fields
for value in self._resolve_values(key, claim=claim, contexts=contexts)
)
keyword_hits = [
keyword
for keyword in explanation_keywords
if keyword and keyword in explanation_corpus
]
unexpected_route_cities = self._resolve_unexpected_route_cities(
route_values,
reference_values=reference_values,
home_values=home_values,
)
has_destination_overlap = self._condition_passes(
"overlap",
attachment_values,
reference_values,
)
if not unexpected_route_cities and (has_destination_overlap or keyword_hits):
return None
reason = (
"票据路线包含申报行程和常驻地之外的中转城市。"
if unexpected_route_cities
else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。"
)
return {
"message": self._resolve_message(
params,
fallback=reason,
),
"evidence": {
"failed_conditions": [
{
"left": "attachment.city",
"operator": "overlap",
"right": "claim.location",
"left_values": attachment_values[:5],
"right_values": reference_values[:5],
}
],
"condition_summary": params.get("condition_summary"),
"formula": params.get("formula"),
"city_consistency": {
"attachment_values": attachment_values[:8],
"reference_values": reference_values[:8],
"home_values": home_values[:8],
"route_values": route_values[:8],
"unexpected_route_cities": unexpected_route_cities[:8],
"explanation_keywords": explanation_keywords[:8],
"explanation_hits": keyword_hits[:8],
},
},
}
def _evaluate_composite_rule(
self,
params: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
condition_evidence: list[dict[str, Any]] = []
condition_results: dict[str, bool] = {}
for index, condition in enumerate(conditions):
if not isinstance(condition, dict):
continue
condition_id = str(condition.get("id") or f"condition_{index + 1}").strip()
passed, evidence = self._evaluate_composite_condition(
condition,
claim=claim,
contexts=contexts,
)
condition_results[condition_id] = passed
condition_evidence.append({"id": condition_id, **evidence, "passed": passed})
hit_logic = params.get("hit_logic")
hit = (
self._evaluate_logic_node(hit_logic, condition_results)
if isinstance(hit_logic, (dict, list, str))
else bool(condition_results) and all(condition_results.values())
)
if not hit:
return None
return {
"message": self._resolve_message(
params,
fallback=str(params.get("condition_summary") or "复合规则条件命中,进入人工复核。"),
),
"evidence": {
"condition_summary": params.get("condition_summary"),
"formula": params.get("formula"),
"semantic_type": params.get("semantic_type"),
"conditions": condition_evidence,
"condition_results": condition_results,
"hit_logic": hit_logic,
"rule_ir": params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {},
},
}
def _evaluate_composite_condition(
self,
condition: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> tuple[bool, dict[str, Any]]:
operator = str(condition.get("operator") or "").strip()
fields = self._read_string_list(condition.get("fields"))
left_fields = self._read_string_list(condition.get("left_fields"))
right_fields = self._read_string_list(condition.get("right_fields"))
if operator == "exists_any":
values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
return bool(values), {"operator": operator, "fields": fields, "values": values[:8]}
if operator in {"exists_all", "all_present"}:
missing = [
key for key in fields if not self._resolve_values(key, claim=claim, contexts=contexts)
]
return not missing, {"operator": operator, "fields": fields, "missing_fields": missing}
if operator in {"not_in_scope", "not_in_set", "not_overlap"}:
left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts)
right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts)
matched = self._values_overlap(left_values, right_values)
return bool(left_values and right_values and not matched), {
"operator": operator,
"left_fields": left_fields,
"right_fields": right_fields,
"left_values": left_values[:8],
"right_values": right_values[:8],
}
if operator in {"in_scope", "overlap"}:
left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts)
right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts)
return self._values_overlap(left_values, right_values), {
"operator": operator,
"left_fields": left_fields,
"right_fields": right_fields,
"left_values": left_values[:8],
"right_values": right_values[:8],
}
if operator == "date_outside_range":
return self._evaluate_date_outside_range(condition, claim=claim, contexts=contexts)
if operator in {"not_contains_any", "contains_any"}:
keywords = self._read_string_list(condition.get("keywords"))
values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
corpus = "\n".join(values)
hits = [keyword for keyword in keywords if keyword and keyword in corpus]
passed = not hits if operator == "not_contains_any" else bool(hits)
return passed, {
"operator": operator,
"fields": fields,
"keyword_hits": hits[:8],
"values": values[:8],
}
left = str(condition.get("left") or "").strip()
right = str(condition.get("right") or "").strip()
if left:
left_values = self._resolve_values(left, claim=claim, contexts=contexts)
right_values = self._resolve_values(right, claim=claim, contexts=contexts) if right else []
passed = self._condition_passes(operator or "overlap", left_values, right_values)
return passed, {
"operator": operator or "overlap",
"left": left,
"right": right,
"left_values": left_values[:8],
"right_values": right_values[:8],
}
return False, {"operator": operator or "unknown"}
def _evaluate_date_outside_range(
self,
condition: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> tuple[bool, dict[str, Any]]:
date_fields = self._read_string_list(condition.get("date_fields"))
start_fields = self._read_string_list(condition.get("range_start_fields"))
end_fields = self._read_string_list(condition.get("range_end_fields"))
tolerance_days = int(condition.get("tolerance_days") or 0)
dates = self._resolve_group_dates(date_fields, claim=claim, contexts=contexts)
starts = self._resolve_group_dates(start_fields, claim=claim, contexts=contexts)
ends = self._resolve_group_dates(end_fields, claim=claim, contexts=contexts)
if not dates or not (starts or ends):
return False, {
"operator": "date_outside_range",
"date_fields": date_fields,
"range_start_fields": start_fields,
"range_end_fields": end_fields,
"dates": [item.isoformat() for item in dates],
"range_start": None,
"range_end": None,
}
start = min(starts or ends) - timedelta(days=tolerance_days)
end = max(ends or starts) + timedelta(days=tolerance_days)
outside = [item for item in dates if item < start or item > end]
return bool(outside), {
"operator": "date_outside_range",
"date_fields": date_fields,
"range_start_fields": start_fields,
"range_end_fields": end_fields,
"dates": [item.isoformat() for item in dates],
"range_start": start.isoformat(),
"range_end": end.isoformat(),
"outside_dates": [item.isoformat() for item in outside],
}
def _resolve_group_values(
self,
field_keys: list[str],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> list[str]:
values: list[str] = []
for key in field_keys:
values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
return self._dedupe_values(values)
def _resolve_group_dates(
self,
field_keys: list[str],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> list[date]:
values: list[date] = []
for key in field_keys:
for value in self._resolve_values(key, claim=claim, contexts=contexts):
parsed = self._parse_date_value(value)
if parsed and parsed not in values:
values.append(parsed)
return values
@staticmethod
def _evaluate_logic_node(node: Any, condition_results: dict[str, bool]) -> bool:
if isinstance(node, str):
return bool(condition_results.get(node))
if isinstance(node, list):
return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in node)
if not isinstance(node, dict):
return bool(node)
if "all" in node:
values = node.get("all") if isinstance(node.get("all"), list) else []
return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values)
if "any" in node:
values = node.get("any") if isinstance(node.get("any"), list) else []
return any(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values)
if "not" in node:
return not RiskRuleTemplateExecutor._evaluate_logic_node(node.get("not"), condition_results)
return False
@staticmethod
def _looks_like_city_consistency_rule(params: dict[str, Any]) -> bool:
field_keys = RiskRuleTemplateExecutor._read_string_list(
params.get("search_fields") or params.get("field_keys")
)
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
return True
has_reference = any(key in {"claim.location", "item.item_location"} for key in field_keys)
has_attachment_city = any(
key in {"attachment.route_cities", "attachment.hotel_city"} for key in field_keys
)
if not (has_reference and has_attachment_city):
return False
text = "\n".join(
str(params.get(key) or "")
for key in ("natural_language", "condition_summary", "message_template")
)
consistency_terms = ("一致", "不一致", "匹配", "不符", "对应", "出现在")
city_terms = ("城市", "地点", "目的地", "行程", "票据", "发票")
return any(term in text for term in consistency_terms) and any(
term in text for term in city_terms
)
def _resolve_values(
self,
field_key: str,
@@ -150,6 +497,12 @@ class RiskRuleTemplateExecutor:
normalized = str(field_key or "").strip()
if not normalized:
return []
if normalized == "claim.trip_start_date":
explicit = getattr(claim, "trip_start_date", None)
return self._normalize_values([explicit or self._claim_trip_date(claim, start=True)])
if normalized == "claim.trip_end_date":
explicit = getattr(claim, "trip_end_date", None)
return self._normalize_values([explicit or self._claim_trip_date(claim, start=False)])
if normalized.startswith("claim."):
return self._normalize_values([getattr(claim, normalized.removeprefix("claim."), "")])
if normalized.startswith("item."):
@@ -157,10 +510,39 @@ class RiskRuleTemplateExecutor:
return self._normalize_values(
[getattr(item, attr, "") for item in list(claim.items or [])]
)
if normalized.startswith("employee."):
employee = getattr(claim, "employee", None)
if employee is None:
return []
return self._normalize_values(
[getattr(employee, normalized.removeprefix("employee."), "")]
)
if normalized.startswith("attachment."):
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
return []
@staticmethod
def _resolve_unexpected_route_cities(
route_values: list[str],
*,
reference_values: list[str],
home_values: list[str],
) -> list[str]:
if len(route_values) < 2:
return []
allowed = {value.lower() for value in [*reference_values, *home_values] if value}
if not allowed:
return []
candidates = route_values if home_values else route_values[1:-1]
unexpected: list[str] = []
for city in candidates:
normalized = city.lower()
if normalized in allowed:
continue
if city not in unexpected:
unexpected.append(city)
return unexpected
def _resolve_attachment_values(
self, field_key: str, contexts: list[dict[str, Any]]
) -> list[str]:
@@ -171,13 +553,15 @@ class RiskRuleTemplateExecutor:
document_info = {}
if field_key == "ocr_text":
values.extend([context.get("ocr_text"), context.get("ocr_summary")])
if field_key in {"hotel_city", "route_cities"}:
if field_key == "hotel_city":
specific_values = self._scan_document_values(document_info, field_key)
values.extend(
specific_values
if specific_values
else self._scan_document_values(document_info, "city")
)
elif field_key == "route_cities":
values.extend(self._scan_document_values(document_info, field_key))
else:
values.extend(self._scan_document_values(document_info, field_key))
return self._normalize_values(values)
@@ -207,6 +591,8 @@ class RiskRuleTemplateExecutor:
"buyer_name": ("购买方", "抬头", "买方"),
"goods_name": ("品名", "商品", "服务名称"),
"issue_date": ("日期", "开票日期", "发票日期"),
"stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
"stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
"route_cities": ("行程", "路线", "目的地", "出差城市"),
"city": ("城市", "地点"),
@@ -222,6 +608,17 @@ class RiskRuleTemplateExecutor:
) -> bool:
return bool(self._resolve_values(field_key, claim=claim, contexts=contexts))
@staticmethod
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
item_dates = [
item.item_date
for item in list(claim.items or [])
if getattr(item, "item_date", None) is not None
]
if item_dates:
return min(item_dates) if start else max(item_dates)
return getattr(claim, "occurred_at", None)
@staticmethod
def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
if operator == "is_empty":
@@ -239,6 +636,43 @@ class RiskRuleTemplateExecutor:
return any(any(right in left for right in right_set) for left in left_set)
return bool(left_set & right_set)
@staticmethod
def _values_overlap(left_values: list[str], right_values: list[str]) -> bool:
left_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in left_values]
right_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in right_values]
for left in left_set:
for right in right_set:
if left and right and (left == right or left in right or right in left):
return True
return False
@staticmethod
def _normalize_match_value(value: str) -> str:
return re.sub(r"[省市区县\s]+$", "", str(value or "").strip().lower())
@staticmethod
def _parse_date_value(value: Any) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value or "").strip()
if not text:
return None
iso_match = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", text)
cn_match = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text)
match = iso_match or cn_match
if match:
year, month, day = (int(part) for part in match.groups())
try:
return date(year, month, day)
except ValueError:
return None
try:
return date.fromisoformat(text[:10])
except ValueError:
return None
@staticmethod
def _normalize_values(values: list[Any]) -> list[str]:
normalized: list[str] = []
@@ -251,6 +685,15 @@ class RiskRuleTemplateExecutor:
normalized.append(text)
return normalized
@staticmethod
def _dedupe_values(values: list[str]) -> list[str]:
deduped: list[str] = []
for value in values:
text = str(value or "").strip()
if text and text not in deduped:
deduped.append(text)
return deduped
@staticmethod
def _read_string_list(value: Any) -> list[str]:
if not isinstance(value, list):

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)

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import re
DIRECT_MUNICIPALITY_DISPLAY = {
"北京": "北京市",
"北京市": "北京市",
"上海": "上海市",
"上海市": "上海市",
"天津": "天津市",
"天津市": "天津市",
"重庆": "重庆市",
"重庆市": "重庆市",
}
PROVINCE_ALIASES = {
"新疆维吾尔自治区": "新疆",
"新疆": "新疆",
"广东省": "广东",
"广东": "广东",
"浙江省": "浙江",
"浙江": "浙江",
"江苏省": "江苏",
"江苏": "江苏",
"四川省": "四川",
"四川": "四川",
"湖北省": "湖北",
"湖北": "湖北",
"陕西省": "陕西",
"陕西": "陕西",
"山东省": "山东",
"山东": "山东",
"湖南省": "湖南",
"湖南": "湖南",
"河南省": "河南",
"河南": "河南",
"安徽省": "安徽",
"安徽": "安徽",
"福建省": "福建",
"福建": "福建",
"云南省": "云南",
"云南": "云南",
"江西省": "江西",
"江西": "江西",
"辽宁省": "辽宁",
"辽宁": "辽宁",
}
CITY_TO_PROVINCE = {
"伊犁": "新疆",
"伊犁哈萨克自治州": "新疆",
"乌鲁木齐": "新疆",
"克拉玛依": "新疆",
"喀什": "新疆",
"广州": "广东",
"深圳": "广东",
"佛山": "广东",
"东莞": "广东",
"杭州": "浙江",
"宁波": "浙江",
"南京": "江苏",
"苏州": "江苏",
"无锡": "江苏",
"成都": "四川",
"武汉": "湖北",
"西安": "陕西",
"青岛": "山东",
"济南": "山东",
"长沙": "湖南",
"郑州": "河南",
"合肥": "安徽",
"福州": "福建",
"厦门": "福建",
"昆明": "云南",
"南昌": "江西",
"沈阳": "辽宁",
"大连": "辽宁",
}
LOCATION_NOISE_PATTERN = re.compile(
r"(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$"
)
def normalize_application_location(value: str) -> str:
text = _cleanup_location_text(value)
if not text:
return ""
direct = _resolve_direct_municipality(text)
if direct:
return direct
province_city = _resolve_province_city(text)
if province_city:
return province_city
return text[:12]
def _cleanup_location_text(value: str) -> str:
text = re.sub(r"\s+", "", str(value or ""))
text = text.strip(":,。;;、")
text = re.sub(r"^(?:地点|业务地点|发生地点)[:]", "", text)
text = re.sub(r"^(?:去|到|赴|前往)", "", text)
text = LOCATION_NOISE_PATTERN.sub("", text)
return text.strip(":,。;;、")
def _resolve_direct_municipality(text: str) -> str:
for key, display in DIRECT_MUNICIPALITY_DISPLAY.items():
if text.startswith(key):
return display
return ""
def _resolve_province_city(text: str) -> str:
for province_alias, province_display in PROVINCE_ALIASES.items():
if not text.startswith(province_alias):
continue
remainder = text[len(province_alias) :].strip("省市地区自治州盟,,、")
if not remainder:
return province_display
city = _resolve_city_name(remainder)
return f"{province_display}{city}" if city else province_display
city = _resolve_city_name(text)
if city:
province = CITY_TO_PROVINCE.get(city)
return f"{province}{city}" if province else city
return ""
def _resolve_city_name(text: str) -> str:
normalized = text.strip(",、")
if not normalized:
return ""
for city in sorted(CITY_TO_PROVINCE, key=len, reverse=True):
if normalized.startswith(city):
return _display_city_name(city)
return ""
def _display_city_name(city: str) -> str:
if city == "伊犁哈萨克自治州":
return "伊犁"
return city.removesuffix("")