feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -15,18 +15,27 @@ from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
)
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class ExpenseClaimPlatformRiskMixin:
|
||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
||||
|
||||
def evaluate_platform_risk_rules(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
rule_codes: list[str] | None = None,
|
||||
business_stage: str | None = None,
|
||||
) -> dict[str, list[Any]]:
|
||||
manifests = self._load_platform_risk_rule_manifests(rule_codes=rule_codes)
|
||||
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
|
||||
manifests = self._load_platform_risk_rule_manifests(
|
||||
rule_codes=rule_codes,
|
||||
business_stage=normalized_stage,
|
||||
)
|
||||
if not manifests:
|
||||
return {"flags": [], "blocking_reasons": []}
|
||||
|
||||
@@ -69,6 +78,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
self,
|
||||
*,
|
||||
rule_codes: list[str] | None,
|
||||
business_stage: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
code_filter = {
|
||||
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip()
|
||||
@@ -117,7 +127,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
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
|
||||
if payload.get("enabled") is False:
|
||||
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
|
||||
payload,
|
||||
business_stage=business_stage,
|
||||
):
|
||||
continue
|
||||
|
||||
payload = dict(payload)
|
||||
@@ -149,7 +162,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
continue
|
||||
if code_filter and rule_code not in missing_codes:
|
||||
continue
|
||||
if payload.get("enabled") is False:
|
||||
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
|
||||
payload,
|
||||
business_stage=business_stage,
|
||||
):
|
||||
continue
|
||||
payload = dict(payload)
|
||||
payload["_rule_version"] = "v1.0.0"
|
||||
@@ -157,6 +173,34 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
|
||||
return list(manifests_by_code.values())
|
||||
|
||||
@classmethod
|
||||
def _normalize_platform_risk_business_stage(cls, value: str | None) -> str:
|
||||
normalized = str(value or cls._DEFAULT_RISK_BUSINESS_STAGE).strip().lower()
|
||||
if not normalized or normalized not in cls._SUPPORTED_RISK_BUSINESS_STAGES:
|
||||
return cls._DEFAULT_RISK_BUSINESS_STAGE
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _risk_manifest_matches_business_stage(
|
||||
cls,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
business_stage: str | None,
|
||||
) -> bool:
|
||||
if not business_stage:
|
||||
return True
|
||||
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
raw_stages = applies_to.get("business_stages")
|
||||
if not isinstance(raw_stages, list):
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
raw_stages = [manifest.get("business_stage") or metadata.get("business_stage") or cls._DEFAULT_RISK_BUSINESS_STAGE]
|
||||
stages = {
|
||||
cls._normalize_platform_risk_business_stage(str(item))
|
||||
for item in raw_stages
|
||||
if str(item or "").strip()
|
||||
}
|
||||
return business_stage in (stages or {cls._DEFAULT_RISK_BUSINESS_STAGE})
|
||||
|
||||
def _risk_manifest_applies_to_claim(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
@@ -187,9 +231,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
configured_expense_types = self._normalize_expense_type_values(
|
||||
*[str(value or "") for value in list(applies_to.get("expense_types") or [])]
|
||||
)
|
||||
configured_expense_categories = self._normalize_expense_type_values(
|
||||
*[str(value or "") for value in list(applies_to.get("expense_categories") or [])]
|
||||
)
|
||||
|
||||
if self._is_all_expense_scope(configured_expense_types):
|
||||
configured_expense_types = set()
|
||||
if self._is_all_expense_scope(configured_expense_categories):
|
||||
configured_expense_categories = set()
|
||||
|
||||
if configured_expense_types and not (expense_types & configured_expense_types):
|
||||
return False
|
||||
if configured_expense_categories and not (expense_types & configured_expense_categories):
|
||||
return False
|
||||
if domains and not self._risk_domains_match_claim(
|
||||
domains,
|
||||
expense_types=expense_types,
|
||||
@@ -207,11 +261,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
if not raw:
|
||||
continue
|
||||
normalized.add(raw.lower())
|
||||
if raw in {"全部", "通用"}:
|
||||
normalized.add("all")
|
||||
if raw.lower().endswith("_application"):
|
||||
normalized.add(raw.lower().removesuffix("_application"))
|
||||
resolved = resolve_expense_type_code_from_text(raw)
|
||||
if resolved:
|
||||
normalized.add(resolved)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _is_all_expense_scope(values: set[str]) -> bool:
|
||||
return bool(values & {"all", "*", "overall", "general", "全部", "通用"})
|
||||
|
||||
def _risk_domains_match_claim(
|
||||
self,
|
||||
domains: set[str],
|
||||
@@ -634,25 +696,12 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
message: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
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 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()
|
||||
|
||||
return {
|
||||
"source": "submission_review",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"evidence": evidence,
|
||||
}
|
||||
return build_platform_risk_flag(
|
||||
manifest,
|
||||
message=message,
|
||||
evidence=evidence,
|
||||
default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _count_values(values: list[str]) -> dict[str, int]:
|
||||
|
||||
Reference in New Issue
Block a user