Files
X-Financial/server/src/app/services/user_agent_review_travel_receipts.py
caoxiaozhu 34457f9c3e feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
2026-06-03 15:46:56 +08:00

616 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.agent_asset import AgentAssetListItem
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.schemas.user_agent import (
UserAgentCitation,
UserAgentDraftPayload,
UserAgentExpenseQueryRecord,
UserAgentQueryPayload,
UserAgentQueryStatusGroup,
UserAgentReviewAction,
UserAgentReviewClaimGroup,
UserAgentReviewDocumentCard,
UserAgentReviewDocumentField,
UserAgentReviewEditField,
UserAgentReviewPayload,
UserAgentReviewRiskBrief,
UserAgentReviewSlotCard,
UserAgentRequest,
UserAgentSuggestedAction,
)
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import *
class UserAgentReviewTravelReceiptMixin:
def _is_travel_review_context(
self,
payload: UserAgentRequest,
document_cards: list[UserAgentReviewDocumentCard],
claim_groups: list[UserAgentReviewClaimGroup],
) -> bool:
entity_expense_type = self._collect_entity_values(payload).get("expense_type_code", "")
review_form_values = self._resolve_review_form_values(payload)
form_expense_type = str(review_form_values.get("expense_type") or "").strip()
message_context = " ".join(
[
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
str(payload.context_json.get("expense_type") or ""),
form_expense_type,
]
)
if entity_expense_type in {"travel", "hotel", "transport"}:
return True
if any(group.group_code == "travel" or group.expense_type in {"travel", "hotel", "transport"} for group in claim_groups):
return True
if any(card.suggested_expense_type in {"travel", "hotel", "transport"} for card in document_cards):
return True
return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿"))
def _build_travel_receipt_state(
self,
payload: UserAgentRequest,
*,
document_cards: list[UserAgentReviewDocumentCard],
claim_groups: list[UserAgentReviewClaimGroup],
) -> dict[str, Any]:
empty_state: dict[str, Any] = {
"is_travel_context": False,
"has_long_distance_ticket": False,
"ticket_type_label": "",
"ticket_amount": Decimal("0.00"),
"destination": "",
"days": 1,
"has_hotel_invoice": False,
"has_local_transport": False,
"required_missing_labels": [],
"optional_missing_labels": [],
"blocks_next_step": False,
}
if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups):
return empty_state
long_distance_cards = [card for card in document_cards if self._is_long_distance_travel_card(card)]
if not long_distance_cards:
return {
**empty_state,
"is_travel_context": True,
}
has_hotel_invoice = any(self._is_review_hotel_card(card) for card in document_cards)
required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"]
ticket_amount = sum(
(self._extract_amount_decimal_from_card(card) or Decimal("0.00"))
for card in long_distance_cards
).quantize(Decimal("0.01"))
return {
**empty_state,
"is_travel_context": True,
"has_long_distance_ticket": True,
"ticket_type_label": self._resolve_travel_ticket_type_label(long_distance_cards),
"ticket_amount": ticket_amount,
"destination": self._resolve_travel_receipt_destination(payload, long_distance_cards),
"days": self._resolve_travel_receipt_days(payload, long_distance_cards),
"has_hotel_invoice": has_hotel_invoice,
"has_local_transport": any(self._is_local_transport_receipt_card(card) for card in document_cards),
"required_missing_labels": required_missing_labels,
"optional_missing_labels": [],
"blocks_next_step": bool(required_missing_labels),
}
@staticmethod
def _is_long_distance_travel_card(card: UserAgentReviewDocumentCard) -> bool:
document_type = str(card.document_type or "").strip().lower()
return document_type in {"train_ticket", "flight_itinerary"}
@staticmethod
def _is_local_transport_receipt_card(card: UserAgentReviewDocumentCard) -> bool:
document_type = str(card.document_type or "").strip().lower()
suggested_type = str(card.suggested_expense_type or "").strip().lower()
return document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"} or (
suggested_type == "transport" and document_type not in {"train_ticket", "flight_itinerary"}
)
@staticmethod
def _resolve_travel_ticket_type_label(cards: list[UserAgentReviewDocumentCard]) -> str:
labels: list[str] = []
for card in cards:
document_type = str(card.document_type or "").strip().lower()
if document_type == "train_ticket" and "火车" not in labels:
labels.append("火车")
if document_type == "flight_itinerary" and "飞机" not in labels:
labels.append("飞机")
return "/".join(labels) if labels else "交通"
def _resolve_travel_receipt_destination(
self,
payload: UserAgentRequest,
long_distance_cards: list[UserAgentReviewDocumentCard],
) -> str:
for card in long_distance_cards:
for field in card.fields:
if str(field.label or "").strip() not in {"行程", "路线"}:
continue
destination = self._extract_travel_destination_from_route(field.value)
if destination:
return self._normalize_travel_destination(destination)
card_text = self._build_review_document_card_text(card)
route_match = TRAVEL_ROUTE_PATTERN.search(card_text)
if route_match:
return self._normalize_travel_destination(route_match.group(2))
location = self._resolve_location_value(payload)
if location:
return self._normalize_travel_destination(location)
return ""
@staticmethod
def _extract_travel_destination_from_route(value: str) -> str:
route_text = str(value or "").strip()
if not route_text:
return ""
route_match = TRAVEL_ROUTE_PATTERN.search(route_text)
if route_match:
return route_match.group(2).strip()
parts = [
item.strip()
for item in re.split(r"\s*(?:至|到|→|->|-|—|~|)\s*", route_text)
if item.strip()
]
return parts[-1] if len(parts) >= 2 else ""
def _normalize_travel_destination(self, value: str) -> str:
candidate = re.sub(
r"(?:火车站|高铁站|动车站|车站|站|机场|航站楼)$",
"",
str(value or "").strip(),
)
if not candidate:
return ""
try:
policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy
except Exception:
policy = None
if policy is not None:
policy_city = self._extract_policy_city_from_text(candidate, policy)
if policy_city:
return policy_city
return candidate
def _resolve_travel_receipt_days(
self,
payload: UserAgentRequest,
long_distance_cards: list[UserAgentReviewDocumentCard],
) -> int:
dates: list[datetime] = []
for card in long_distance_cards:
card_text = self._build_review_document_card_text(card)
dates.extend(self._extract_dates_from_text(card_text))
if dates:
return max(1, (max(dates).date() - min(dates).date()).days + 1)
start_date = self._parse_date_text(payload.ontology.time_range.start_date or "")
end_date = self._parse_date_text(payload.ontology.time_range.end_date or "")
if start_date and end_date:
return max(1, (end_date.date() - start_date.date()).days + 1)
return 1
@staticmethod
def _extract_dates_from_text(text: str) -> list[datetime]:
dates: list[datetime] = []
for match in DATE_TEXT_PATTERN.finditer(str(text or "")):
parsed = UserAgentReviewTravelReceiptMixin._parse_date_text(match.group(1))
if parsed is not None:
dates.append(parsed)
return dates
@staticmethod
def _parse_date_text(value: str) -> datetime | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
normalized = (
raw_value.replace("", "-")
.replace("", "-")
.replace("/", "-")
.replace("", "")
.strip()
)
parts = [part for part in normalized.split("-") if part]
if len(parts) != 3:
return None
try:
year, month, day = (int(part) for part in parts)
return datetime(year, month, day)
except ValueError:
return None
def _build_travel_receipt_briefs(
self,
travel_receipt_state: dict[str, Any],
) -> list[UserAgentReviewRiskBrief]:
if not travel_receipt_state.get("has_long_distance_ticket"):
return []
required_labels = [
str(item).strip()
for item in travel_receipt_state.get("required_missing_labels", [])
if str(item).strip()
]
if not required_labels:
return []
required_text = "".join(required_labels)
return [
UserAgentReviewRiskBrief(
title="差旅票据待补充",
level="warning",
content=required_text,
detail=(
"系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。"
+ f"当前必须补充:{required_text}"
),
suggestion="请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。",
)
]
def _resolve_review_travel_allowance_standard(
self,
policy: RuntimeTravelPolicy,
*,
declared_city: str,
card_text: str,
) -> tuple[str, Decimal] | None:
meal_limits = getattr(policy, "allowance_limits", {}).get("meal", {})
if not meal_limits:
return None
region_label = self._resolve_review_travel_allowance_region(
" ".join([declared_city or "", card_text or ""])
)
amount = meal_limits.get(region_label)
if amount is None and region_label != "其他地区":
amount = meal_limits.get("其他地区")
region_label = "其他地区"
if amount is None:
return None
return region_label, Decimal(amount).quantize(Decimal("0.01"))
@staticmethod
def _resolve_review_travel_allowance_region(text: str) -> str:
normalized = re.sub(r"\s+", "", str(text or ""))
if not normalized:
return "其他地区"
if any(keyword in normalized for keyword in ("境外", "国外", "海外")):
return "国外"
if any(keyword in normalized for keyword in ("香港", "澳门", "台湾", "港澳台")):
return "港澳台"
if "乌鲁木齐" in normalized:
return "新疆-乌鲁木齐"
if "新疆" in normalized:
return "新疆-其他"
if any(keyword in normalized for keyword in ("西藏", "拉萨")):
return "西藏"
if any(keyword in normalized for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
return "直辖市/特区"
return "其他地区"
def _resolve_review_amount_scene_code(
self,
card: UserAgentReviewDocumentCard,
payload: UserAgentRequest,
) -> str:
document_type = str(card.document_type or "").strip().lower()
suggested_type = str(card.suggested_expense_type or "").strip().lower()
if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}:
return "transport"
if document_type == "meal_receipt":
entity_values = self._collect_entity_values(payload)
if suggested_type == "entertainment" or entity_values.get("expense_type_code") == "entertainment":
return "entertainment"
return "meal"
if document_type == "hotel_invoice" or suggested_type == "hotel":
return "hotel"
if suggested_type in {
"travel",
"transport",
"meal",
"entertainment",
"office",
"meeting",
"training",
"communication",
"welfare",
"other",
}:
return suggested_type
return self._collect_entity_values(payload).get("expense_type_code") or "other"
@staticmethod
def _resolve_review_scene_amount_limit(scene_policy: Any | None) -> Any | None:
if scene_policy is None:
return None
return getattr(scene_policy, "item_amount_limit", None) or getattr(scene_policy, "claim_amount_limit", None)
@staticmethod
def _resolve_scene_standard_amount(limit_config: Any | None) -> Decimal | None:
if limit_config is None:
return None
warn_amount = getattr(limit_config, "warn_amount", None)
block_amount = getattr(limit_config, "block_amount", None)
amount = warn_amount if warn_amount is not None else block_amount
if amount is None:
return None
try:
return Decimal(amount).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
@staticmethod
def _evaluate_review_scene_amount(
*,
amount: Decimal,
limit_config: Any,
reason_text: str,
) -> tuple[str, Decimal] | None:
block_amount = getattr(limit_config, "block_amount", None)
warn_amount = getattr(limit_config, "warn_amount", None)
exception_keywords = list(getattr(limit_config, "exception_keywords", []) or [])
has_exception = UserAgentReviewTravelReceiptMixin._text_contains_any(reason_text, exception_keywords)
if block_amount is not None and amount > Decimal(block_amount):
return ("high", Decimal(block_amount).quantize(Decimal("0.01")))
if warn_amount is not None and amount > Decimal(warn_amount):
return ("high", Decimal(warn_amount).quantize(Decimal("0.01")))
return None
def _resolve_review_employee_grade(self, payload: UserAgentRequest, *, employee: Employee | None) -> str:
if employee is not None and employee.grade:
return str(employee.grade).strip()
review_form_values = self._resolve_review_form_values(payload)
for source in (
review_form_values,
payload.context_json,
payload.tool_payload,
):
for key in ("employee_grade", "grade", "user_grade", "position_grade"):
value = str(source.get(key) or "").strip() if isinstance(source, dict) else ""
if value:
return value
return ""
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str:
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
parts = [
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
str(review_form_values.get("reason") or ""),
str(review_form_values.get("location") or ""),
]
return "\n".join(part.strip() for part in parts if part and part.strip())
def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str:
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
candidates = [
str(review_form_values.get("location") or ""),
self._resolve_location_value(payload),
str(payload.message or ""),
]
for candidate in candidates:
city = self._extract_policy_city_from_text(candidate, policy)
if city:
return city
return ""
@staticmethod
def _build_review_document_card_text(card: UserAgentReviewDocumentCard) -> str:
field_text = " ".join(f"{field.label}:{field.value}" for field in card.fields)
return " ".join(
[
str(card.filename or ""),
str(card.document_type or ""),
str(card.scene_label or ""),
str(card.summary or ""),
field_text,
]
).strip()
@staticmethod
def _is_review_hotel_card(card: UserAgentReviewDocumentCard) -> bool:
document_type = str(card.document_type or "").strip().lower()
suggested_type = str(card.suggested_expense_type or "").strip().lower()
scene_label = str(card.scene_label or "").strip()
return document_type == "hotel_invoice" or suggested_type == "hotel" or "住宿" in scene_label
@staticmethod
def _extract_amount_decimal_from_card(card: UserAgentReviewDocumentCard) -> Decimal | None:
for field in card.fields:
if field.label != "金额":
continue
normalized = str(field.value or "").replace("", "").replace("", "").replace("¥", "").replace(",", "").strip()
try:
amount = Decimal(normalized).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
continue
if amount > Decimal("0.00"):
return amount
return None
@staticmethod
def _extract_review_hotel_night_count(card: UserAgentReviewDocumentCard) -> int:
text = f"{card.summary or ''} {' '.join(f'{field.label}:{field.value}' for field in card.fields)}"
match = TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN.search(text)
if not match:
return 1
try:
return max(1, int(match.group(1)))
except (TypeError, ValueError):
return 1
@staticmethod
def _extract_policy_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(text or "").strip()
if not normalized:
return ""
city_names = set(policy.city_tiers.keys())
city_names.update(getattr(policy, "hotel_city_limits", {}).keys())
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
if city in normalized:
return city
return ""
@staticmethod
def _format_travel_city_tier(city_tier: str) -> str:
return {
"tier_1": "一线城市",
"tier_2": "重点城市",
"tier_3": "其他城市",
}.get(str(city_tier or "").strip(), "当前城市")
@staticmethod
def _resolve_review_hotel_cap(
policy: RuntimeTravelPolicy,
*,
grade_band: str,
city: str,
city_tier: str,
) -> Decimal:
normalized_city = str(city or "").strip()
if normalized_city and getattr(policy, "hotel_city_limits", None):
city_limits = policy.hotel_city_limits.get(normalized_city, {})
city_cap = city_limits.get(grade_band)
if city_cap is not None:
return Decimal(city_cap).quantize(Decimal("0.01"))
return Decimal(policy.hotel_limits.get(grade_band, {}).get(city_tier, Decimal("0.00"))).quantize(
Decimal("0.01")
)
def _detect_review_transport_class(
self,
card: UserAgentReviewDocumentCard,
policy: RuntimeTravelPolicy,
) -> tuple[str, str, int] | None:
document_type = str(card.document_type or "").strip().lower()
text = re.sub(r"\s+", "", self._build_review_document_card_text(card))
if not text:
return None
if document_type == "flight_itinerary" or any(keyword in text for keyword in ("机票", "航班", "登机牌")):
for config in policy.flight_classes:
label = str(config.keyword or "").strip()
if label and label in text:
return "flight", label, int(config.level)
if document_type == "train_ticket" or any(keyword in text for keyword in ("火车", "高铁", "动车", "铁路")):
for config in policy.train_classes:
label = str(config.keyword or "").strip()
if label and label in text:
return "train", label, int(config.level)
return None
@staticmethod
def _text_contains_any(text: str, keywords: list[str] | tuple[str, ...]) -> bool:
compact = re.sub(r"\s+", "", str(text or ""))
return bool(compact) and any(str(keyword or "").strip() and str(keyword).strip() in compact for keyword in keywords)
@staticmethod
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
raw_reasons = payload.tool_payload.get("submission_blocked_reasons")
submission_blocked = bool(payload.tool_payload.get("submission_blocked"))
if raw_reasons is None and submission_blocked:
raw_reasons = payload.tool_payload.get("missing_fields")
if raw_reasons is None and not submission_blocked:
return []
reasons: list[str] = []
if isinstance(raw_reasons, list):
reasons.extend(str(item or "").strip() for item in raw_reasons)
elif isinstance(raw_reasons, str):
reasons.extend(
item.strip()
for item in re.split(r"[;\n]+", raw_reasons)
if item.strip()
)
if not reasons and submission_blocked:
message = str(payload.tool_payload.get("message") or "").strip()
for prefix in (
"提交前请先补全信息:",
"自动检测暂未通过,原因如下:",
"自动检测未通过,原因如下:",
"自动检测暂未通过:",
"自动检测未通过:",
"AI预审暂未通过原因如下",
"AI预审未通过原因如下",
"AI预审暂未通过",
"AI预审未通过",
):
if message.startswith(prefix):
message = message[len(prefix):].strip()
break
if message:
reasons.extend(
item.strip()
for item in re.split(r"[;\n]+", message)
if item.strip()
and not item.strip().startswith("AI预审暂未通过")
and not item.strip().startswith("自动检测暂未通过")
)
return list(dict.fromkeys(reason for reason in reasons if reason))