From 470f343b29212c0cf0d521810d8e4507fd362e19 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 17 Jun 2026 09:36:24 +0800 Subject: [PATCH] fix(expense): narrow travel route risk indicators --- .../expense_claim_platform_context_tools.py | 97 +++++++ .../services/expense_claim_platform_risk.py | 258 +++--------------- .../expense_claim_platform_route_risk.py | 244 +++++++++++++++++ .../expense_claim_platform_text_risk.py | 136 +++++++++ .../test_expense_claim_platform_risk_stage.py | 121 ++++++++ .../views/travel-request-detail-view.css | 6 +- .../travelRequestDetailBusinessStage.js | 124 +++++++++ .../scripts/travelRequestDetailInsights.js | 155 +---------- .../scripts/travelRequestDetailRouteRisk.js | 197 +++++++++++++ ...travel-request-detail-risk-advice.test.mjs | 70 ++++- 10 files changed, 1040 insertions(+), 368 deletions(-) create mode 100644 server/src/app/services/expense_claim_platform_context_tools.py create mode 100644 server/src/app/services/expense_claim_platform_route_risk.py create mode 100644 server/src/app/services/expense_claim_platform_text_risk.py create mode 100644 web/src/views/scripts/travelRequestDetailBusinessStage.js create mode 100644 web/src/views/scripts/travelRequestDetailRouteRisk.js diff --git a/server/src/app/services/expense_claim_platform_context_tools.py b/server/src/app/services/expense_claim_platform_context_tools.py new file mode 100644 index 0000000..c1411e4 --- /dev/null +++ b/server/src/app/services/expense_claim_platform_context_tools.py @@ -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 "" diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index a6a5206..e1104d9 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -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 "" diff --git a/server/src/app/services/expense_claim_platform_route_risk.py b/server/src/app/services/expense_claim_platform_route_risk.py new file mode 100644 index 0000000..475e640 --- /dev/null +++ b/server/src/app/services/expense_claim_platform_route_risk.py @@ -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 diff --git a/server/src/app/services/expense_claim_platform_text_risk.py b/server/src/app/services/expense_claim_platform_text_risk.py new file mode 100644 index 0000000..49a2675 --- /dev/null +++ b/server/src/app/services/expense_claim_platform_text_risk.py @@ -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 diff --git a/server/tests/test_expense_claim_platform_risk_stage.py b/server/tests/test_expense_claim_platform_risk_stage.py index c09d1b3..33c6265 100644 --- a/server/tests/test_expense_claim_platform_risk_stage.py +++ b/server/tests/test_expense_claim_platform_risk_stage.py @@ -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 diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index e7954d6..43d7ab3 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -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; diff --git a/web/src/views/scripts/travelRequestDetailBusinessStage.js b/web/src/views/scripts/travelRequestDetailBusinessStage.js new file mode 100644 index 0000000..2ff30ca --- /dev/null +++ b/web/src/views/scripts/travelRequestDetailBusinessStage.js @@ -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' +} diff --git a/web/src/views/scripts/travelRequestDetailInsights.js b/web/src/views/scripts/travelRequestDetailInsights.js index a0eb53f..c90cb87 100644 --- a/web/src/views/scripts/travelRequestDetailInsights.js +++ b/web/src/views/scripts/travelRequestDetailInsights.js @@ -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, diff --git a/web/src/views/scripts/travelRequestDetailRouteRisk.js b/web/src/views/scripts/travelRequestDetailRouteRisk.js new file mode 100644 index 0000000..6db69d5 --- /dev/null +++ b/web/src/views/scripts/travelRequestDetailRouteRisk.js @@ -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 +} diff --git a/web/tests/travel-request-detail-risk-advice.test.mjs b/web/tests/travel-request-detail-risk-advice.test.mjs index 4994c47..4b332fb 100644 --- a/web/tests/travel-request-detail-risk-advice.test.mjs +++ b/web/tests/travel-request-detail-risk-advice.test.mjs @@ -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', () => {