refactor(server): split oversized backend services

This commit is contained in:
caoxiaozhu
2026-05-22 10:42:31 +08:00
parent 2e57702638
commit 222ba0bfdc
84 changed files with 26263 additions and 21898 deletions

View File

@@ -0,0 +1,625 @@
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.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)
has_local_transport = any(self._is_local_transport_receipt_card(card) for card in document_cards)
required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"]
optional_missing_labels = [] if has_local_transport 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": has_local_transport,
"required_missing_labels": required_missing_labels,
"optional_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()
]
optional_labels = [
str(item).strip()
for item in travel_receipt_state.get("optional_missing_labels", [])
if str(item).strip()
]
if not required_labels and not optional_labels:
return []
content_parts = [*required_labels, *optional_labels]
required_text = "".join(required_labels)
optional_text = "".join(optional_labels)
return [
UserAgentReviewRiskBrief(
title="差旅票据待补充",
level="warning" if required_labels else "info",
content="".join(content_parts),
detail=(
"系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。"
+ (f"当前必须补充:{required_text}" if required_text else "")
+ (f"当前还可以补充:{optional_text}" if optional_text else "")
),
suggestion=(
"请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。"
if required_labels
else "如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传;没有也可以进入下一步或保存草稿。"
),
)
]
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 = 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("business_reason") or ""),
str(review_form_values.get("location") or ""),
str(review_form_values.get("business_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 = self._resolve_review_form_values(payload)
candidates = [
str(review_form_values.get("business_location") or ""),
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预审暂未通过")
)
return list(dict.fromkeys(reason for reason in reasons if reason))