feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import re
from calendar import monthrange
from datetime import date, datetime, timedelta
from typing import Any
@@ -506,8 +507,8 @@ class RiskRuleTemplateExecutor:
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)
if parsed and parsed not in values:
values.append(parsed)
return values
def _resolve_group_numbers(
@@ -695,6 +696,9 @@ class RiskRuleTemplateExecutor:
@staticmethod
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
application_date = RiskRuleTemplateExecutor._claim_application_trip_date(claim, start=start)
if application_date is not None:
return application_date
item_dates = [
item.item_date
for item in list(claim.items or [])
@@ -704,6 +708,166 @@ class RiskRuleTemplateExecutor:
return min(item_dates) if start else max(item_dates)
return getattr(claim, "occurred_at", None)
@staticmethod
def _claim_application_trip_date(claim: ExpenseClaim, *, start: bool) -> date | None:
windows: list[tuple[date, date]] = []
reference_year = RiskRuleTemplateExecutor._claim_reference_year(claim)
for raw_value in RiskRuleTemplateExecutor._iter_application_time_values(claim):
windows.extend(
RiskRuleTemplateExecutor._parse_date_windows(
raw_value,
reference_year=reference_year,
)
)
if not windows:
return None
values = [window[0] if start else window[1] for window in windows]
return min(values) if start else max(values)
@staticmethod
def _claim_reference_year(claim: ExpenseClaim) -> int | None:
for value in [getattr(claim, "occurred_at", None)]:
parsed = RiskRuleTemplateExecutor._parse_date_value(value)
if parsed is not None:
return parsed.year
for item in list(claim.items or []):
parsed = RiskRuleTemplateExecutor._parse_date_value(getattr(item, "item_date", None))
if parsed is not None:
return parsed.year
return None
@staticmethod
def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = []
application_sources = {"application_detail", "application_handoff", "application_link"}
time_keys = (
"application_time",
"applicationTime",
"application_date",
"applicationDate",
"business_time",
"businessTime",
"time_range",
"timeRange",
"time",
"date",
)
nested_keys = (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
)
for flag in list(getattr(claim, "risk_flags_json", None) or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_anchor = (
source in application_sources
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
)
if not has_application_anchor:
continue
sources: list[dict[str, Any]] = [flag]
for key in nested_keys:
nested = flag.get(key)
if isinstance(nested, dict):
sources.append(nested)
for source_dict in sources:
for key in time_keys:
value = source_dict.get(key)
if value not in (None, ""):
values.append(value)
return values
@staticmethod
def _parse_date_windows(
value: Any,
*,
reference_year: int | None = None,
) -> list[tuple[date, date]]:
if isinstance(value, datetime):
item = value.date()
return [(item, item)]
if isinstance(value, date):
return [(value, value)]
text = str(value or "").strip()
if not text:
return []
exact_dates = RiskRuleTemplateExecutor._parse_exact_dates(
text,
reference_year=reference_year,
)
if exact_dates:
return [(min(exact_dates), max(exact_dates))]
month_windows = RiskRuleTemplateExecutor._parse_month_windows(
text,
reference_year=reference_year,
)
if month_windows:
return month_windows
return []
@staticmethod
def _parse_exact_dates(text: str, *, reference_year: int | None = None) -> list[date]:
values: list[date] = []
def append_date(year: int, month: int, day: int) -> None:
try:
parsed = date(year, month, day)
except ValueError:
return
if parsed not in values:
values.append(parsed)
for pattern in (
r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})",
r"(\d{4})年(\d{1,2})月(\d{1,2})日?",
):
for match in re.finditer(pattern, text):
year, month, day = (int(part) for part in match.groups())
append_date(year, month, day)
if reference_year is not None:
for match in re.finditer(r"(?<!\d)(\d{1,2})月(\d{1,2})日?", text):
month, day = (int(part) for part in match.groups())
append_date(reference_year, month, day)
return values
@staticmethod
def _parse_month_windows(
text: str,
*,
reference_year: int | None = None,
) -> list[tuple[date, date]]:
windows: list[tuple[date, date]] = []
def append_month(year: int, month: int) -> None:
if month < 1 or month > 12:
return
last_day = monthrange(year, month)[1]
window = (date(year, month, 1), date(year, month, last_day))
if window not in windows:
windows.append(window)
for match in re.finditer(r"(\d{4})[-/.](\d{1,2})(?![-/.]\d)", text):
year, month = (int(part) for part in match.groups())
append_month(year, month)
for match in re.finditer(r"(\d{4})年(\d{1,2})月(?!\d)", text):
year, month = (int(part) for part in match.groups())
append_month(year, month)
if reference_year is not None:
for match in re.finditer(r"(?<!\d)(\d{1,2})月(?!\d|日)", text):
append_month(reference_year, int(match.group(1)))
return windows
@staticmethod
def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
if operator == "is_empty":