feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user