fix(expense): narrow travel route risk indicators
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.services.expense_rule_runtime import RuntimeTravelPolicy
|
||||
|
||||
|
||||
def count_values(values: list[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for value in values:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
continue
|
||||
counts[normalized] = counts.get(normalized, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def collect_invoice_keys_from_contexts(contexts: list[dict[str, Any]]) -> list[str]:
|
||||
invoice_keys: list[str] = []
|
||||
for context in contexts:
|
||||
document_info = context.get("document_info") or {}
|
||||
for key in collect_invoice_keys_from_document_info(document_info):
|
||||
if key not in invoice_keys:
|
||||
invoice_keys.append(key)
|
||||
return invoice_keys
|
||||
|
||||
|
||||
def collect_invoice_keys_from_document_info(document_info: dict[str, Any]) -> list[str]:
|
||||
keys: list[str] = []
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in {"invoiceno", "invoicenumber", "number", "code"} or any(
|
||||
token in label for token in ("发票号码", "票号", "发票代码", "号码")
|
||||
):
|
||||
normalized = re.sub(r"\s+", "", value)
|
||||
if normalized and normalized not in keys:
|
||||
keys.append(normalized)
|
||||
return keys
|
||||
|
||||
|
||||
def collect_attachment_cities(
|
||||
contexts: list[dict[str, Any]],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> list[str]:
|
||||
cities: list[str] = []
|
||||
for context in contexts:
|
||||
document_info = context.get("document_info") or {}
|
||||
parts = [
|
||||
str(context.get("ocr_summary") or ""),
|
||||
str(context.get("ocr_text") or ""),
|
||||
str(context.get("item").item_location if context.get("item") is not None else ""),
|
||||
]
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if isinstance(field, dict):
|
||||
parts.append(str(field.get("value") or ""))
|
||||
for city in extract_known_cities_from_text(" ".join(parts), policy):
|
||||
if city not in cities:
|
||||
cities.append(city)
|
||||
return cities
|
||||
|
||||
|
||||
def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
|
||||
normalized = str(text or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
cities: list[str] = []
|
||||
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
|
||||
if city in normalized and city not in cities:
|
||||
cities.append(city)
|
||||
return cities
|
||||
|
||||
|
||||
def resolve_first_document_field_value(
|
||||
document_info: dict[str, Any],
|
||||
*,
|
||||
keys: set[str],
|
||||
labels: set[str],
|
||||
) -> str:
|
||||
normalized_keys = {key.replace("_", "").lower() for key in keys}
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in normalized_keys or any(token in label for token in labels):
|
||||
return value
|
||||
return ""
|
||||
@@ -11,11 +11,23 @@ from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_platform_context_tools import (
|
||||
collect_attachment_cities,
|
||||
collect_invoice_keys_from_contexts,
|
||||
collect_invoice_keys_from_document_info,
|
||||
count_values,
|
||||
extract_known_cities_from_text,
|
||||
resolve_first_document_field_value,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
)
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
from app.services.expense_claim_platform_route_risk import resolve_multi_city_related_item_ids
|
||||
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
||||
from app.services.expense_claim_platform_text_risk import (
|
||||
collect_vague_goods_description_evidence,
|
||||
)
|
||||
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
@@ -24,44 +36,6 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
class ExpenseClaimPlatformRiskMixin:
|
||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
||||
_CLEAR_TRAVEL_DOCUMENT_TYPES = {
|
||||
"flight_itinerary",
|
||||
"train_ticket",
|
||||
"ship_ticket",
|
||||
"hotel_invoice",
|
||||
"taxi_receipt",
|
||||
"parking_toll_receipt",
|
||||
}
|
||||
_CLEAR_TRAVEL_SCENE_CODES = {"travel", "hotel", "transport"}
|
||||
_GOODS_DESCRIPTION_FIELD_KEYS = {
|
||||
"goodsname",
|
||||
"servicename",
|
||||
"itemname",
|
||||
"project",
|
||||
"productname",
|
||||
"description",
|
||||
"content",
|
||||
"expensecontent",
|
||||
"feeitem",
|
||||
}
|
||||
_GOODS_DESCRIPTION_LABEL_TOKENS = (
|
||||
"商品",
|
||||
"服务",
|
||||
"货物",
|
||||
"项目",
|
||||
"品名",
|
||||
"名称",
|
||||
"费用内容",
|
||||
"消费内容",
|
||||
)
|
||||
_VAGUE_KEYWORD_NEGATION_MARKERS = (
|
||||
"不含",
|
||||
"不包含",
|
||||
"不包括",
|
||||
"未包含",
|
||||
"不涉及",
|
||||
"不属于",
|
||||
)
|
||||
|
||||
def evaluate_platform_risk_rules(
|
||||
self,
|
||||
@@ -539,7 +513,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
policy = self._get_expense_rule_catalog().travel_policy
|
||||
if policy is None:
|
||||
return None
|
||||
declared_cities = self._extract_known_cities_from_text(
|
||||
declared_cities = extract_known_cities_from_text(
|
||||
" ".join(
|
||||
[
|
||||
str(claim.location or ""),
|
||||
@@ -548,7 +522,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
),
|
||||
policy,
|
||||
)
|
||||
evidence_cities = self._collect_attachment_cities(contexts, policy)
|
||||
evidence_cities = collect_attachment_cities(contexts, policy)
|
||||
if not declared_cities or not evidence_cities:
|
||||
return None
|
||||
if set(declared_cities) & set(evidence_cities):
|
||||
@@ -574,9 +548,9 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
invoice_keys = self._collect_invoice_keys_from_contexts(contexts)
|
||||
invoice_keys = collect_invoice_keys_from_contexts(contexts)
|
||||
duplicate_keys = [
|
||||
key for key, count in self._count_values(invoice_keys).items() if count > 1
|
||||
key for key, count in count_values(invoice_keys).items() if count > 1
|
||||
]
|
||||
if duplicate_keys:
|
||||
return self._build_platform_risk_flag(
|
||||
@@ -604,7 +578,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
other_document_info = other_meta.get("document_info")
|
||||
if not isinstance(other_document_info, dict):
|
||||
continue
|
||||
other_keys = self._collect_invoice_keys_from_document_info(other_document_info)
|
||||
other_keys = collect_invoice_keys_from_document_info(other_document_info)
|
||||
if set(invoice_keys) & set(other_keys):
|
||||
matched_claim_ids.add(str(other_item.claim_id or ""))
|
||||
|
||||
@@ -635,7 +609,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
return None
|
||||
mismatched_buyers: list[str] = []
|
||||
for context in contexts:
|
||||
buyer = self._resolve_first_document_field_value(
|
||||
buyer = resolve_first_document_field_value(
|
||||
context.get("document_info") or {},
|
||||
keys={"buyer_name", "buyer", "purchaser_name", "claimant"},
|
||||
labels={"购买方", "抬头", "买方", "购方"},
|
||||
@@ -667,7 +641,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
for context in contexts:
|
||||
text = " ".join(
|
||||
[
|
||||
self._resolve_first_document_field_value(
|
||||
resolve_first_document_field_value(
|
||||
context.get("document_info") or {},
|
||||
keys={"date", "issue_date", "invoice_date"},
|
||||
labels={"日期", "开票日期", "发生时间"},
|
||||
@@ -723,99 +697,16 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
keywords: list[str],
|
||||
fallback_message: str,
|
||||
) -> dict[str, Any] | None:
|
||||
matched_keywords: list[str] = []
|
||||
matched_fields: list[dict[str, str]] = []
|
||||
|
||||
for context in contexts:
|
||||
document_info = context.get("document_info") or {}
|
||||
if self._is_clear_travel_document(document_info):
|
||||
continue
|
||||
|
||||
field_values = self._collect_goods_description_field_values(document_info)
|
||||
if field_values:
|
||||
for value in field_values:
|
||||
hits = self._collect_non_negated_keyword_hits(value, keywords)
|
||||
for keyword in hits:
|
||||
if keyword not in matched_keywords:
|
||||
matched_keywords.append(keyword)
|
||||
if hits:
|
||||
matched_fields.append(
|
||||
{
|
||||
"item_index": str(context.get("index") or ""),
|
||||
"value": value[:80],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
fallback_text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}"
|
||||
hits = self._collect_non_negated_keyword_hits(fallback_text, keywords)
|
||||
for keyword in hits:
|
||||
if keyword not in matched_keywords:
|
||||
matched_keywords.append(keyword)
|
||||
if hits:
|
||||
matched_fields.append(
|
||||
{
|
||||
"item_index": str(context.get("index") or ""),
|
||||
"value": "OCR全文兜底",
|
||||
}
|
||||
)
|
||||
|
||||
if not matched_keywords:
|
||||
evidence = collect_vague_goods_description_evidence(contexts, keywords)
|
||||
if not evidence:
|
||||
return None
|
||||
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=fallback_message,
|
||||
evidence={
|
||||
"matched_keywords": matched_keywords,
|
||||
"matched_fields": matched_fields[:5],
|
||||
},
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_clear_travel_document(cls, document_info: dict[str, Any]) -> bool:
|
||||
document_type = str(document_info.get("document_type") or "").strip().lower()
|
||||
scene_code = str(document_info.get("scene_code") or "").strip().lower()
|
||||
return (
|
||||
document_type in cls._CLEAR_TRAVEL_DOCUMENT_TYPES
|
||||
or scene_code in cls._CLEAR_TRAVEL_SCENE_CODES
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _collect_goods_description_field_values(cls, document_info: dict[str, Any]) -> list[str]:
|
||||
values: list[str] = []
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in cls._GOODS_DESCRIPTION_FIELD_KEYS or any(
|
||||
token in label for token in cls._GOODS_DESCRIPTION_LABEL_TOKENS
|
||||
):
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def _collect_non_negated_keyword_hits(cls, text: str, keywords: list[str]) -> list[str]:
|
||||
normalized = str(text or "")
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
hits: list[str] = []
|
||||
for keyword in keywords:
|
||||
if not keyword:
|
||||
continue
|
||||
for match in re.finditer(re.escape(keyword), normalized):
|
||||
window = normalized[max(0, match.start() - 12): match.end() + 12]
|
||||
if any(marker in window for marker in cls._VAGUE_KEYWORD_NEGATION_MARKERS):
|
||||
continue
|
||||
hits.append(keyword)
|
||||
break
|
||||
return hits
|
||||
|
||||
def _evaluate_multi_city_reason_required_risk(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
@@ -826,9 +717,9 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
policy = self._get_expense_rule_catalog().travel_policy
|
||||
if policy is None:
|
||||
return None
|
||||
cities = self._collect_attachment_cities(contexts, policy)
|
||||
cities = collect_attachment_cities(contexts, policy)
|
||||
for item in list(claim.items or []):
|
||||
for city in self._extract_known_cities_from_text(str(item.item_location or ""), policy):
|
||||
for city in extract_known_cities_from_text(str(item.item_location or ""), policy):
|
||||
if city not in cities:
|
||||
cities.append(city)
|
||||
if len(cities) <= 2:
|
||||
@@ -836,13 +727,21 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
reason_corpus = self._build_travel_reason_corpus(claim)
|
||||
if self._text_contains_keywords(reason_corpus, policy.route_exception_keywords):
|
||||
return None
|
||||
related_item_ids, extra_cities = resolve_multi_city_related_item_ids(
|
||||
claim,
|
||||
contexts,
|
||||
policy,
|
||||
)
|
||||
evidence = {"cities": cities[:8]}
|
||||
if extra_cities:
|
||||
evidence["extra_cities"] = extra_cities[:8]
|
||||
return self._with_related_item_ids(
|
||||
self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
|
||||
evidence={"cities": cities[:8]},
|
||||
evidence=evidence,
|
||||
),
|
||||
self._context_item_ids(contexts),
|
||||
related_item_ids or self._context_item_ids(contexts),
|
||||
)
|
||||
|
||||
def _build_platform_risk_flag(
|
||||
@@ -882,92 +781,3 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
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] = {}
|
||||
for value in values:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
continue
|
||||
counts[normalized] = counts.get(normalized, 0) + 1
|
||||
return counts
|
||||
|
||||
def _collect_invoice_keys_from_contexts(self, contexts: list[dict[str, Any]]) -> list[str]:
|
||||
invoice_keys: list[str] = []
|
||||
for context in contexts:
|
||||
document_info = context.get("document_info") or {}
|
||||
for key in self._collect_invoice_keys_from_document_info(document_info):
|
||||
if key not in invoice_keys:
|
||||
invoice_keys.append(key)
|
||||
return invoice_keys
|
||||
|
||||
def _collect_invoice_keys_from_document_info(self, document_info: dict[str, Any]) -> list[str]:
|
||||
keys: list[str] = []
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in {"invoiceno", "invoicenumber", "number", "code"} or any(
|
||||
token in label for token in ("发票号码", "票号", "发票代码", "号码")
|
||||
):
|
||||
normalized = re.sub(r"\s+", "", value)
|
||||
if normalized and normalized not in keys:
|
||||
keys.append(normalized)
|
||||
return keys
|
||||
|
||||
def _collect_attachment_cities(
|
||||
self,
|
||||
contexts: list[dict[str, Any]],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> list[str]:
|
||||
cities: list[str] = []
|
||||
for context in contexts:
|
||||
document_info = context.get("document_info") or {}
|
||||
parts = [
|
||||
str(context.get("ocr_summary") or ""),
|
||||
str(context.get("ocr_text") or ""),
|
||||
str(context.get("item").item_location if context.get("item") is not None else ""),
|
||||
]
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if isinstance(field, dict):
|
||||
parts.append(str(field.get("value") or ""))
|
||||
for city in self._extract_known_cities_from_text(" ".join(parts), policy):
|
||||
if city not in cities:
|
||||
cities.append(city)
|
||||
return cities
|
||||
|
||||
@staticmethod
|
||||
def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
|
||||
normalized = str(text or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
cities: list[str] = []
|
||||
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
|
||||
if city in normalized and city not in cities:
|
||||
cities.append(city)
|
||||
return cities
|
||||
|
||||
@staticmethod
|
||||
def _resolve_first_document_field_value(
|
||||
document_info: dict[str, Any],
|
||||
*,
|
||||
keys: set[str],
|
||||
labels: set[str],
|
||||
) -> str:
|
||||
normalized_keys = {key.replace("_", "").lower() for key in keys}
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in normalized_keys or any(token in label for token in labels):
|
||||
return value
|
||||
return ""
|
||||
|
||||
244
server/src/app/services/expense_claim_platform_route_risk.py
Normal file
244
server/src/app/services/expense_claim_platform_route_risk.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_rule_runtime import RuntimeTravelPolicy
|
||||
|
||||
|
||||
def resolve_multi_city_related_item_ids(
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
segments = _collect_travel_route_segments(contexts, policy)
|
||||
if not segments:
|
||||
return _context_item_ids(contexts), []
|
||||
|
||||
first_origin = str(segments[0].get("origin") or "").strip()
|
||||
first_destination = str(segments[0].get("destination") or "").strip()
|
||||
expected_destination = _resolve_expected_travel_city(claim, contexts, policy)
|
||||
baseline_cities = _unique_text_values(
|
||||
[first_origin, expected_destination or first_destination]
|
||||
)
|
||||
|
||||
destination_cities = _unique_text_values(
|
||||
[str(segment.get("destination") or "") for segment in segments]
|
||||
)
|
||||
extra_cities = [
|
||||
city
|
||||
for city in destination_cities
|
||||
if city and city not in set(baseline_cities)
|
||||
]
|
||||
if not extra_cities:
|
||||
route_cities = _unique_text_values(
|
||||
[
|
||||
city
|
||||
for segment in segments
|
||||
for city in (
|
||||
str(segment.get("origin") or ""),
|
||||
str(segment.get("destination") or ""),
|
||||
)
|
||||
]
|
||||
)
|
||||
extra_cities = [
|
||||
city
|
||||
for city in route_cities
|
||||
if city and city not in set(baseline_cities)
|
||||
]
|
||||
|
||||
if not extra_cities:
|
||||
return [], []
|
||||
|
||||
affected_segments = [
|
||||
segment
|
||||
for segment in segments
|
||||
if str(segment.get("origin") or "") in extra_cities
|
||||
or str(segment.get("destination") or "") in extra_cities
|
||||
]
|
||||
return _route_segment_item_ids(affected_segments), extra_cities
|
||||
|
||||
|
||||
def _collect_travel_route_segments(
|
||||
contexts: list[dict[str, Any]],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> list[dict[str, Any]]:
|
||||
segments: list[dict[str, Any]] = []
|
||||
for context in list(contexts or []):
|
||||
if not isinstance(context, dict) or not _is_long_distance_context(context, policy):
|
||||
continue
|
||||
route_segment = _extract_route_segment(context, policy)
|
||||
if route_segment is None:
|
||||
continue
|
||||
origin, destination = route_segment
|
||||
segments.append(
|
||||
{
|
||||
"item": context.get("item"),
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
}
|
||||
)
|
||||
return segments
|
||||
|
||||
|
||||
def _resolve_expected_travel_city(
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> str:
|
||||
claim_city = _extract_first_known_city(str(claim.location or ""), policy)
|
||||
if claim_city:
|
||||
return claim_city
|
||||
|
||||
for context in list(contexts or []):
|
||||
document_info = context.get("document_info") if isinstance(context, dict) else {}
|
||||
document_type = str(document_info.get("document_type") or "").strip().lower()
|
||||
scene_code = str(document_info.get("scene_code") or "").strip().lower()
|
||||
if document_type != "hotel_invoice" and scene_code != "hotel":
|
||||
continue
|
||||
for city in _extract_context_cities(context, policy):
|
||||
return city
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_route_segment(
|
||||
context: dict[str, Any],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> tuple[str, str] | None:
|
||||
document_info = context.get("document_info") or {}
|
||||
item = context.get("item")
|
||||
route_value = _resolve_document_field_value(
|
||||
document_info,
|
||||
keys={"route", "route_cities", "routecities", "travel_route", "trip_route"},
|
||||
labels={"路线", "行程", "起讫", "起终", "始发", "到达"},
|
||||
)
|
||||
candidates = [
|
||||
route_value,
|
||||
str(getattr(item, "item_location", "") or ""),
|
||||
str(getattr(item, "item_reason", "") or ""),
|
||||
str(context.get("ocr_summary") or ""),
|
||||
str(context.get("ocr_text") or ""),
|
||||
]
|
||||
for candidate in candidates:
|
||||
normalized = str(candidate or "").strip()
|
||||
if not normalized:
|
||||
continue
|
||||
for separator in ("-", "—", "–", "至"):
|
||||
if separator not in normalized:
|
||||
continue
|
||||
origin_text, destination_text = [
|
||||
segment.strip()
|
||||
for segment in normalized.split(separator, 1)
|
||||
]
|
||||
origin = _extract_first_known_city(origin_text, policy)
|
||||
destination = _extract_first_known_city(destination_text, policy)
|
||||
if origin and destination and origin != destination:
|
||||
return origin, destination
|
||||
return None
|
||||
|
||||
|
||||
def _is_long_distance_context(
|
||||
context: dict[str, Any],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> bool:
|
||||
document_info = context.get("document_info") or {}
|
||||
item = context.get("item")
|
||||
document_type = str(document_info.get("document_type") or "").strip().lower()
|
||||
scene_code = str(document_info.get("scene_code") or "").strip().lower()
|
||||
item_type = str(getattr(item, "item_type", "") or "").strip().lower()
|
||||
long_distance_types = set(policy.long_distance_document_types)
|
||||
return (
|
||||
document_type in long_distance_types
|
||||
or item_type in long_distance_types
|
||||
or scene_code == "travel"
|
||||
)
|
||||
|
||||
|
||||
def _extract_context_cities(
|
||||
context: dict[str, Any],
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> list[str]:
|
||||
document_info = context.get("document_info") or {}
|
||||
item = context.get("item")
|
||||
parts = [
|
||||
str(context.get("ocr_summary") or ""),
|
||||
str(context.get("ocr_text") or ""),
|
||||
str(getattr(item, "item_location", "") or ""),
|
||||
str(getattr(item, "item_reason", "") or ""),
|
||||
]
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if isinstance(field, dict):
|
||||
parts.append(str(field.get("value") or ""))
|
||||
return _extract_known_cities_from_text(" ".join(parts), policy)
|
||||
|
||||
|
||||
def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
|
||||
normalized = str(text or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
cities: list[str] = []
|
||||
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
|
||||
if city in normalized and city not in cities:
|
||||
cities.append(city)
|
||||
return cities
|
||||
|
||||
|
||||
def _extract_first_known_city(text: str, policy: RuntimeTravelPolicy) -> str:
|
||||
cities = _extract_known_cities_from_text(text, policy)
|
||||
return cities[0] if cities else ""
|
||||
|
||||
|
||||
def _resolve_document_field_value(
|
||||
document_info: dict[str, Any],
|
||||
*,
|
||||
keys: set[str],
|
||||
labels: set[str],
|
||||
) -> str:
|
||||
normalized_keys = {key.replace("_", "").lower() for key in keys}
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in normalized_keys or any(token in label for token in labels):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _route_segment_item_ids(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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _unique_text_values(values: list[str]) -> list[str]:
|
||||
normalized_values: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in list(values or []):
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
normalized_values.append(normalized)
|
||||
return normalized_values
|
||||
136
server/src/app/services/expense_claim_platform_text_risk.py
Normal file
136
server/src/app/services/expense_claim_platform_text_risk.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_CLEAR_TRAVEL_DOCUMENT_TYPES = {
|
||||
"flight_itinerary",
|
||||
"train_ticket",
|
||||
"ship_ticket",
|
||||
"hotel_invoice",
|
||||
"taxi_receipt",
|
||||
"parking_toll_receipt",
|
||||
}
|
||||
_CLEAR_TRAVEL_SCENE_CODES = {"travel", "hotel", "transport"}
|
||||
_GOODS_DESCRIPTION_FIELD_KEYS = {
|
||||
"goodsname",
|
||||
"servicename",
|
||||
"itemname",
|
||||
"project",
|
||||
"productname",
|
||||
"description",
|
||||
"content",
|
||||
"expensecontent",
|
||||
"feeitem",
|
||||
}
|
||||
_GOODS_DESCRIPTION_LABEL_TOKENS = (
|
||||
"商品",
|
||||
"服务",
|
||||
"货物",
|
||||
"项目",
|
||||
"品名",
|
||||
"名称",
|
||||
"费用内容",
|
||||
"消费内容",
|
||||
)
|
||||
_VAGUE_KEYWORD_NEGATION_MARKERS = (
|
||||
"不含",
|
||||
"不包含",
|
||||
"不包括",
|
||||
"未包含",
|
||||
"不涉及",
|
||||
"不属于",
|
||||
)
|
||||
|
||||
|
||||
def collect_vague_goods_description_evidence(
|
||||
contexts: list[dict[str, Any]],
|
||||
keywords: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
matched_keywords: list[str] = []
|
||||
matched_fields: list[dict[str, str]] = []
|
||||
|
||||
for context in contexts:
|
||||
document_info = context.get("document_info") or {}
|
||||
if _is_clear_travel_document(document_info):
|
||||
continue
|
||||
|
||||
field_values = _collect_goods_description_field_values(document_info)
|
||||
if field_values:
|
||||
for value in field_values:
|
||||
hits = _collect_non_negated_keyword_hits(value, keywords)
|
||||
for keyword in hits:
|
||||
if keyword not in matched_keywords:
|
||||
matched_keywords.append(keyword)
|
||||
if hits:
|
||||
matched_fields.append(
|
||||
{
|
||||
"item_index": str(context.get("index") or ""),
|
||||
"value": value[:80],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
fallback_text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}"
|
||||
hits = _collect_non_negated_keyword_hits(fallback_text, keywords)
|
||||
for keyword in hits:
|
||||
if keyword not in matched_keywords:
|
||||
matched_keywords.append(keyword)
|
||||
if hits:
|
||||
matched_fields.append(
|
||||
{
|
||||
"item_index": str(context.get("index") or ""),
|
||||
"value": "OCR全文兜底",
|
||||
}
|
||||
)
|
||||
|
||||
if not matched_keywords:
|
||||
return None
|
||||
return {
|
||||
"matched_keywords": matched_keywords,
|
||||
"matched_fields": matched_fields[:5],
|
||||
}
|
||||
|
||||
|
||||
def _is_clear_travel_document(document_info: dict[str, Any]) -> bool:
|
||||
document_type = str(document_info.get("document_type") or "").strip().lower()
|
||||
scene_code = str(document_info.get("scene_code") or "").strip().lower()
|
||||
return (
|
||||
document_type in _CLEAR_TRAVEL_DOCUMENT_TYPES
|
||||
or scene_code in _CLEAR_TRAVEL_SCENE_CODES
|
||||
)
|
||||
|
||||
|
||||
def _collect_goods_description_field_values(document_info: dict[str, Any]) -> list[str]:
|
||||
values: list[str] = []
|
||||
for field in list(document_info.get("fields") or []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = str(field.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if field_key in _GOODS_DESCRIPTION_FIELD_KEYS or any(
|
||||
token in label for token in _GOODS_DESCRIPTION_LABEL_TOKENS
|
||||
):
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def _collect_non_negated_keyword_hits(text: str, keywords: list[str]) -> list[str]:
|
||||
normalized = str(text or "")
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
hits: list[str] = []
|
||||
for keyword in keywords:
|
||||
if not keyword:
|
||||
continue
|
||||
for match in re.finditer(re.escape(keyword), normalized):
|
||||
window = normalized[max(0, match.start() - 12): match.end() + 12]
|
||||
if any(marker in window for marker in _VAGUE_KEYWORD_NEGATION_MARKERS):
|
||||
continue
|
||||
hits.append(keyword)
|
||||
break
|
||||
return hits
|
||||
@@ -160,6 +160,52 @@ def _add_vague_goods_rule_asset(
|
||||
)
|
||||
|
||||
|
||||
def _add_multi_city_reason_rule_asset(
|
||||
db: Session,
|
||||
manager: AgentAssetRuleLibraryManager,
|
||||
) -> None:
|
||||
rule_code = "risk.travel.medium.multi_city_no_reason"
|
||||
file_name = f"{rule_code}.json"
|
||||
payload = {
|
||||
"schema_version": "2.0",
|
||||
"rule_code": rule_code,
|
||||
"name": "多城市行程缺少说明中风险",
|
||||
"evaluator": "multi_city_reason_required",
|
||||
"enabled": True,
|
||||
"requires_attachment": True,
|
||||
"applies_to": {
|
||||
"domains": ["expense", "travel"],
|
||||
"expense_types": ["travel"],
|
||||
"business_stages": ["reimbursement"],
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "medium", "action": "manual_review"}},
|
||||
}
|
||||
manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
db.add(
|
||||
AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=rule_code,
|
||||
name="多城市行程缺少说明中风险",
|
||||
description="",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["差旅费"],
|
||||
owner="pytest",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
config_json={
|
||||
"detail_mode": "json_risk",
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": {"file_name": file_name},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _write_attachment_meta(storage_root, invoice_id: str, meta: dict[str, Any]) -> None:
|
||||
file_path = storage_root / invoice_id
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -520,3 +566,78 @@ def test_vague_ticket_content_still_flags_unclear_goods_name(
|
||||
assert len(rule_flags) == 1
|
||||
assert rule_flags[0]["severity"] == "low"
|
||||
assert rule_flags[0]["evidence"]["matched_keywords"] == ["服务费"]
|
||||
|
||||
|
||||
def test_multi_city_reason_risk_marks_only_abnormal_route_items(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
storage_root = tmp_path / "attachments"
|
||||
_patch_rule_manager(monkeypatch, manager)
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root)
|
||||
_add_multi_city_reason_rule_asset(db, manager)
|
||||
|
||||
claim = _build_claim(claim_no="RE-MULTI-CITY-001", expense_type="travel")
|
||||
claim.location = "上海"
|
||||
claim.reason = "支撑国网仿生产环境部署"
|
||||
routes = [
|
||||
("outbound", "武汉-上海", date(2026, 2, 20), Decimal("354.00")),
|
||||
("extra-out", "上海-深圳", date(2026, 2, 21), Decimal("438.00")),
|
||||
("extra-back", "深圳-上海", date(2026, 2, 22), Decimal("388.00")),
|
||||
("return", "上海-武汉", date(2026, 2, 23), Decimal("354.00")),
|
||||
]
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=item_date,
|
||||
item_type="train_ticket",
|
||||
item_reason=route,
|
||||
item_location=route,
|
||||
item_amount=amount,
|
||||
invoice_id=f"claim-multi-city/{suffix}.pdf",
|
||||
)
|
||||
for suffix, route, item_date, amount in routes
|
||||
]
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
for suffix, route, _, _ in routes:
|
||||
_write_attachment_meta(
|
||||
storage_root,
|
||||
f"claim-multi-city/{suffix}.pdf",
|
||||
{
|
||||
"document_info": {
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "交通票据",
|
||||
"fields": [
|
||||
{"key": "route", "label": "路线", "value": route},
|
||||
],
|
||||
},
|
||||
"ocr_summary": f"火车票;{route}",
|
||||
"ocr_text": f"旅客行程为 {route}",
|
||||
},
|
||||
)
|
||||
|
||||
review = ExpenseClaimService(db).evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
rule_flags = [
|
||||
flag
|
||||
for flag in review["flags"]
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "risk.travel.medium.multi_city_no_reason"
|
||||
]
|
||||
|
||||
assert len(rule_flags) == 1
|
||||
flagged_item_ids = set(rule_flags[0]["item_ids"])
|
||||
route_item_ids = {item.item_reason: item.id for item in claim.items}
|
||||
assert flagged_item_ids == {
|
||||
route_item_ids["上海-深圳"],
|
||||
route_item_ids["深圳-上海"],
|
||||
}
|
||||
assert route_item_ids["武汉-上海"] not in flagged_item_ids
|
||||
assert route_item_ids["上海-武汉"] not in flagged_item_ids
|
||||
|
||||
@@ -1013,7 +1013,11 @@
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
|
||||
.editor-control:not(.risk-note-editor-input),
|
||||
.editor-select {
|
||||
min-height: var(--expense-editor-control-height);
|
||||
height: var(--expense-editor-control-height);
|
||||
}
|
||||
|
||||
.editor-select {
|
||||
padding: 0;
|
||||
|
||||
124
web/src/views/scripts/travelRequestDetailBusinessStage.js
Normal file
124
web/src/views/scripts/travelRequestDetailBusinessStage.js
Normal file
@@ -0,0 +1,124 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function cardLikeText(card = {}) {
|
||||
if (!card || typeof card !== 'object') {
|
||||
return normalizeText(card)
|
||||
}
|
||||
return [
|
||||
card.title,
|
||||
card.label,
|
||||
card.name,
|
||||
card.summary,
|
||||
card.message,
|
||||
card.reason,
|
||||
card.suggestion,
|
||||
card.ruleName,
|
||||
card.rule_name,
|
||||
card.ruleCode,
|
||||
card.rule_code,
|
||||
card.evidence?.summary,
|
||||
card.evidence?.reason
|
||||
].map((value) => normalizeText(value)).filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export function normalizeBusinessStage(value) {
|
||||
const stage = normalizeText(value).toLowerCase()
|
||||
if ([
|
||||
'expense_application',
|
||||
'application',
|
||||
'apply',
|
||||
'pre_apply',
|
||||
'pre_application',
|
||||
'budget_application'
|
||||
].includes(stage)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
if ([
|
||||
'reimbursement',
|
||||
'expense_reimbursement',
|
||||
'claim',
|
||||
'expense_claim',
|
||||
'expense_report'
|
||||
].includes(stage)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return resolveRiskTextBusinessStage(flag, fallback)
|
||||
}
|
||||
|
||||
const explicitStage = normalizeBusinessStage(
|
||||
flag.businessStage
|
||||
|| flag.business_stage
|
||||
|| flag.controlStage
|
||||
|| flag.control_stage
|
||||
)
|
||||
if (explicitStage) {
|
||||
return explicitStage
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source).toLowerCase()
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
|
||||
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
if (/application/.test(source) || /expense_application/.test(eventType)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
|
||||
}
|
||||
|
||||
export function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
|
||||
const text = normalizeText(value)
|
||||
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function resolveRequestBusinessStage(request = {}) {
|
||||
const explicitStage = normalizeBusinessStage(
|
||||
request?.businessStage
|
||||
|| request?.business_stage
|
||||
|| request?.controlStage
|
||||
|| request?.control_stage
|
||||
)
|
||||
if (explicitStage) {
|
||||
return explicitStage
|
||||
}
|
||||
const documentType = normalizeText(
|
||||
request?.documentTypeCode
|
||||
|| request?.document_type_code
|
||||
|| request?.documentType
|
||||
|| request?.document_type
|
||||
).toLowerCase()
|
||||
if (['application', 'expense_application'].includes(documentType)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
const claimNo = normalizeText(
|
||||
request?.claimNo
|
||||
|| request?.claim_no
|
||||
|| request?.documentNo
|
||||
|| request?.document_no
|
||||
|| request?.id
|
||||
).toUpperCase()
|
||||
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
|
||||
if (typeCode === 'application' || typeCode.endsWith('_application')) {
|
||||
return 'expense_application'
|
||||
}
|
||||
return 'reimbursement'
|
||||
}
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
resolveRiskDomain,
|
||||
resolveRiskVisibilityScope
|
||||
} from '../../utils/riskVisibility.js'
|
||||
import {
|
||||
normalizeBusinessStage,
|
||||
resolveFlagBusinessStage,
|
||||
resolveRequestBusinessStage,
|
||||
resolveRiskTextBusinessStage
|
||||
} from './travelRequestDetailBusinessStage.js'
|
||||
import { resolveRouteRelatedItemIdsForRisk } from './travelRequestDetailRouteRisk.js'
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
@@ -46,68 +53,6 @@ function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizeBusinessStage(value) {
|
||||
const stage = normalizeText(value).toLowerCase()
|
||||
if ([
|
||||
'expense_application',
|
||||
'application',
|
||||
'apply',
|
||||
'pre_apply',
|
||||
'pre_application',
|
||||
'budget_application'
|
||||
].includes(stage)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
if ([
|
||||
'reimbursement',
|
||||
'expense_reimbursement',
|
||||
'claim',
|
||||
'expense_claim',
|
||||
'expense_report'
|
||||
].includes(stage)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return resolveRiskTextBusinessStage(flag, fallback)
|
||||
}
|
||||
|
||||
const explicitStage = normalizeBusinessStage(
|
||||
flag.businessStage
|
||||
|| flag.business_stage
|
||||
|| flag.controlStage
|
||||
|| flag.control_stage
|
||||
)
|
||||
if (explicitStage) {
|
||||
return explicitStage
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source).toLowerCase()
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
|
||||
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
if (/application/.test(source) || /expense_application/.test(eventType)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
|
||||
}
|
||||
|
||||
function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
|
||||
const text = normalizeText(value)
|
||||
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function cardLikeText(card = {}) {
|
||||
return [
|
||||
card.label,
|
||||
@@ -121,46 +66,6 @@ function cardLikeText(card = {}) {
|
||||
].map((item) => normalizeText(item)).join(' ')
|
||||
}
|
||||
|
||||
function resolveRequestBusinessStage(request = {}) {
|
||||
const explicitStage = normalizeBusinessStage(
|
||||
request?.businessStage
|
||||
|| request?.business_stage
|
||||
|| request?.controlStage
|
||||
|| request?.control_stage
|
||||
)
|
||||
if (explicitStage) {
|
||||
return explicitStage
|
||||
}
|
||||
|
||||
const documentType = normalizeText(
|
||||
request?.documentTypeCode
|
||||
|| request?.document_type_code
|
||||
|| request?.documentType
|
||||
|| request?.document_type
|
||||
).toLowerCase()
|
||||
if (['application', 'expense_application'].includes(documentType)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
const claimNo = normalizeText(
|
||||
request?.claimNo
|
||||
|| request?.claim_no
|
||||
|| request?.documentNo
|
||||
|| request?.document_no
|
||||
|| request?.id
|
||||
).toUpperCase()
|
||||
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
|
||||
if (typeCode === 'application' || typeCode.endsWith('_application')) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
return 'reimbursement'
|
||||
}
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = normalizeText(value).toLowerCase()
|
||||
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
|
||||
@@ -587,43 +492,6 @@ function isCoveredByAttachmentHotelOverStandardRisk(flag, attachmentCards = [])
|
||||
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({
|
||||
expenseItems = [],
|
||||
attachmentMetaByItemId = {},
|
||||
@@ -731,9 +599,12 @@ export function buildAttachmentRiskCards({
|
||||
const risks = flagPoints.length
|
||||
? flagPoints
|
||||
: [primaryRisk || fallbackRisk].filter(Boolean)
|
||||
const relatedItemIds = flagItemIds.length
|
||||
? flagItemIds
|
||||
: inferRelatedItemIdsForRisk(flag, risks, expenseItems)
|
||||
const relatedItemIds = resolveRouteRelatedItemIdsForRisk({
|
||||
flagItemIds,
|
||||
flag,
|
||||
risks,
|
||||
expenseItems
|
||||
})
|
||||
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
|
||||
const title = normalizeRiskCardTitle(
|
||||
flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code,
|
||||
|
||||
197
web/src/views/scripts/travelRequestDetailRouteRisk.js
Normal file
197
web/src/views/scripts/travelRequestDetailRouteRisk.js
Normal file
@@ -0,0 +1,197 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function cardLikeText(card = {}) {
|
||||
if (!card || typeof card !== 'object') {
|
||||
return normalizeText(card)
|
||||
}
|
||||
return [
|
||||
card.title,
|
||||
card.label,
|
||||
card.name,
|
||||
card.summary,
|
||||
card.message,
|
||||
card.reason,
|
||||
card.suggestion,
|
||||
card.ruleName,
|
||||
card.rule_name,
|
||||
card.ruleCode,
|
||||
card.rule_code,
|
||||
card.evidence?.summary,
|
||||
card.evidence?.reason
|
||||
].map((value) => normalizeText(value)).filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const GENERIC_ROUTE_CITY_TOKENS = new Set([
|
||||
'起始地',
|
||||
'目的地',
|
||||
'出发地',
|
||||
'返回地',
|
||||
'中转地',
|
||||
'城市',
|
||||
'地点'
|
||||
])
|
||||
|
||||
function normalizeRouteCityToken(value) {
|
||||
const text = normalizeText(value).replace(/[,。;、]/g, '').replace(/市$/, '')
|
||||
if (!text || GENERIC_ROUTE_CITY_TOKENS.has(text)) {
|
||||
return ''
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function extractRouteCityPairFromText(value) {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const match = text.match(/([\u4e00-\u9fa5]{2,8})\s*(?:市)?\s*[--—–~~至到]\s*([\u4e00-\u9fa5]{2,8})\s*(?:市)?/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const origin = normalizeRouteCityToken(match[1])
|
||||
const destination = normalizeRouteCityToken(match[2])
|
||||
if (!origin || !destination || origin === destination) {
|
||||
return null
|
||||
}
|
||||
return { origin, destination }
|
||||
}
|
||||
|
||||
function resolveTravelRouteInfo(item = {}) {
|
||||
if (!normalizeId(item?.id) || !isTravelRouteExpenseItem(item)) {
|
||||
return null
|
||||
}
|
||||
const pair = [
|
||||
item.desc,
|
||||
item.itemReason,
|
||||
item.item_reason,
|
||||
item.itemLocation,
|
||||
item.item_location,
|
||||
item.detail
|
||||
]
|
||||
.map((value) => extractRouteCityPairFromText(value))
|
||||
.find(Boolean)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: normalizeId(item.id),
|
||||
cities: [pair.origin, pair.destination],
|
||||
origin: pair.origin,
|
||||
destination: pair.destination
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueTextList(values) {
|
||||
return (Array.isArray(values) ? values : [])
|
||||
.map((value) => normalizeText(value))
|
||||
.filter(Boolean)
|
||||
.filter((value, index, list) => list.indexOf(value) === index)
|
||||
}
|
||||
|
||||
function resolveRoundTripBaseCities(routeInfos) {
|
||||
if (!Array.isArray(routeInfos) || routeInfos.length < 2) {
|
||||
return new Set()
|
||||
}
|
||||
const first = routeInfos[0]
|
||||
const last = routeInfos[routeInfos.length - 1]
|
||||
if (
|
||||
first?.origin
|
||||
&& first?.destination
|
||||
&& last?.origin
|
||||
&& last?.destination
|
||||
&& first.origin === last.destination
|
||||
&& first.destination === last.origin
|
||||
) {
|
||||
return new Set([first.origin, first.destination])
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function resolveUnexpectedRouteCitiesForRisk(text, routeInfos) {
|
||||
const routeCities = uniqueTextList(routeInfos.flatMap((item) => item.cities))
|
||||
if (!routeCities.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const baseCities = resolveRoundTripBaseCities(routeInfos)
|
||||
const mentionedCities = routeCities.filter((city) => text.includes(city))
|
||||
const mentionedExtraCities = mentionedCities.filter((city) => !baseCities.has(city))
|
||||
if (mentionedExtraCities.length) {
|
||||
return mentionedExtraCities
|
||||
}
|
||||
|
||||
if (baseCities.size) {
|
||||
return routeCities.filter((city) => !baseCities.has(city))
|
||||
}
|
||||
|
||||
return mentionedCities
|
||||
}
|
||||
|
||||
function inferRouteRelatedItemIds(flag, risks, expenseItems) {
|
||||
const text = [
|
||||
cardLikeText(flag),
|
||||
...uniqueTextList(risks)
|
||||
].map((value) => normalizeText(value)).join(' ')
|
||||
if (!isRouteLevelRiskText(text)) {
|
||||
return []
|
||||
}
|
||||
const routeInfos = (Array.isArray(expenseItems) ? expenseItems : [])
|
||||
.map((item) => resolveTravelRouteInfo(item))
|
||||
.filter(Boolean)
|
||||
const unexpectedCities = resolveUnexpectedRouteCitiesForRisk(text, routeInfos)
|
||||
if (!unexpectedCities.length) {
|
||||
return []
|
||||
}
|
||||
return routeInfos
|
||||
.filter((item) => item.cities.some((city) => unexpectedCities.includes(city)))
|
||||
.map((item) => item.id)
|
||||
}
|
||||
|
||||
export function resolveRouteRelatedItemIdsForRisk({
|
||||
flagItemIds = [],
|
||||
flag = {},
|
||||
risks = [],
|
||||
expenseItems = []
|
||||
} = {}) {
|
||||
const normalizedFlagItemIds = (Array.isArray(flagItemIds) ? flagItemIds : [])
|
||||
.map((itemId) => normalizeId(itemId))
|
||||
.filter(Boolean)
|
||||
const inferredItemIds = inferRouteRelatedItemIds(flag, risks, expenseItems)
|
||||
if (!normalizedFlagItemIds.length) {
|
||||
return inferredItemIds
|
||||
}
|
||||
if (
|
||||
inferredItemIds.length
|
||||
&& inferredItemIds.length < normalizedFlagItemIds.length
|
||||
&& inferredItemIds.every((itemId) => normalizedFlagItemIds.includes(itemId))
|
||||
) {
|
||||
return inferredItemIds
|
||||
}
|
||||
return normalizedFlagItemIds
|
||||
}
|
||||
@@ -659,6 +659,22 @@ test('legacy route-level risk cards infer affected travel rows when backend has
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'transfer.png'
|
||||
},
|
||||
{
|
||||
id: 'train-transfer-return',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '深圳-上海',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'transfer-return.png'
|
||||
},
|
||||
{
|
||||
id: 'train-return',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '上海-武汉',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'return.png'
|
||||
},
|
||||
{
|
||||
id: 'allowance-row',
|
||||
name: '出差补贴',
|
||||
@@ -678,7 +694,59 @@ test('legacy route-level risk cards infer affected travel rows when backend has
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.deepEqual(riskCards[0].itemIds, ['train-outbound', 'train-transfer'])
|
||||
assert.deepEqual(riskCards[0].itemIds, ['train-transfer', 'train-transfer-return'])
|
||||
})
|
||||
|
||||
test('route-level risk cards narrow broad backend item ids to abnormal route rows', () => {
|
||||
const expenseItems = [
|
||||
{
|
||||
id: 'train-outbound',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '武汉-上海',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'outbound.png'
|
||||
},
|
||||
{
|
||||
id: 'train-transfer',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '上海-深圳',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'transfer.png'
|
||||
},
|
||||
{
|
||||
id: 'train-transfer-return',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '深圳-上海',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'transfer-return.png'
|
||||
},
|
||||
{
|
||||
id: 'train-return',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '上海-武汉',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'return.png'
|
||||
}
|
||||
]
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems,
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '多城市行程待说明',
|
||||
message: '本次报销识别到多城市行程(上海、武汉、深圳),但事由中未说明中转、多地拜访或改签原因。',
|
||||
item_ids: expenseItems.map((item) => item.id)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.deepEqual(riskCards[0].itemIds, ['train-transfer', 'train-transfer-return'])
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
|
||||
Reference in New Issue
Block a user