feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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 ""),
|
||||
|
||||
250
server/src/app/services/agent_asset_risk_rule_level.py
Normal file
250
server/src/app/services/agent_asset_risk_rule_level.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 ["北京"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
|
||||
86
server/src/app/services/document_numbering.py
Normal file
86
server/src/app/services/document_numbering.py
Normal 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-"))
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -103,7 +103,7 @@ def build_import_template_bytes() -> bytes:
|
||||
("办公地点", "可选。"),
|
||||
("岗位*", "必填。"),
|
||||
("职级*", "必填,例如 P3、P5。"),
|
||||
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
|
||||
("部门编码", "可选,须与系统组织编码一致,例如 FINANCE-DEPT。"),
|
||||
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
|
||||
("财务归口", "可选。"),
|
||||
("成本中心", "可选。"),
|
||||
|
||||
53
server/src/app/services/employee_time.py
Normal file
53
server/src/app/services/employee_time.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
83
server/src/app/services/expense_claim_application_handoff.py
Normal file
83
server/src/app/services/expense_claim_application_handoff.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
"}"
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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),
|
||||
"```",
|
||||
]
|
||||
)
|
||||
|
||||
17
server/src/app/services/risk_rule_generation_interpreter.py
Normal file
17
server/src/app/services/risk_rule_generation_interpreter.py
Normal 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",
|
||||
}
|
||||
336
server/src/app/services/risk_rule_generation_jobs.py
Normal file
336
server/src/app/services/risk_rule_generation_jobs.py
Normal 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")
|
||||
39
server/src/app/services/risk_rule_generation_markdown.py
Normal file
39
server/src/app/services/risk_rule_generation_markdown.py
Normal 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),
|
||||
"```",
|
||||
]
|
||||
)
|
||||
163
server/src/app/services/risk_rule_generation_ontology.py
Normal file
163
server/src/app/services/risk_rule_generation_ontology.py
Normal 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.",),
|
||||
}
|
||||
147
server/src/app/services/risk_rule_generation_prompt.py
Normal file
147
server/src/app/services/risk_rule_generation_prompt.py
Normal 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 只能使用受控 operator:exists_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中出现B∪C之外城市时命中。"
|
||||
),
|
||||
"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,
|
||||
),
|
||||
},
|
||||
]
|
||||
119
server/src/app/services/risk_rule_generation_semantics.py
Normal file
119
server/src/app/services/risk_rule_generation_semantics.py
Normal 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无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
)
|
||||
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 B∪C)"
|
||||
),
|
||||
"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,
|
||||
}
|
||||
],
|
||||
}
|
||||
199
server/src/app/services/risk_rule_manifest_normalizer.py
Normal file
199
server/src/app/services/risk_rule_manifest_normalizer.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramField,
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
)
|
||||
from app.services.risk_rule_generation_semantics import (
|
||||
CITY_ATTACHMENT_FIELDS,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPE,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES,
|
||||
CITY_EXCEPTION_FIELDS,
|
||||
CITY_EXCEPTION_KEYWORDS,
|
||||
CITY_HOME_FIELDS,
|
||||
CITY_REFERENCE_FIELDS,
|
||||
build_city_consistency_params,
|
||||
is_city_consistency_rule,
|
||||
)
|
||||
|
||||
RISK_LEVEL_LABELS = {
|
||||
"low": "低风险",
|
||||
"medium": "中风险",
|
||||
"high": "高风险",
|
||||
"critical": "极高风险",
|
||||
}
|
||||
|
||||
CITY_ROUTE_CONDITION_SUMMARY = (
|
||||
"判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
)
|
||||
CITY_ROUTE_FLOW_DECISION = (
|
||||
"附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市"
|
||||
)
|
||||
CITY_ROUTE_FLOW_EVIDENCE = (
|
||||
"读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
|
||||
)
|
||||
|
||||
|
||||
def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把历史误编译的城市一致性规则规范为受控语义 DSL。"""
|
||||
|
||||
if not isinstance(manifest, dict) or not _looks_like_city_consistency_manifest(manifest):
|
||||
return manifest
|
||||
|
||||
payload = dict(manifest)
|
||||
metadata = dict(payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {})
|
||||
params = dict(payload.get("params") if isinstance(payload.get("params"), dict) else {})
|
||||
exception_keywords = _read_string_list(
|
||||
payload.get("exception_keywords") or params.get("exception_keywords")
|
||||
) or list(CITY_EXCEPTION_KEYWORDS)
|
||||
field_keys = _resolve_city_field_keys(payload, params)
|
||||
severity = _resolve_severity(payload)
|
||||
severity_label = RISK_LEVEL_LABELS.get(severity, "中风险")
|
||||
|
||||
payload["template_key"] = "field_compare_v1"
|
||||
payload["semantic_type"] = CITY_CONSISTENCY_SEMANTIC_TYPE
|
||||
payload["keywords"] = []
|
||||
payload["exception_keywords"] = exception_keywords
|
||||
|
||||
params.update(
|
||||
build_city_consistency_params(
|
||||
{
|
||||
"exception_keywords": exception_keywords,
|
||||
}
|
||||
)
|
||||
)
|
||||
params["template_key"] = "field_compare_v1"
|
||||
params["field_keys"] = field_keys
|
||||
params["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
||||
payload["params"] = params
|
||||
|
||||
payload["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
||||
flow = dict(metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {})
|
||||
flow["evidence"] = CITY_ROUTE_FLOW_EVIDENCE
|
||||
flow["decision"] = CITY_ROUTE_FLOW_DECISION
|
||||
flow.setdefault(
|
||||
"start",
|
||||
"差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
|
||||
)
|
||||
flow.setdefault(
|
||||
"pass",
|
||||
"票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
|
||||
)
|
||||
flow["fail"] = (
|
||||
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"
|
||||
)
|
||||
metadata["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
|
||||
metadata["flow"] = flow
|
||||
payload["metadata"] = metadata
|
||||
payload["flow_diagram_svg"] = _build_city_flow_svg(payload, field_keys, severity, severity_label)
|
||||
return payload
|
||||
|
||||
|
||||
def _looks_like_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
semantic_type = str(manifest.get("semantic_type") or params.get("semantic_type") or "").strip()
|
||||
if semantic_type in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return True
|
||||
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
text = "\n".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
metadata.get("natural_language"),
|
||||
params.get("natural_language"),
|
||||
manifest.get("description"),
|
||||
metadata.get("condition_summary"),
|
||||
params.get("condition_summary"),
|
||||
)
|
||||
)
|
||||
if is_city_consistency_rule(text):
|
||||
return True
|
||||
|
||||
field_keys = set(_resolve_city_field_keys(manifest, params))
|
||||
has_attachment_city = bool(field_keys & set(CITY_ATTACHMENT_FIELDS))
|
||||
has_reference_city = bool(field_keys & set(CITY_REFERENCE_FIELDS))
|
||||
return has_attachment_city and has_reference_city and "风险关键词" in text
|
||||
|
||||
|
||||
def _resolve_city_field_keys(manifest: dict[str, Any], params: dict[str, Any]) -> list[str]:
|
||||
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
|
||||
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||
known = {
|
||||
str(item.get("key") or "").strip()
|
||||
for item in input_fields
|
||||
if isinstance(item, dict) and str(item.get("key") or "").strip()
|
||||
}
|
||||
candidates = [
|
||||
*_read_string_list(manifest.get("field_keys")),
|
||||
*_read_string_list(params.get("field_keys") or params.get("search_fields")),
|
||||
*CITY_ATTACHMENT_FIELDS,
|
||||
*CITY_REFERENCE_FIELDS,
|
||||
*CITY_HOME_FIELDS,
|
||||
*CITY_EXCEPTION_FIELDS,
|
||||
]
|
||||
resolved: list[str] = []
|
||||
for key in candidates:
|
||||
if known and key not in known and key not in {
|
||||
*CITY_ATTACHMENT_FIELDS,
|
||||
*CITY_REFERENCE_FIELDS,
|
||||
*CITY_HOME_FIELDS,
|
||||
*CITY_EXCEPTION_FIELDS,
|
||||
}:
|
||||
continue
|
||||
if key not in resolved:
|
||||
resolved.append(key)
|
||||
return resolved
|
||||
|
||||
|
||||
def _build_city_flow_svg(
|
||||
payload: dict[str, Any],
|
||||
field_keys: list[str],
|
||||
severity: str,
|
||||
severity_label: str,
|
||||
) -> str:
|
||||
inputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
|
||||
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||
label_by_key = {
|
||||
str(item.get("key") or "").strip(): str(item.get("label") or item.get("key") or "").strip()
|
||||
for item in input_fields
|
||||
if isinstance(item, dict) and str(item.get("key") or "").strip()
|
||||
}
|
||||
fields = tuple(
|
||||
RiskRuleFlowDiagramField(key=key, label=label_by_key.get(key) or key)
|
||||
for key in field_keys[:8]
|
||||
)
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
|
||||
return RiskRuleFlowDiagramRenderer().render(
|
||||
RiskRuleFlowDiagramSpec(
|
||||
title=str(payload.get("name") or "风险规则判断流程").strip(),
|
||||
domain_label=str(payload.get("risk_category") or "差旅费").strip(),
|
||||
severity=severity,
|
||||
severity_label=severity_label,
|
||||
fields=fields,
|
||||
start=str(flow.get("start") or "差旅报销单据提交").strip(),
|
||||
evidence=CITY_ROUTE_FLOW_EVIDENCE,
|
||||
decision=CITY_ROUTE_FLOW_DECISION,
|
||||
basis=CITY_ROUTE_CONDITION_SUMMARY,
|
||||
pass_text=str(flow.get("pass") or "未命中风险,继续流转").strip(),
|
||||
fail_text=str(flow.get("fail") or f"命中{severity_label},进入人工复核").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_severity(payload: dict[str, Any]) -> str:
|
||||
outcomes = payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {}
|
||||
fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail.get("severity") or payload.get("severity") or "").strip().lower()
|
||||
return severity if severity in RISK_LEVEL_LABELS else "medium"
|
||||
|
||||
|
||||
def _read_string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item or "").strip() for item in value if str(item or "").strip()]
|
||||
322
server/src/app/services/risk_rule_scoring.py
Normal file
322
server/src/app/services/risk_rule_scoring.py
Normal 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)))))
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
148
server/src/app/services/user_agent_application_locations.py
Normal file
148
server/src/app/services/user_agent_application_locations.py
Normal 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("市")
|
||||
Reference in New Issue
Block a user