- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
970 lines
40 KiB
Python
970 lines
40 KiB
Python
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
|