Files
X-Financial/server/src/app/services/risk_rule_template_executor.py
caoxiaozhu ca691f3ee0 feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
2026-06-02 14:01:51 +08:00

970 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from calendar import monthrange
from datetime import date, datetime, timedelta
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.risk_rule_execution_trace import build_risk_rule_execution_trace
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_value_compare import compare_numbers, duplicate_text_values, parse_number_value
CITY_CONSISTENCY_SEMANTIC_TYPES = {
"travel_city_consistency",
"travel_route_city_consistency",
}
ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->||-|—|~||/|、||,|;|)\s*")
class RiskRuleTemplateExecutor:
def evaluate_with_trace(
self,
manifest: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any]:
result = self.evaluate(manifest, claim=claim, contexts=contexts)
return {
"hit": result is not None,
"result": result,
"trace": build_risk_rule_execution_trace(manifest, result=result),
}
def evaluate(
self,
manifest: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
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()
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(
self,
params: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
required_fields = self._read_string_list(
params.get("required_fields") or params.get("field_keys")
)
missing = [
field_key
for field_key in required_fields
if not self._resolve_values(field_key, claim=claim, contexts=contexts)
]
if not missing:
return None
return {
"message": self._resolve_message(
params,
fallback=f"规则要求的字段未完整提供:{''.join(missing[:4])}",
),
"evidence": {
"missing_fields": missing,
"condition_summary": params.get("condition_summary"),
},
}
def _evaluate_compare_conditions(
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 []
failures: list[dict[str, Any]] = []
for index, condition in enumerate(conditions, start=1):
if not isinstance(condition, dict):
continue
condition_id = str(condition.get("id") or f"condition_{index}").strip()
left_key = str(condition.get("left") or "").strip()
right_key = str(condition.get("right") or "").strip()
operator = str(condition.get("operator") or "not_overlap").strip()
left_values = self._resolve_values(left_key, claim=claim, contexts=contexts)
right_values = self._resolve_values(right_key, claim=claim, contexts=contexts)
if self._condition_passes(operator, left_values, right_values):
continue
failures.append(
{
"left": left_key,
"id": condition_id,
"operator": operator,
"right": right_key,
"left_values": left_values[:5],
"right_values": right_values[:5],
}
)
if not failures:
return None
return {
"message": self._resolve_message(
params,
fallback=(
"规则字段对比未通过:"
f"{params.get('condition_summary') or '字段关系不符合要求'}"
),
),
"evidence": {
"failed_conditions": failures[:5],
"condition_summary": params.get("condition_summary"),
},
}
def _evaluate_keyword_match(
self,
params: dict[str, Any],
*,
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")
)
if not keywords:
return None
corpus_parts: list[str] = []
for field_key in search_fields:
corpus_parts.extend(self._resolve_values(field_key, claim=claim, contexts=contexts))
if not corpus_parts:
corpus_parts.extend(
[
str(claim.reason or ""),
str(claim.location or ""),
*[str(item.item_reason or "") for item in list(claim.items or [])],
*[str(context.get("ocr_text") or "") for context in contexts],
]
)
corpus = "\n".join(corpus_parts)
hits = [keyword for keyword in keywords if keyword and keyword in corpus]
if not hits:
return None
return {
"message": self._resolve_message(
params,
fallback=f"识别到风险关键词:{''.join(hits[:5])}",
),
"evidence": {
"keyword_hits": hits[:8],
"search_fields": search_fields,
"condition_summary": params.get("condition_summary"),
},
}
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"),
"condition_results": {
"city_evidence_present": bool(attachment_values and reference_values),
"destination_overlap": has_destination_overlap,
"unexpected_route_city": bool(unexpected_route_cities),
"reasonable_exception": bool(keyword_hits),
},
"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 == "numeric_compare":
return self._evaluate_numeric_compare(condition, claim=claim, contexts=contexts)
if operator == "duplicate_value":
values = [
value
for key in fields
for value in self._resolve_values(key, claim=claim, contexts=contexts)
]
duplicates = duplicate_text_values(values)
evidence = {"operator": operator, "fields": fields, "values": values[:8], "duplicates": duplicates[:8]}
return bool(duplicates), evidence
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 _evaluate_numeric_compare(
self,
condition: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> tuple[bool, dict[str, Any]]:
left_fields = self._read_string_list(condition.get("left_fields") or condition.get("fields"))
right_fields = self._read_string_list(condition.get("right_fields"))
left_numbers = self._resolve_group_numbers(left_fields, claim=claim, contexts=contexts)
right_numbers = self._resolve_group_numbers(right_fields, claim=claim, contexts=contexts)
threshold = parse_number_value(condition.get("threshold") or condition.get("value"))
if threshold is not None:
right_numbers.append(threshold)
compare = str(condition.get("compare") or condition.get("comparator") or "gt").strip().lower()
passed = any(
compare_numbers(left, right, compare)
for left in left_numbers
for right in right_numbers
)
return passed, {
"operator": "numeric_compare",
"compare": compare,
"left_fields": left_fields,
"right_fields": right_fields,
"left_values": left_numbers[:8],
"right_values": right_numbers[:8],
}
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
def _resolve_group_numbers(
self,
field_keys: list[str],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> list[float]:
values: list[float] = []
for key in field_keys:
for value in self._resolve_values(key, claim=claim, contexts=contexts):
parsed = parse_number_value(value)
if parsed is not None 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,
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> list[str]:
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."):
attr = normalized.removeprefix("item.")
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)
if normalized.startswith("budget."):
return self._resolve_budget_values(normalized.removeprefix("budget."), 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_values = [value for value in [*reference_values, *home_values] if value]
if not allowed_values:
return []
candidates = route_values if home_values else route_values[1:-1]
unexpected: list[str] = []
for city in candidates:
if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
continue
if city not in unexpected:
unexpected.append(city)
return unexpected
@staticmethod
def _expand_route_city_values(values: list[Any]) -> list[Any]:
expanded: list[Any] = []
for value in values:
if isinstance(value, (list, tuple, set)):
expanded.extend(RiskRuleTemplateExecutor._expand_route_city_values(list(value)))
continue
text = str(value or "").strip()
if not text:
continue
parts = [part.strip() for part in ROUTE_CITY_SPLIT_PATTERN.split(text) if part.strip()]
expanded.extend(parts if len(parts) >= 2 else [text])
return expanded
def _resolve_attachment_values(
self, field_key: str, contexts: list[dict[str, Any]]
) -> list[str]:
values: list[Any] = []
for context in contexts:
document_info = context.get("document_info") if isinstance(context, dict) else {}
if not isinstance(document_info, dict):
document_info = {}
if field_key == "ocr_text":
values.extend([context.get("ocr_text"), context.get("ocr_summary")])
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._expand_route_city_values(self._scan_document_values(document_info, field_key)))
else:
values.extend(self._scan_document_values(document_info, field_key))
return self._normalize_values(values)
def _resolve_budget_values(self, field_key: str, contexts: list[dict[str, Any]]) -> list[str]:
values: list[Any] = []
for context in contexts:
if not isinstance(context, dict):
continue
budget_context = context.get("budget_context")
if not isinstance(budget_context, dict):
continue
for key in {field_key, field_key.replace("_", ""), field_key.replace("-", "_")}:
if key in budget_context:
values.append(budget_context.get(key))
return self._normalize_values(values)
def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
values: list[Any] = []
for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:
if key in document_info:
values.append(document_info.get(key))
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower()
label = str(field.get("label") or "").strip()
if self._field_matches(key, label, field_key):
values.append(field.get("value"))
return values
@staticmethod
def _field_matches(key: str, label: str, field_key: str) -> bool:
compact_key = key.replace("_", "")
compact_target = field_key.replace("_", "")
if compact_target in compact_key:
return True
label_map = {
"invoice_no": ("发票号", "发票号码", "票号"),
"buyer_name": ("购买方", "抬头", "买方"),
"goods_name": ("品名", "商品", "服务名称"),
"issue_date": ("日期", "开票日期", "发票日期"),
"stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
"stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
"route_cities": ("行程", "路线", "目的地", "出差城市"),
"city": ("城市", "地点"),
}
return any(item in label for item in label_map.get(field_key, ()))
@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 [])
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 _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":
return not left_values
if not left_values or not right_values:
return False
left_set = {value.lower() for value in left_values}
right_set = {value.lower() for value in right_values}
if operator in {"equals", "in", "overlap"}:
return RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
if operator in {"not_equals", "not_in", "not_overlap"}:
return not RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
if operator == "contains_any":
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] = []
for value in values:
if isinstance(value, (list, tuple, set)):
normalized.extend(RiskRuleTemplateExecutor._normalize_values(list(value)))
continue
text = re.sub(r"\s+", " ", str(value or "")).strip()
if text:
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):
return []
return [str(item or "").strip() for item in value if str(item or "").strip()]
@staticmethod
def _resolve_message(params: dict[str, Any], *, fallback: str) -> str:
template = str(params.get("message_template") or "").strip()
return template or fallback