fix(claim): align risk advice with expense rows
This commit is contained in:
@@ -427,10 +427,13 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
)
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
return self._build_platform_risk_flag(
|
return self._with_related_item_ids(
|
||||||
|
self._build_platform_risk_flag(
|
||||||
manifest,
|
manifest,
|
||||||
message=str(result.get("message") or "自然语言风险规则命中。"),
|
message=str(result.get("message") or "自然语言风险规则命中。"),
|
||||||
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
|
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
|
||||||
|
),
|
||||||
|
self._context_item_ids(contexts),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -517,10 +520,13 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
|
|
||||||
if not mismatches:
|
if not mismatches:
|
||||||
return None
|
return None
|
||||||
return self._build_platform_risk_flag(
|
return self._with_related_item_ids(
|
||||||
|
self._build_platform_risk_flag(
|
||||||
manifest,
|
manifest,
|
||||||
message=";".join(mismatches[:3]) + ",与当前费用场景不匹配。",
|
message=";".join(mismatches[:3]) + ",与当前费用场景不匹配。",
|
||||||
evidence={"mismatches": mismatches[:5]},
|
evidence={"mismatches": mismatches[:5]},
|
||||||
|
),
|
||||||
|
self._context_item_ids(contexts),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _evaluate_location_consistency_risk(
|
def _evaluate_location_consistency_risk(
|
||||||
@@ -549,13 +555,16 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
return None
|
return None
|
||||||
declared_text = "、".join(declared_cities)
|
declared_text = "、".join(declared_cities)
|
||||||
evidence_text = "、".join(evidence_cities[:5])
|
evidence_text = "、".join(evidence_cities[:5])
|
||||||
return self._build_platform_risk_flag(
|
return self._with_related_item_ids(
|
||||||
|
self._build_platform_risk_flag(
|
||||||
manifest,
|
manifest,
|
||||||
message=(
|
message=(
|
||||||
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
|
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(
|
def _evaluate_duplicate_invoice_risk(
|
||||||
@@ -827,10 +836,13 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
reason_corpus = self._build_travel_reason_corpus(claim)
|
reason_corpus = self._build_travel_reason_corpus(claim)
|
||||||
if self._text_contains_keywords(reason_corpus, policy.route_exception_keywords):
|
if self._text_contains_keywords(reason_corpus, policy.route_exception_keywords):
|
||||||
return None
|
return None
|
||||||
return self._build_platform_risk_flag(
|
return self._with_related_item_ids(
|
||||||
|
self._build_platform_risk_flag(
|
||||||
manifest,
|
manifest,
|
||||||
message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
|
message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
|
||||||
evidence={"cities": cities[:8]},
|
evidence={"cities": cities[:8]},
|
||||||
|
),
|
||||||
|
self._context_item_ids(contexts),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_platform_risk_flag(
|
def _build_platform_risk_flag(
|
||||||
@@ -847,6 +859,30 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
|
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
|
@staticmethod
|
||||||
def _count_values(values: list[str]) -> dict[str, int]:
|
def _count_values(values: list[str]) -> dict[str, int]:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ from app.services.expense_rule_runtime import (
|
|||||||
RuntimeTravelPolicy,
|
RuntimeTravelPolicy,
|
||||||
build_default_expense_rule_catalog,
|
build_default_expense_rule_catalog,
|
||||||
)
|
)
|
||||||
|
from app.services.travel_policy_grades import (
|
||||||
|
resolve_travel_policy_grade_key,
|
||||||
|
travel_policy_grade_key_candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimPolicyReviewMixin:
|
class ExpenseClaimPolicyReviewMixin:
|
||||||
@@ -234,13 +238,16 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
f"下一段却从 {current_origin} 出发,请补充中转或改签说明。"
|
f"下一段却从 {current_origin} 出发,请补充中转或改签说明。"
|
||||||
)
|
)
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"label": "行程闭环异常",
|
"label": "行程闭环异常",
|
||||||
"message": message,
|
"message": message,
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
self._itinerary_segment_item_ids([previous, current]),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
blocking_reasons.append("差旅行程未形成连续闭环,请补充中转、改签或异地出发原因。")
|
blocking_reasons.append("差旅行程未形成连续闭环,请补充中转、改签或异地出发原因。")
|
||||||
break
|
break
|
||||||
@@ -255,13 +262,16 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
f"与申报目的地 {expected_destination_city} 不一致,请补充多地出差或后续行程说明。"
|
f"与申报目的地 {expected_destination_city} 不一致,请补充多地出差或后续行程说明。"
|
||||||
)
|
)
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"label": "行程终点异常",
|
"label": "行程终点异常",
|
||||||
"message": message,
|
"message": message,
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
self._itinerary_segment_item_ids(itinerary_segments),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
blocking_reasons.append("差旅行程终点与申报目的地不一致,请补充多地出差说明或补齐后续票据。")
|
blocking_reasons.append("差旅行程终点与申报目的地不一致,请补充多地出差说明或补齐后续票据。")
|
||||||
|
|
||||||
@@ -277,7 +287,13 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
]
|
]
|
||||||
if extra_destinations and not has_route_exception:
|
if extra_destinations and not has_route_exception:
|
||||||
destinations_text = "、".join(extra_destinations[:3])
|
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(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
@@ -287,7 +303,9 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
"但当前报销事由未说明中转、多地拜访或改签原因。"
|
"但当前报销事由未说明中转、多地拜访或改签原因。"
|
||||||
),
|
),
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
self._itinerary_segment_item_ids(affected_segments or itinerary_segments),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
blocking_reasons.append("检测到多城市差旅行程,但当前未补充中转或多地出差说明。")
|
blocking_reasons.append("检测到多城市差旅行程,但当前未补充中转或多地出差说明。")
|
||||||
|
|
||||||
@@ -301,6 +319,7 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
if hotel_city and allowed_hotel_cities and hotel_city not in allowed_hotel_cities:
|
if hotel_city and allowed_hotel_cities and hotel_city not in allowed_hotel_cities:
|
||||||
expected_text = "、".join(sorted(allowed_hotel_cities))
|
expected_text = "、".join(sorted(allowed_hotel_cities))
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
@@ -310,7 +329,9 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
f"与当前差旅目的地/行程城市 {expected_text} 不一致,请补充异地住宿原因。"
|
f"与当前差旅目的地/行程城市 {expected_text} 不一致,请补充异地住宿原因。"
|
||||||
),
|
),
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
[self._context_item_id(context)],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
blocking_reasons.append("酒店票据地点与差旅目的地不一致,请补充异地住宿原因或更换附件。")
|
blocking_reasons.append("酒店票据地点与差旅目的地不一致,请补充异地住宿原因或更换附件。")
|
||||||
|
|
||||||
@@ -346,23 +367,29 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
|
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
|
||||||
if has_standard_exception or item_has_exception:
|
if has_standard_exception or item_has_exception:
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "medium",
|
"severity": "medium",
|
||||||
"label": "住宿超标提醒",
|
"label": "住宿超标提醒",
|
||||||
"message": hotel_message + " 已识别到补充说明,请直属领导重点复核。",
|
"message": hotel_message + " 已识别到补充说明,请直属领导重点复核。",
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
[self._context_item_id(context)],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"label": "住宿超标待说明",
|
"label": "住宿超标待说明",
|
||||||
"message": hotel_message + " 当前未识别到超标说明,请先补充原因。",
|
"message": hotel_message + " 当前未识别到超标说明,请先补充原因。",
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
[self._context_item_id(context)],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
blocking_reasons.append("住宿金额超出当前职级差标,且未补充超标说明。")
|
blocking_reasons.append("住宿金额超出当前职级差标,且未补充超标说明。")
|
||||||
|
|
||||||
@@ -373,7 +400,11 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
transport_kind, class_label, class_level = transport_class
|
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:
|
if allowed_level is None or class_level <= allowed_level:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -387,23 +418,29 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
message = f"{band_label} 职级当前默认不可报销 {class_label}。"
|
message = f"{band_label} 职级当前默认不可报销 {class_label}。"
|
||||||
if has_standard_exception or item_has_exception:
|
if has_standard_exception or item_has_exception:
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "medium",
|
"severity": "medium",
|
||||||
"label": "交通舱位超标提醒",
|
"label": "交通舱位超标提醒",
|
||||||
"message": message + " 已识别到补充说明,请审批人重点复核。",
|
"message": message + " 已识别到补充说明,请审批人重点复核。",
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
[self._context_item_id(context)],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
flags.append(
|
flags.append(
|
||||||
|
self._with_related_item_ids(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"label": "交通舱位超标待说明",
|
"label": "交通舱位超标待说明",
|
||||||
"message": message + " 当前未识别到例外说明,请先补充原因。",
|
"message": message + " 当前未识别到例外说明,请先补充原因。",
|
||||||
"rule_code": policy.rule_code,
|
"rule_code": policy.rule_code,
|
||||||
}
|
},
|
||||||
|
[self._context_item_id(context)],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
|
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
|
||||||
|
|
||||||
@@ -439,6 +476,35 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
)
|
)
|
||||||
return contexts
|
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(
|
def _is_travel_policy_relevant_context(
|
||||||
self,
|
self,
|
||||||
context: dict[str, Any],
|
context: dict[str, Any],
|
||||||
@@ -483,28 +549,21 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_travel_policy_band(grade: str | None) -> str | None:
|
def _resolve_travel_policy_band(grade: str | None) -> str | None:
|
||||||
normalized = str(grade or "").strip().upper()
|
return resolve_travel_policy_grade_key(grade)
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
p_match = re.search(r"P(\d+)", normalized)
|
@staticmethod
|
||||||
if p_match:
|
def _resolve_travel_policy_transport_level(
|
||||||
level = int(p_match.group(1))
|
policy: RuntimeTravelPolicy,
|
||||||
if level <= 3:
|
*,
|
||||||
return "junior"
|
grade_band: str,
|
||||||
if level <= 5:
|
transport_kind: str,
|
||||||
return "mid"
|
) -> int | None:
|
||||||
return "senior"
|
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||||
|
allowed_level = (getattr(policy, "transport_limits", {}) or {}).get(
|
||||||
m_match = re.search(r"M(\d+)", normalized)
|
candidate, {}
|
||||||
if m_match:
|
).get(transport_kind)
|
||||||
level = int(m_match.group(1))
|
if allowed_level is not None:
|
||||||
if level <= 2:
|
return allowed_level
|
||||||
return "manager"
|
|
||||||
return "executive"
|
|
||||||
|
|
||||||
if normalized.startswith("D"):
|
|
||||||
return "executive"
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_expected_travel_city(
|
def _resolve_expected_travel_city(
|
||||||
@@ -580,18 +639,21 @@ class ExpenseClaimPolicyReviewMixin:
|
|||||||
normalized_city = str(city or "").strip()
|
normalized_city = str(city or "").strip()
|
||||||
city_limits = getattr(policy, "hotel_city_limits", {}) or {}
|
city_limits = getattr(policy, "hotel_city_limits", {}) or {}
|
||||||
city_entry = city_limits.get(normalized_city) if normalized_city else None
|
city_entry = city_limits.get(normalized_city) if normalized_city else None
|
||||||
if city_entry and city_entry.get(grade_band) is not None:
|
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||||
cap = Decimal(city_entry[grade_band]).quantize(Decimal("0.01"))
|
if city_entry and city_entry.get(candidate) is not None:
|
||||||
|
cap = Decimal(city_entry[candidate]).quantize(Decimal("0.01"))
|
||||||
return cap, normalized_city
|
return cap, normalized_city
|
||||||
|
|
||||||
city_tier = (getattr(policy, "city_tiers", {}) or {}).get(normalized_city, "tier_3")
|
city_tier = (getattr(policy, "city_tiers", {}) or {}).get(normalized_city, "tier_3")
|
||||||
tier_entry = (getattr(policy, "hotel_limits", {}) or {}).get(grade_band, {})
|
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)
|
tier_cap = tier_entry.get(city_tier)
|
||||||
if tier_cap is None:
|
if tier_cap is None:
|
||||||
return None
|
continue
|
||||||
tier_label = self._format_travel_policy_city_tier(city_tier)
|
tier_label = self._format_travel_policy_city_tier(city_tier)
|
||||||
cap = Decimal(tier_cap).quantize(Decimal("0.01"))
|
cap = Decimal(tier_cap).quantize(Decimal("0.01"))
|
||||||
return cap, tier_label
|
return cap, tier_label
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
|
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]:
|
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||||
base_flags = list(claim.risk_flags_json or [])
|
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 = [
|
preserved_flags = [
|
||||||
flag
|
flag
|
||||||
for flag in base_flags
|
for flag in base_flags
|
||||||
@@ -44,47 +38,9 @@ class ExpenseClaimRiskReviewMixin(
|
|||||||
]
|
]
|
||||||
|
|
||||||
review_flags: list[dict[str, Any]] = []
|
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)
|
manager_name = self._resolve_claim_manager_name(claim)
|
||||||
if not manager_name:
|
if not manager_name:
|
||||||
attention_reasons.append("未识别到该员工的直属领导,需审批环节补充分配。")
|
|
||||||
review_flags.append(
|
review_flags.append(
|
||||||
{
|
{
|
||||||
"source": "submission_review",
|
"source": "submission_review",
|
||||||
@@ -123,18 +79,15 @@ class ExpenseClaimRiskReviewMixin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
travel_review = self._run_travel_policy_review(claim)
|
travel_review = self._run_travel_policy_review(claim)
|
||||||
attention_reasons.extend(travel_review["blocking_reasons"])
|
|
||||||
review_flags.extend(travel_review["flags"])
|
review_flags.extend(travel_review["flags"])
|
||||||
|
|
||||||
scene_policy_review = self._run_scene_policy_review(claim)
|
scene_policy_review = self._run_scene_policy_review(claim)
|
||||||
attention_reasons.extend(scene_policy_review["blocking_reasons"])
|
|
||||||
review_flags.extend(scene_policy_review["flags"])
|
review_flags.extend(scene_policy_review["flags"])
|
||||||
|
|
||||||
platform_risk_review = self.evaluate_platform_risk_rules(
|
platform_risk_review = self.evaluate_platform_risk_rules(
|
||||||
claim,
|
claim,
|
||||||
business_stage="reimbursement",
|
business_stage="reimbursement",
|
||||||
)
|
)
|
||||||
attention_reasons.extend(platform_risk_review["blocking_reasons"])
|
|
||||||
platform_risk_flags = list(platform_risk_review["flags"])
|
platform_risk_flags = list(platform_risk_review["flags"])
|
||||||
review_flags.extend(platform_risk_flags)
|
review_flags.extend(platform_risk_flags)
|
||||||
if platform_risk_flags:
|
if platform_risk_flags:
|
||||||
@@ -149,20 +102,6 @@ class ExpenseClaimRiskReviewMixin(
|
|||||||
claim.id,
|
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]
|
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2283,6 +2283,13 @@ def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fi
|
|||||||
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
||||||
for flag in flags
|
for flag in flags
|
||||||
)
|
)
|
||||||
|
city_flag = next(
|
||||||
|
flag
|
||||||
|
for flag in flags
|
||||||
|
if isinstance(flag, dict)
|
||||||
|
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
||||||
|
)
|
||||||
|
assert city_flag.get("item_ids") == [claim.items[0].id]
|
||||||
|
|
||||||
|
|
||||||
def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
|
def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
|
||||||
@@ -2545,6 +2552,104 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="emp-hotel-summary@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_recognize(
|
||||||
|
self,
|
||||||
|
files: list[tuple[str, bytes, str | None]],
|
||||||
|
) -> OcrRecognizeBatchRead:
|
||||||
|
return OcrRecognizeBatchRead(
|
||||||
|
total_file_count=1,
|
||||||
|
success_count=1,
|
||||||
|
documents=[
|
||||||
|
OcrRecognizeDocumentRead(
|
||||||
|
filename="hotel-summary-risk.png",
|
||||||
|
media_type="image/png",
|
||||||
|
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
|
||||||
|
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
|
||||||
|
avg_score=0.98,
|
||||||
|
line_count=1,
|
||||||
|
page_count=1,
|
||||||
|
document_type="hotel_invoice",
|
||||||
|
document_type_label="酒店住宿票据",
|
||||||
|
scene_code="hotel",
|
||||||
|
scene_label="住宿票据",
|
||||||
|
document_fields=[
|
||||||
|
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
|
||||||
|
{"key": "amount", "label": "金额", "value": "800元"},
|
||||||
|
{"key": "date", "label": "日期", "value": "2026-05-13"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||||
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E7410",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-hotel-summary@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E7411",
|
||||||
|
name="张三",
|
||||||
|
email="emp-hotel-summary@example.com",
|
||||||
|
grade="P4",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
claim = build_claim(expense_type="travel", location="北京")
|
||||||
|
claim.employee = employee
|
||||||
|
claim.employee_id = employee.id
|
||||||
|
claim.reason = "北京客户现场出差"
|
||||||
|
claim.amount = Decimal("0.00")
|
||||||
|
claim.invoice_count = 0
|
||||||
|
claim.items[0].item_type = "hotel"
|
||||||
|
claim.items[0].item_reason = "北京住宿"
|
||||||
|
claim.items[0].item_location = "北京"
|
||||||
|
claim.items[0].item_amount = Decimal("0.00")
|
||||||
|
claim.items[0].invoice_id = None
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ExpenseClaimService(db).upload_claim_item_attachment(
|
||||||
|
claim_id=claim.id,
|
||||||
|
item_id=claim.items[0].id,
|
||||||
|
filename="hotel-summary-risk.png",
|
||||||
|
content=b"fake-image-bytes",
|
||||||
|
media_type="image/png",
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.refresh(claim)
|
||||||
|
labels = [
|
||||||
|
str(flag.get("label") or "").strip()
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
if isinstance(flag, dict)
|
||||||
|
]
|
||||||
|
assert "自动检测重点复核" not in labels
|
||||||
|
assert "自动检测提醒" not in labels
|
||||||
|
assert any(
|
||||||
|
isinstance(flag, dict)
|
||||||
|
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||||
|
and str(flag.get("severity") or "").strip() == "high"
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
|
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-hotel-risk@example.com",
|
username="emp-hotel-risk@example.com",
|
||||||
@@ -3533,6 +3638,16 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
|||||||
)
|
)
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
for flag in list(submitted.risk_flags_json or [])
|
||||||
)
|
)
|
||||||
|
route_flags = [
|
||||||
|
flag
|
||||||
|
for flag in list(submitted.risk_flags_json or [])
|
||||||
|
if isinstance(flag, dict)
|
||||||
|
and str(flag.get("source") or "").strip() == "submission_review"
|
||||||
|
and str(flag.get("label") or "").strip() in {"行程终点异常", "多城市行程待说明"}
|
||||||
|
]
|
||||||
|
assert route_flags
|
||||||
|
assert all(flag.get("item_ids") for flag in route_flags)
|
||||||
|
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
||||||
|
|||||||
@@ -1657,7 +1657,15 @@ export default {
|
|||||||
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
||||||
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||||
|
|
||||||
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|
return actionableCards.find((card) => {
|
||||||
|
const cardItemIds = [
|
||||||
|
card?.itemId,
|
||||||
|
card?.item_id,
|
||||||
|
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
|
||||||
|
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
|
||||||
|
].map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
return cardItemIds.includes(itemId)
|
||||||
|
})
|
||||||
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
||||||
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
||||||
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
||||||
|
|||||||
@@ -181,6 +181,56 @@ function normalizeId(value) {
|
|||||||
return normalizeText(value)
|
return normalizeText(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIdList(value) {
|
||||||
|
const rawValues = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: normalizeText(value)
|
||||||
|
? [value]
|
||||||
|
: []
|
||||||
|
return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpenseItemIndexMap(expenseItems = []) {
|
||||||
|
const itemIndexById = new Map()
|
||||||
|
;(Array.isArray(expenseItems) ? expenseItems : []).forEach((item, index) => {
|
||||||
|
const itemId = normalizeId(item?.id)
|
||||||
|
if (itemId && !itemIndexById.has(itemId)) {
|
||||||
|
itemIndexById.set(itemId, index + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return itemIndexById
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRiskItemNumbers({ itemId = '', itemIds = [], itemIndex = null } = {}, expenseItems = []) {
|
||||||
|
const itemIndexById = buildExpenseItemIndexMap(expenseItems)
|
||||||
|
const itemNumbers = []
|
||||||
|
const explicitItemIndex = Number(itemIndex)
|
||||||
|
if (Number.isFinite(explicitItemIndex) && explicitItemIndex > 0) {
|
||||||
|
itemNumbers.push(Math.floor(explicitItemIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedItemIds = uniqueTexts([
|
||||||
|
normalizeId(itemId),
|
||||||
|
...normalizeIdList(itemIds)
|
||||||
|
])
|
||||||
|
relatedItemIds.forEach((relatedItemId) => {
|
||||||
|
const resolvedIndex = itemIndexById.get(relatedItemId)
|
||||||
|
if (resolvedIndex) {
|
||||||
|
itemNumbers.push(resolvedIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...new Set(itemNumbers)].sort((left, right) => left - right)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRiskTitleWithItemNumbers(title, itemNumbers = []) {
|
||||||
|
const cleanTitle = normalizeText(title) || '单据风险提示'
|
||||||
|
if (!itemNumbers.length || /^第\s*[\d、,,\s]+\s*条[::]/.test(cleanTitle)) {
|
||||||
|
return cleanTitle
|
||||||
|
}
|
||||||
|
return `第 ${itemNumbers.join('、')} 条:${cleanTitle}`
|
||||||
|
}
|
||||||
|
|
||||||
function resolveItemRiskFlag(item, claimRiskFlags) {
|
function resolveItemRiskFlag(item, claimRiskFlags) {
|
||||||
const itemId = normalizeId(item?.id)
|
const itemId = normalizeId(item?.id)
|
||||||
if (!itemId || !Array.isArray(claimRiskFlags)) {
|
if (!itemId || !Array.isArray(claimRiskFlags)) {
|
||||||
@@ -197,8 +247,9 @@ function resolveItemRiskFlag(item, claimRiskFlags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
||||||
|
const flagItemIds = normalizeIdList(flag.item_ids || flag.itemIds)
|
||||||
const tone = resolveFlagTone(flag)
|
const tone = resolveFlagTone(flag)
|
||||||
return flagItemId === itemId && isRiskTone(tone)
|
return (flagItemId === itemId || flagItemIds.includes(itemId)) && isRiskTone(tone)
|
||||||
}) || null
|
}) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +577,78 @@ function buildManualReturnRiskCard(flag, businessStage = 'reimbursement') {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGenericAutoReviewSummaryFlag(flag) {
|
||||||
|
if (!flag || typeof flag !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = normalizeText(flag.source)
|
||||||
|
const label = normalizeText(flag.label || flag.title || flag.name)
|
||||||
|
if (source !== 'submission_review' || !['自动检测重点复核', '自动检测提醒'].includes(label)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasConcreteRule = normalizeText(flag.rule_code || flag.ruleCode || flag.hit_source || flag.hitSource)
|
||||||
|
const hasConcreteItem = (
|
||||||
|
normalizeText(flag.item_id || flag.itemId || flag.invoice_id || flag.invoiceId)
|
||||||
|
|| normalizeIdList(flag.item_ids || flag.itemIds).length
|
||||||
|
)
|
||||||
|
if (hasConcreteRule || hasConcreteItem) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^自动检测发现/.test(normalizeText(flag.message || flag.summary || flag.reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHotelOverStandardRiskText(value) {
|
||||||
|
const text = normalizeText(value)
|
||||||
|
return /住宿|酒店|宾馆/.test(text) && /超标|超出|报销标准|住宿标准|差标/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCoveredByAttachmentHotelOverStandardRisk(flag, attachmentCards = []) {
|
||||||
|
if (!isHotelOverStandardRiskText(cardLikeText(flag))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return attachmentCards.some((card) => isHotelOverStandardRiskText(cardLikeText(card)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRouteLevelRiskText(value) {
|
||||||
|
const text = normalizeText(value)
|
||||||
|
return /行程|多城市|目的地|票据城市|差旅地点|中转|改签|异地/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTravelRouteExpenseItem(item = {}) {
|
||||||
|
const text = [
|
||||||
|
item.name,
|
||||||
|
item.category,
|
||||||
|
item.desc,
|
||||||
|
item.detail,
|
||||||
|
item.itemType,
|
||||||
|
item.item_type
|
||||||
|
].map((value) => normalizeText(value)).join(' ')
|
||||||
|
if (/补贴|系统自动计算/.test(text)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return /火车|高铁|机票|航班|交通票|出发|返回|中转|起始地|目的地|[--—~至]/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRelatedItemIdsForRisk(flag, risks, expenseItems) {
|
||||||
|
const text = [
|
||||||
|
cardLikeText(flag),
|
||||||
|
...listRiskTextValues(risks)
|
||||||
|
].map((value) => normalizeText(value)).join(' ')
|
||||||
|
if (!isRouteLevelRiskText(text)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (Array.isArray(expenseItems) ? expenseItems : [])
|
||||||
|
.filter((item) => normalizeId(item?.id) && isTravelRouteExpenseItem(item))
|
||||||
|
.map((item) => normalizeId(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRiskTextValues(risks) {
|
||||||
|
return Array.isArray(risks) ? risks : []
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAttachmentRiskCards({
|
export function buildAttachmentRiskCards({
|
||||||
expenseItems = [],
|
expenseItems = [],
|
||||||
attachmentMetaByItemId = {},
|
attachmentMetaByItemId = {},
|
||||||
@@ -581,6 +704,12 @@ export function buildAttachmentRiskCards({
|
|||||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
|
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
if (isGenericAutoReviewSummaryFlag(flag)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (isCoveredByAttachmentHotelOverStandardRisk(flag, attachmentCards)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
if (!flag || typeof flag !== 'object') {
|
if (!flag || typeof flag !== 'object') {
|
||||||
if (!isActionableRiskFlag(flag)) {
|
if (!isActionableRiskFlag(flag)) {
|
||||||
@@ -609,6 +738,7 @@ export function buildAttachmentRiskCards({
|
|||||||
|
|
||||||
const source = normalizeText(flag.source)
|
const source = normalizeText(flag.source)
|
||||||
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
||||||
|
const flagItemIds = normalizeIdList(flag.item_ids || flag.itemIds)
|
||||||
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
|
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -626,6 +756,19 @@ export function buildAttachmentRiskCards({
|
|||||||
const risks = flagPoints.length
|
const risks = flagPoints.length
|
||||||
? flagPoints
|
? flagPoints
|
||||||
: [primaryRisk || fallbackRisk].filter(Boolean)
|
: [primaryRisk || fallbackRisk].filter(Boolean)
|
||||||
|
const relatedItemIds = flagItemIds.length
|
||||||
|
? flagItemIds
|
||||||
|
: inferRelatedItemIdsForRisk(flag, risks, expenseItems)
|
||||||
|
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
|
||||||
|
const relatedItemNumbers = resolveRiskItemNumbers({
|
||||||
|
itemId: flagItemId,
|
||||||
|
itemIds: relatedItemIds,
|
||||||
|
itemIndex
|
||||||
|
}, expenseItems)
|
||||||
|
const title = buildRiskTitleWithItemNumbers(
|
||||||
|
normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
|
||||||
|
relatedItemNumbers
|
||||||
|
)
|
||||||
const summary = normalizeText(flag.summary || flag.message || flag.reason)
|
const summary = normalizeText(flag.summary || flag.message || flag.reason)
|
||||||
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
|
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
|
||||||
risk: risks[0] || primaryRisk || fallbackRisk,
|
risk: risks[0] || primaryRisk || fallbackRisk,
|
||||||
@@ -635,13 +778,14 @@ export function buildAttachmentRiskCards({
|
|||||||
|
|
||||||
return risks.map((risk, pointIndex) => withRiskTags({
|
return risks.map((risk, pointIndex) => withRiskTags({
|
||||||
id: `claim-risk-${index}-${pointIndex}`,
|
id: `claim-risk-${index}-${pointIndex}`,
|
||||||
itemId: flagItemId,
|
itemId: flagItemId || (relatedItemIds.length === 1 ? relatedItemIds[0] : ''),
|
||||||
itemIndex: Number(flag.item_index ?? flag.itemIndex ?? 0) || null,
|
itemIds: relatedItemIds,
|
||||||
|
itemIndex,
|
||||||
invoiceId: normalizeText(flag.invoice_id || flag.invoiceId),
|
invoiceId: normalizeText(flag.invoice_id || flag.invoiceId),
|
||||||
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
|
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
|
||||||
tone,
|
tone,
|
||||||
label: resolveRiskLevelLabel(tone),
|
label: resolveRiskLevelLabel(tone),
|
||||||
title: normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
|
title,
|
||||||
risk,
|
risk,
|
||||||
summary,
|
summary,
|
||||||
ruleBasis,
|
ruleBasis,
|
||||||
|
|||||||
@@ -393,6 +393,55 @@ test('attachment risk cards do not duplicate claim fallback flags for the same i
|
|||||||
assert.equal(riskCards[0].risk, '住宿标准:当前酒店识别金额约 880.00 元/晚。')
|
assert.equal(riskCards[0].risk, '住宿标准:当前酒店识别金额约 880.00 元/晚。')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI advice hides generic auto review summaries when a specific hotel over-standard risk exists', () => {
|
||||||
|
const riskCards = buildAttachmentRiskCards({
|
||||||
|
expenseItems: [
|
||||||
|
{
|
||||||
|
id: 'hotel-over-standard-item',
|
||||||
|
name: '住宿票',
|
||||||
|
itemType: 'hotel_ticket',
|
||||||
|
invoiceId: 'hotel-over-standard.png'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attachmentMetaByItemId: {
|
||||||
|
'hotel-over-standard-item': {
|
||||||
|
analysis: {
|
||||||
|
severity: 'high',
|
||||||
|
label: '高风险',
|
||||||
|
headline: 'AI提示:住宿金额超出报销标准',
|
||||||
|
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
|
||||||
|
points: ['住宿标准:P5在上海的住宿标准为 250.00 元/晚,当前酒店识别金额约 362.00 元/晚。'],
|
||||||
|
suggestion: '请补充超标说明。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
claimRiskFlags: [
|
||||||
|
{
|
||||||
|
source: 'submission_review',
|
||||||
|
severity: 'high',
|
||||||
|
label: '自动检测重点复核',
|
||||||
|
message: '自动检测发现 1 条高风险附件,已随单流转给审批人重点复核。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'submission_review',
|
||||||
|
severity: 'high',
|
||||||
|
label: '住宿超标待说明',
|
||||||
|
message: 'P5 职级在上海的住宿标准为 250.00 元/晚,当前酒店识别金额约 362.00 元/晚。 当前未识别到超标说明,请先补充原因。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'submission_review',
|
||||||
|
severity: 'medium',
|
||||||
|
label: '自动检测重点复核',
|
||||||
|
message: '自动检测发现需审批重点关注事项:存在高风险票据,需审批人重点复核;住宿金额超出当前职级差标,且未补充超标说明。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(riskCards.length, 1)
|
||||||
|
assert.equal(riskCards[0].title, '第 1 条:AI提示:住宿金额超出报销标准')
|
||||||
|
assert.equal(riskCards[0].tone, 'high')
|
||||||
|
})
|
||||||
|
|
||||||
test('AI advice view model exposes grouped completion and risk sections', () => {
|
test('AI advice view model exposes grouped completion and risk sections', () => {
|
||||||
const advice = buildAiAdviceViewModel({
|
const advice = buildAiAdviceViewModel({
|
||||||
completionItems: ['补充业务地点', '补充报销金额'],
|
completionItems: ['补充业务地点', '补充报销金额'],
|
||||||
@@ -567,6 +616,71 @@ test('expense risk indicator can focus and flash related risk card', () => {
|
|||||||
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
|
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('route-level risk cards keep related item ids for every affected expense row', () => {
|
||||||
|
const riskCards = buildAttachmentRiskCards({
|
||||||
|
expenseItems: [
|
||||||
|
{ id: 'travel-item-1', name: '火车票', category: '火车票' },
|
||||||
|
{ id: 'travel-item-2', name: '火车票', category: '火车票' },
|
||||||
|
{ id: 'travel-item-3', name: '火车票', category: '火车票' }
|
||||||
|
],
|
||||||
|
claimRiskFlags: [
|
||||||
|
{
|
||||||
|
source: 'submission_review',
|
||||||
|
severity: 'high',
|
||||||
|
label: '多城市行程待说明',
|
||||||
|
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
|
||||||
|
item_ids: ['travel-item-2', 'travel-item-3']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(riskCards.length, 1)
|
||||||
|
assert.deepEqual(riskCards[0].itemIds, ['travel-item-2', 'travel-item-3'])
|
||||||
|
assert.equal(riskCards[0].title, '第 2、3 条:多城市行程待说明')
|
||||||
|
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => {
|
||||||
|
const riskCards = buildAttachmentRiskCards({
|
||||||
|
expenseItems: [
|
||||||
|
{
|
||||||
|
id: 'train-outbound',
|
||||||
|
name: '火车票',
|
||||||
|
category: '火车票',
|
||||||
|
desc: '武汉-上海',
|
||||||
|
detail: '起始地-目的地',
|
||||||
|
invoiceId: 'outbound.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'train-transfer',
|
||||||
|
name: '火车票',
|
||||||
|
category: '火车票',
|
||||||
|
desc: '上海-深圳',
|
||||||
|
detail: '起始地-目的地',
|
||||||
|
invoiceId: 'transfer.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'allowance-row',
|
||||||
|
name: '出差补贴',
|
||||||
|
category: '出差补贴',
|
||||||
|
desc: '系统自动计算',
|
||||||
|
detail: '直辖市/特区'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
claimRiskFlags: [
|
||||||
|
{
|
||||||
|
source: 'submission_review',
|
||||||
|
severity: 'high',
|
||||||
|
label: '多城市行程待说明',
|
||||||
|
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(riskCards.length, 1)
|
||||||
|
assert.deepEqual(riskCards[0].itemIds, ['train-outbound', 'train-transfer'])
|
||||||
|
})
|
||||||
|
|
||||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||||
const riskCards = buildAttachmentRiskCards({
|
const riskCards = buildAttachmentRiskCards({
|
||||||
claimRiskFlags: [
|
claimRiskFlags: [
|
||||||
|
|||||||
Reference in New Issue
Block a user