fix(claim): align risk advice with expense rows
This commit is contained in:
@@ -427,10 +427,13 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=str(result.get("message") or "自然语言风险规则命中。"),
|
||||
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
|
||||
return self._with_related_item_ids(
|
||||
self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=str(result.get("message") or "自然语言风险规则命中。"),
|
||||
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
|
||||
),
|
||||
self._context_item_ids(contexts),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -517,10 +520,13 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
|
||||
if not mismatches:
|
||||
return None
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=";".join(mismatches[:3]) + ",与当前费用场景不匹配。",
|
||||
evidence={"mismatches": mismatches[:5]},
|
||||
return self._with_related_item_ids(
|
||||
self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=";".join(mismatches[:3]) + ",与当前费用场景不匹配。",
|
||||
evidence={"mismatches": mismatches[:5]},
|
||||
),
|
||||
self._context_item_ids(contexts),
|
||||
)
|
||||
|
||||
def _evaluate_location_consistency_risk(
|
||||
@@ -549,13 +555,16 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
return None
|
||||
declared_text = "、".join(declared_cities)
|
||||
evidence_text = "、".join(evidence_cities[:5])
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=(
|
||||
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
|
||||
"建议补充异地说明或更换附件。"
|
||||
return self._with_related_item_ids(
|
||||
self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=(
|
||||
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
|
||||
"建议补充异地说明或更换附件。"
|
||||
),
|
||||
evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities},
|
||||
),
|
||||
evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities},
|
||||
self._context_item_ids(contexts),
|
||||
)
|
||||
|
||||
def _evaluate_duplicate_invoice_risk(
|
||||
@@ -827,10 +836,13 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
reason_corpus = self._build_travel_reason_corpus(claim)
|
||||
if self._text_contains_keywords(reason_corpus, policy.route_exception_keywords):
|
||||
return None
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
|
||||
evidence={"cities": cities[:8]},
|
||||
return self._with_related_item_ids(
|
||||
self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
|
||||
evidence={"cities": cities[:8]},
|
||||
),
|
||||
self._context_item_ids(contexts),
|
||||
)
|
||||
|
||||
def _build_platform_risk_flag(
|
||||
@@ -847,6 +859,30 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
|
||||
item_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for context in list(contexts or []):
|
||||
item = context.get("item") if isinstance(context, dict) else None
|
||||
item_id = str(getattr(item, "id", "") or "").strip()
|
||||
if item_id and item_id not in seen:
|
||||
seen.add(item_id)
|
||||
item_ids.append(item_id)
|
||||
return item_ids
|
||||
|
||||
@staticmethod
|
||||
def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]:
|
||||
normalized_item_ids = list(
|
||||
dict.fromkeys(str(item_id or "").strip() for item_id in list(item_ids or []) if str(item_id or "").strip())
|
||||
)
|
||||
if not normalized_item_ids:
|
||||
return flag
|
||||
flag["item_ids"] = normalized_item_ids
|
||||
if len(normalized_item_ids) == 1:
|
||||
flag["item_id"] = normalized_item_ids[0]
|
||||
return flag
|
||||
|
||||
@staticmethod
|
||||
def _count_values(values: list[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
@@ -33,6 +33,10 @@ from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
build_default_expense_rule_catalog,
|
||||
)
|
||||
from app.services.travel_policy_grades import (
|
||||
resolve_travel_policy_grade_key,
|
||||
travel_policy_grade_key_candidates,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimPolicyReviewMixin:
|
||||
@@ -234,13 +238,16 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
f"下一段却从 {current_origin} 出发,请补充中转或改签说明。"
|
||||
)
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "行程闭环异常",
|
||||
"message": message,
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "行程闭环异常",
|
||||
"message": message,
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
self._itinerary_segment_item_ids([previous, current]),
|
||||
)
|
||||
)
|
||||
blocking_reasons.append("差旅行程未形成连续闭环,请补充中转、改签或异地出发原因。")
|
||||
break
|
||||
@@ -255,13 +262,16 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
f"与申报目的地 {expected_destination_city} 不一致,请补充多地出差或后续行程说明。"
|
||||
)
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "行程终点异常",
|
||||
"message": message,
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "行程终点异常",
|
||||
"message": message,
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
self._itinerary_segment_item_ids(itinerary_segments),
|
||||
)
|
||||
)
|
||||
blocking_reasons.append("差旅行程终点与申报目的地不一致,请补充多地出差说明或补齐后续票据。")
|
||||
|
||||
@@ -277,17 +287,25 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
]
|
||||
if extra_destinations and not has_route_exception:
|
||||
destinations_text = "、".join(extra_destinations[:3])
|
||||
affected_segments = [
|
||||
segment
|
||||
for segment in itinerary_segments
|
||||
if segment["origin"] in extra_destinations or segment["destination"] in extra_destinations
|
||||
]
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "多城市行程待说明",
|
||||
"message": (
|
||||
f"检测到本次差旅涉及 {destinations_text} 多个目的地,"
|
||||
"但当前报销事由未说明中转、多地拜访或改签原因。"
|
||||
),
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "多城市行程待说明",
|
||||
"message": (
|
||||
f"检测到本次差旅涉及 {destinations_text} 多个目的地,"
|
||||
"但当前报销事由未说明中转、多地拜访或改签原因。"
|
||||
),
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
self._itinerary_segment_item_ids(affected_segments or itinerary_segments),
|
||||
)
|
||||
)
|
||||
blocking_reasons.append("检测到多城市差旅行程,但当前未补充中转或多地出差说明。")
|
||||
|
||||
@@ -301,16 +319,19 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
if hotel_city and allowed_hotel_cities and hotel_city not in allowed_hotel_cities:
|
||||
expected_text = "、".join(sorted(allowed_hotel_cities))
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "酒店地点异常",
|
||||
"message": (
|
||||
f"酒店票据识别城市为 {hotel_city},"
|
||||
f"与当前差旅目的地/行程城市 {expected_text} 不一致,请补充异地住宿原因。"
|
||||
),
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "酒店地点异常",
|
||||
"message": (
|
||||
f"酒店票据识别城市为 {hotel_city},"
|
||||
f"与当前差旅目的地/行程城市 {expected_text} 不一致,请补充异地住宿原因。"
|
||||
),
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
[self._context_item_id(context)],
|
||||
)
|
||||
)
|
||||
blocking_reasons.append("酒店票据地点与差旅目的地不一致,请补充异地住宿原因或更换附件。")
|
||||
|
||||
@@ -346,23 +367,29 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
|
||||
if has_standard_exception or item_has_exception:
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "住宿超标提醒",
|
||||
"message": hotel_message + " 已识别到补充说明,请直属领导重点复核。",
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "住宿超标提醒",
|
||||
"message": hotel_message + " 已识别到补充说明,请直属领导重点复核。",
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
[self._context_item_id(context)],
|
||||
)
|
||||
)
|
||||
else:
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "住宿超标待说明",
|
||||
"message": hotel_message + " 当前未识别到超标说明,请先补充原因。",
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "住宿超标待说明",
|
||||
"message": hotel_message + " 当前未识别到超标说明,请先补充原因。",
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
[self._context_item_id(context)],
|
||||
)
|
||||
)
|
||||
blocking_reasons.append("住宿金额超出当前职级差标,且未补充超标说明。")
|
||||
|
||||
@@ -373,7 +400,11 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
continue
|
||||
|
||||
transport_kind, class_label, class_level = transport_class
|
||||
allowed_level = policy.transport_limits.get(grade_band, {}).get(transport_kind)
|
||||
allowed_level = self._resolve_travel_policy_transport_level(
|
||||
policy,
|
||||
grade_band=grade_band,
|
||||
transport_kind=transport_kind,
|
||||
)
|
||||
if allowed_level is None or class_level <= allowed_level:
|
||||
continue
|
||||
|
||||
@@ -387,23 +418,29 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
message = f"{band_label} 职级当前默认不可报销 {class_label}。"
|
||||
if has_standard_exception or item_has_exception:
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "交通舱位超标提醒",
|
||||
"message": message + " 已识别到补充说明,请审批人重点复核。",
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "交通舱位超标提醒",
|
||||
"message": message + " 已识别到补充说明,请审批人重点复核。",
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
[self._context_item_id(context)],
|
||||
)
|
||||
)
|
||||
else:
|
||||
flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "交通舱位超标待说明",
|
||||
"message": message + " 当前未识别到例外说明,请先补充原因。",
|
||||
"rule_code": policy.rule_code,
|
||||
}
|
||||
self._with_related_item_ids(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "交通舱位超标待说明",
|
||||
"message": message + " 当前未识别到例外说明,请先补充原因。",
|
||||
"rule_code": policy.rule_code,
|
||||
},
|
||||
[self._context_item_id(context)],
|
||||
)
|
||||
)
|
||||
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
|
||||
|
||||
@@ -439,6 +476,35 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
)
|
||||
return contexts
|
||||
|
||||
@staticmethod
|
||||
def _context_item_id(context: dict[str, Any]) -> str:
|
||||
item = context.get("item") if isinstance(context, dict) else None
|
||||
return str(getattr(item, "id", "") or "").strip()
|
||||
|
||||
@classmethod
|
||||
def _itinerary_segment_item_ids(cls, segments: list[dict[str, Any]]) -> list[str]:
|
||||
item_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for segment in list(segments or []):
|
||||
item = segment.get("item") if isinstance(segment, dict) else None
|
||||
item_id = str(getattr(item, "id", "") or "").strip()
|
||||
if item_id and item_id not in seen:
|
||||
seen.add(item_id)
|
||||
item_ids.append(item_id)
|
||||
return item_ids
|
||||
|
||||
@staticmethod
|
||||
def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]:
|
||||
normalized_item_ids = list(
|
||||
dict.fromkeys(str(item_id or "").strip() for item_id in list(item_ids or []) if str(item_id or "").strip())
|
||||
)
|
||||
if not normalized_item_ids:
|
||||
return flag
|
||||
flag["item_ids"] = normalized_item_ids
|
||||
if len(normalized_item_ids) == 1:
|
||||
flag["item_id"] = normalized_item_ids[0]
|
||||
return flag
|
||||
|
||||
def _is_travel_policy_relevant_context(
|
||||
self,
|
||||
context: dict[str, Any],
|
||||
@@ -483,28 +549,21 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
|
||||
@staticmethod
|
||||
def _resolve_travel_policy_band(grade: str | None) -> str | None:
|
||||
normalized = str(grade or "").strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
return resolve_travel_policy_grade_key(grade)
|
||||
|
||||
p_match = re.search(r"P(\d+)", normalized)
|
||||
if p_match:
|
||||
level = int(p_match.group(1))
|
||||
if level <= 3:
|
||||
return "junior"
|
||||
if level <= 5:
|
||||
return "mid"
|
||||
return "senior"
|
||||
|
||||
m_match = re.search(r"M(\d+)", normalized)
|
||||
if m_match:
|
||||
level = int(m_match.group(1))
|
||||
if level <= 2:
|
||||
return "manager"
|
||||
return "executive"
|
||||
|
||||
if normalized.startswith("D"):
|
||||
return "executive"
|
||||
@staticmethod
|
||||
def _resolve_travel_policy_transport_level(
|
||||
policy: RuntimeTravelPolicy,
|
||||
*,
|
||||
grade_band: str,
|
||||
transport_kind: str,
|
||||
) -> int | None:
|
||||
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||
allowed_level = (getattr(policy, "transport_limits", {}) or {}).get(
|
||||
candidate, {}
|
||||
).get(transport_kind)
|
||||
if allowed_level is not None:
|
||||
return allowed_level
|
||||
return None
|
||||
|
||||
def _resolve_expected_travel_city(
|
||||
@@ -580,18 +639,21 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
normalized_city = str(city or "").strip()
|
||||
city_limits = getattr(policy, "hotel_city_limits", {}) or {}
|
||||
city_entry = city_limits.get(normalized_city) if normalized_city else None
|
||||
if city_entry and city_entry.get(grade_band) is not None:
|
||||
cap = Decimal(city_entry[grade_band]).quantize(Decimal("0.01"))
|
||||
return cap, normalized_city
|
||||
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||
if city_entry and city_entry.get(candidate) is not None:
|
||||
cap = Decimal(city_entry[candidate]).quantize(Decimal("0.01"))
|
||||
return cap, normalized_city
|
||||
|
||||
city_tier = (getattr(policy, "city_tiers", {}) or {}).get(normalized_city, "tier_3")
|
||||
tier_entry = (getattr(policy, "hotel_limits", {}) or {}).get(grade_band, {})
|
||||
tier_cap = tier_entry.get(city_tier)
|
||||
if tier_cap is None:
|
||||
return None
|
||||
tier_label = self._format_travel_policy_city_tier(city_tier)
|
||||
cap = Decimal(tier_cap).quantize(Decimal("0.01"))
|
||||
return cap, tier_label
|
||||
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||
tier_entry = (getattr(policy, "hotel_limits", {}) or {}).get(candidate, {})
|
||||
tier_cap = tier_entry.get(city_tier)
|
||||
if tier_cap is None:
|
||||
continue
|
||||
tier_label = self._format_travel_policy_city_tier(city_tier)
|
||||
cap = Decimal(tier_cap).quantize(Decimal("0.01"))
|
||||
return cap, tier_label
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
|
||||
|
||||
@@ -28,12 +28,6 @@ class ExpenseClaimRiskReviewMixin(
|
||||
):
|
||||
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
base_flags = list(claim.risk_flags_json or [])
|
||||
attachment_flags = [
|
||||
flag
|
||||
for flag in base_flags
|
||||
if isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
]
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in base_flags
|
||||
@@ -44,47 +38,9 @@ class ExpenseClaimRiskReviewMixin(
|
||||
]
|
||||
|
||||
review_flags: list[dict[str, Any]] = []
|
||||
attention_reasons: list[str] = []
|
||||
|
||||
high_attachment_flags = [
|
||||
flag
|
||||
for flag in attachment_flags
|
||||
if str(flag.get("severity") or "").strip().lower() == "high"
|
||||
]
|
||||
medium_attachment_flags = [
|
||||
flag
|
||||
for flag in attachment_flags
|
||||
if str(flag.get("severity") or "").strip().lower() == "medium"
|
||||
]
|
||||
if high_attachment_flags:
|
||||
attention_reasons.append("存在高风险票据,需审批人重点复核。")
|
||||
review_flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "自动检测重点复核",
|
||||
"message": (
|
||||
f"自动检测发现 {len(high_attachment_flags)} 条高风险附件,"
|
||||
"已随单流转给审批人重点复核。"
|
||||
),
|
||||
}
|
||||
)
|
||||
elif medium_attachment_flags:
|
||||
review_flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "自动检测提醒",
|
||||
"message": (
|
||||
f"自动检测发现 {len(medium_attachment_flags)} 条中风险附件,"
|
||||
"已随单流转给审批人复核。"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
manager_name = self._resolve_claim_manager_name(claim)
|
||||
if not manager_name:
|
||||
attention_reasons.append("未识别到该员工的直属领导,需审批环节补充分配。")
|
||||
review_flags.append(
|
||||
{
|
||||
"source": "submission_review",
|
||||
@@ -123,18 +79,15 @@ class ExpenseClaimRiskReviewMixin(
|
||||
)
|
||||
|
||||
travel_review = self._run_travel_policy_review(claim)
|
||||
attention_reasons.extend(travel_review["blocking_reasons"])
|
||||
review_flags.extend(travel_review["flags"])
|
||||
|
||||
scene_policy_review = self._run_scene_policy_review(claim)
|
||||
attention_reasons.extend(scene_policy_review["blocking_reasons"])
|
||||
review_flags.extend(scene_policy_review["flags"])
|
||||
|
||||
platform_risk_review = self.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
attention_reasons.extend(platform_risk_review["blocking_reasons"])
|
||||
platform_risk_flags = list(platform_risk_review["flags"])
|
||||
review_flags.extend(platform_risk_flags)
|
||||
if platform_risk_flags:
|
||||
@@ -149,20 +102,6 @@ class ExpenseClaimRiskReviewMixin(
|
||||
claim.id,
|
||||
)
|
||||
|
||||
if attention_reasons:
|
||||
summary_message = "自动检测发现需审批重点关注事项:" + ";".join(
|
||||
dict.fromkeys(attention_reasons)
|
||||
)
|
||||
review_flags.insert(
|
||||
0,
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "自动检测重点复核",
|
||||
"message": summary_message,
|
||||
},
|
||||
)
|
||||
|
||||
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user