2026-05-23 19:54:42 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
2026-05-26 09:15:14 +08:00
|
|
|
from datetime import date, datetime, timedelta
|
2026-05-23 19:54:42 +08:00
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from app.models.financial_record import ExpenseClaim
|
2026-05-26 09:15:14 +08:00
|
|
|
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
|
|
|
|
|
|
|
|
|
CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
|
|
|
|
"travel_city_consistency",
|
|
|
|
|
"travel_route_city_consistency",
|
|
|
|
|
}
|
2026-05-23 19:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class RiskRuleTemplateExecutor:
|
|
|
|
|
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":
|
2026-05-26 09:15:14 +08:00
|
|
|
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
|
|
|
|
return self._evaluate_city_consistency_rule(
|
|
|
|
|
params,
|
|
|
|
|
claim=claim,
|
|
|
|
|
contexts=contexts,
|
|
|
|
|
)
|
2026-05-23 19:54:42 +08:00
|
|
|
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)
|
2026-05-26 09:15:14 +08:00
|
|
|
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
|
|
|
|
|
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
|
2026-05-23 19:54:42 +08:00
|
|
|
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._has_resolved_value(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 condition in conditions:
|
|
|
|
|
if not isinstance(condition, dict):
|
|
|
|
|
continue
|
|
|
|
|
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,
|
|
|
|
|
"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:
|
2026-05-26 09:15:14 +08:00
|
|
|
if self._looks_like_city_consistency_rule(params):
|
|
|
|
|
return self._evaluate_city_consistency_rule(
|
|
|
|
|
params,
|
|
|
|
|
claim=claim,
|
|
|
|
|
contexts=contexts,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
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"),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
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"),
|
|
|
|
|
"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 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 _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
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
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 []
|
2026-05-26 09:15:14 +08:00
|
|
|
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)])
|
2026-05-23 19:54:42 +08:00
|
|
|
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 [])]
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
if normalized.startswith("employee."):
|
|
|
|
|
employee = getattr(claim, "employee", None)
|
|
|
|
|
if employee is None:
|
|
|
|
|
return []
|
|
|
|
|
return self._normalize_values(
|
|
|
|
|
[getattr(employee, normalized.removeprefix("employee."), "")]
|
|
|
|
|
)
|
2026-05-23 19:54:42 +08:00
|
|
|
if normalized.startswith("attachment."):
|
|
|
|
|
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
|
|
|
|
|
return []
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
@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 = {value.lower() for value in [*reference_values, *home_values] if value}
|
|
|
|
|
if not allowed:
|
|
|
|
|
return []
|
|
|
|
|
candidates = route_values if home_values else route_values[1:-1]
|
|
|
|
|
unexpected: list[str] = []
|
|
|
|
|
for city in candidates:
|
|
|
|
|
normalized = city.lower()
|
|
|
|
|
if normalized in allowed:
|
|
|
|
|
continue
|
|
|
|
|
if city not in unexpected:
|
|
|
|
|
unexpected.append(city)
|
|
|
|
|
return unexpected
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
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")])
|
2026-05-26 09:15:14 +08:00
|
|
|
if field_key == "hotel_city":
|
2026-05-24 21:44:17 +08:00
|
|
|
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")
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
elif field_key == "route_cities":
|
|
|
|
|
values.extend(self._scan_document_values(document_info, field_key))
|
2026-05-23 19:54:42 +08:00
|
|
|
else:
|
|
|
|
|
values.extend(self._scan_document_values(document_info, field_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": ("日期", "开票日期", "发票日期"),
|
2026-05-26 09:15:14 +08:00
|
|
|
"stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
|
|
|
|
|
"stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
|
2026-05-24 21:44:17 +08:00
|
|
|
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
|
|
|
|
|
"route_cities": ("行程", "路线", "目的地", "出差城市"),
|
2026-05-23 19:54:42 +08:00
|
|
|
"city": ("城市", "地点"),
|
|
|
|
|
}
|
|
|
|
|
return any(item in label for item in label_map.get(field_key, ()))
|
|
|
|
|
|
|
|
|
|
def _has_resolved_value(
|
|
|
|
|
self,
|
|
|
|
|
field_key: str,
|
|
|
|
|
*,
|
|
|
|
|
claim: ExpenseClaim,
|
|
|
|
|
contexts: list[dict[str, Any]],
|
|
|
|
|
) -> bool:
|
|
|
|
|
return bool(self._resolve_values(field_key, claim=claim, contexts=contexts))
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
|
|
|
|
|
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)
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
@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 bool(left_set & right_set)
|
|
|
|
|
if operator in {"not_equals", "not_in", "not_overlap"}:
|
|
|
|
|
return not bool(left_set & right_set)
|
|
|
|
|
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)
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
@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 and text not in normalized:
|
|
|
|
|
normalized.append(text)
|
|
|
|
|
return normalized
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-23 19:54:42 +08:00
|
|
|
@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
|