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