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_rule_library import AgentAssetRuleLibraryManager
|
||||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||||
from app.services.budget import BudgetService
|
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 (
|
from app.services.expense_rule_runtime import (
|
||||||
RuntimeTravelPolicy,
|
RuntimeTravelPolicy,
|
||||||
)
|
)
|
||||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
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_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_classifier import is_budget_risk_manifest
|
||||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||||
@@ -24,44 +36,6 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
|||||||
class ExpenseClaimPlatformRiskMixin:
|
class ExpenseClaimPlatformRiskMixin:
|
||||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "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(
|
def evaluate_platform_risk_rules(
|
||||||
self,
|
self,
|
||||||
@@ -539,7 +513,7 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
policy = self._get_expense_rule_catalog().travel_policy
|
policy = self._get_expense_rule_catalog().travel_policy
|
||||||
if policy is None:
|
if policy is None:
|
||||||
return None
|
return None
|
||||||
declared_cities = self._extract_known_cities_from_text(
|
declared_cities = extract_known_cities_from_text(
|
||||||
" ".join(
|
" ".join(
|
||||||
[
|
[
|
||||||
str(claim.location or ""),
|
str(claim.location or ""),
|
||||||
@@ -548,7 +522,7 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
),
|
),
|
||||||
policy,
|
policy,
|
||||||
)
|
)
|
||||||
evidence_cities = self._collect_attachment_cities(contexts, policy)
|
evidence_cities = collect_attachment_cities(contexts, policy)
|
||||||
if not declared_cities or not evidence_cities:
|
if not declared_cities or not evidence_cities:
|
||||||
return None
|
return None
|
||||||
if set(declared_cities) & set(evidence_cities):
|
if set(declared_cities) & set(evidence_cities):
|
||||||
@@ -574,9 +548,9 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
claim: ExpenseClaim,
|
claim: ExpenseClaim,
|
||||||
contexts: list[dict[str, Any]],
|
contexts: list[dict[str, Any]],
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
invoice_keys = self._collect_invoice_keys_from_contexts(contexts)
|
invoice_keys = collect_invoice_keys_from_contexts(contexts)
|
||||||
duplicate_keys = [
|
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:
|
if duplicate_keys:
|
||||||
return self._build_platform_risk_flag(
|
return self._build_platform_risk_flag(
|
||||||
@@ -604,7 +578,7 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
other_document_info = other_meta.get("document_info")
|
other_document_info = other_meta.get("document_info")
|
||||||
if not isinstance(other_document_info, dict):
|
if not isinstance(other_document_info, dict):
|
||||||
continue
|
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):
|
if set(invoice_keys) & set(other_keys):
|
||||||
matched_claim_ids.add(str(other_item.claim_id or ""))
|
matched_claim_ids.add(str(other_item.claim_id or ""))
|
||||||
|
|
||||||
@@ -635,7 +609,7 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
return None
|
return None
|
||||||
mismatched_buyers: list[str] = []
|
mismatched_buyers: list[str] = []
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
buyer = self._resolve_first_document_field_value(
|
buyer = resolve_first_document_field_value(
|
||||||
context.get("document_info") or {},
|
context.get("document_info") or {},
|
||||||
keys={"buyer_name", "buyer", "purchaser_name", "claimant"},
|
keys={"buyer_name", "buyer", "purchaser_name", "claimant"},
|
||||||
labels={"购买方", "抬头", "买方", "购方"},
|
labels={"购买方", "抬头", "买方", "购方"},
|
||||||
@@ -667,7 +641,7 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
for context in contexts:
|
for context in contexts:
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
[
|
[
|
||||||
self._resolve_first_document_field_value(
|
resolve_first_document_field_value(
|
||||||
context.get("document_info") or {},
|
context.get("document_info") or {},
|
||||||
keys={"date", "issue_date", "invoice_date"},
|
keys={"date", "issue_date", "invoice_date"},
|
||||||
labels={"日期", "开票日期", "发生时间"},
|
labels={"日期", "开票日期", "发生时间"},
|
||||||
@@ -723,99 +697,16 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
keywords: list[str],
|
keywords: list[str],
|
||||||
fallback_message: str,
|
fallback_message: str,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
matched_keywords: list[str] = []
|
evidence = collect_vague_goods_description_evidence(contexts, keywords)
|
||||||
matched_fields: list[dict[str, str]] = []
|
if not evidence:
|
||||||
|
|
||||||
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:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._build_platform_risk_flag(
|
return self._build_platform_risk_flag(
|
||||||
manifest,
|
manifest,
|
||||||
message=fallback_message,
|
message=fallback_message,
|
||||||
evidence={
|
evidence=evidence,
|
||||||
"matched_keywords": matched_keywords,
|
|
||||||
"matched_fields": matched_fields[:5],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@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(
|
def _evaluate_multi_city_reason_required_risk(
|
||||||
self,
|
self,
|
||||||
manifest: dict[str, Any],
|
manifest: dict[str, Any],
|
||||||
@@ -826,9 +717,9 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
policy = self._get_expense_rule_catalog().travel_policy
|
policy = self._get_expense_rule_catalog().travel_policy
|
||||||
if policy is None:
|
if policy is None:
|
||||||
return None
|
return None
|
||||||
cities = self._collect_attachment_cities(contexts, policy)
|
cities = collect_attachment_cities(contexts, policy)
|
||||||
for item in list(claim.items or []):
|
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:
|
if city not in cities:
|
||||||
cities.append(city)
|
cities.append(city)
|
||||||
if len(cities) <= 2:
|
if len(cities) <= 2:
|
||||||
@@ -836,13 +727,21 @@ 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
|
||||||
|
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(
|
return self._with_related_item_ids(
|
||||||
self._build_platform_risk_flag(
|
self._build_platform_risk_flag(
|
||||||
manifest,
|
manifest,
|
||||||
message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。",
|
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(
|
def _build_platform_risk_flag(
|
||||||
@@ -882,92 +781,3 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
if len(normalized_item_ids) == 1:
|
if len(normalized_item_ids) == 1:
|
||||||
flag["item_id"] = normalized_item_ids[0]
|
flag["item_id"] = normalized_item_ids[0]
|
||||||
return flag
|
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:
|
def _write_attachment_meta(storage_root, invoice_id: str, meta: dict[str, Any]) -> None:
|
||||||
file_path = storage_root / invoice_id
|
file_path = storage_root / invoice_id
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
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 len(rule_flags) == 1
|
||||||
assert rule_flags[0]["severity"] == "low"
|
assert rule_flags[0]["severity"] == "low"
|
||||||
assert rule_flags[0]["evidence"]["matched_keywords"] == ["服务费"]
|
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;
|
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 {
|
.editor-select {
|
||||||
padding: 0;
|
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,
|
resolveRiskDomain,
|
||||||
resolveRiskVisibilityScope
|
resolveRiskVisibilityScope
|
||||||
} from '../../utils/riskVisibility.js'
|
} from '../../utils/riskVisibility.js'
|
||||||
|
import {
|
||||||
|
normalizeBusinessStage,
|
||||||
|
resolveFlagBusinessStage,
|
||||||
|
resolveRequestBusinessStage,
|
||||||
|
resolveRiskTextBusinessStage
|
||||||
|
} from './travelRequestDetailBusinessStage.js'
|
||||||
|
import { resolveRouteRelatedItemIdsForRisk } from './travelRequestDetailRouteRisk.js'
|
||||||
|
|
||||||
const DOCUMENT_TYPE_LABELS = {
|
const DOCUMENT_TYPE_LABELS = {
|
||||||
flight_itinerary: '机票/航班行程单',
|
flight_itinerary: '机票/航班行程单',
|
||||||
@@ -46,68 +53,6 @@ function uniqueTexts(values) {
|
|||||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
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 = {}) {
|
function cardLikeText(card = {}) {
|
||||||
return [
|
return [
|
||||||
card.label,
|
card.label,
|
||||||
@@ -121,46 +66,6 @@ function cardLikeText(card = {}) {
|
|||||||
].map((item) => normalizeText(item)).join(' ')
|
].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) {
|
function normalizeTone(value) {
|
||||||
const tone = normalizeText(value).toLowerCase()
|
const tone = normalizeText(value).toLowerCase()
|
||||||
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
|
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)))
|
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 = {},
|
||||||
@@ -731,9 +599,12 @@ 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
|
const relatedItemIds = resolveRouteRelatedItemIdsForRisk({
|
||||||
? flagItemIds
|
flagItemIds,
|
||||||
: inferRelatedItemIdsForRisk(flag, risks, expenseItems)
|
flag,
|
||||||
|
risks,
|
||||||
|
expenseItems
|
||||||
|
})
|
||||||
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
|
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
|
||||||
const title = normalizeRiskCardTitle(
|
const title = normalizeRiskCardTitle(
|
||||||
flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code,
|
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: '起始地-目的地',
|
detail: '起始地-目的地',
|
||||||
invoiceId: 'transfer.png'
|
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',
|
id: 'allowance-row',
|
||||||
name: '出差补贴',
|
name: '出差补贴',
|
||||||
@@ -678,7 +694,59 @@ test('legacy route-level risk cards infer affected travel rows when backend has
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(riskCards.length, 1)
|
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', () => {
|
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user