2026-05-22 10:42:31 +08:00
|
|
|
|
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
|
2026-06-03 15:46:56 +08:00
|
|
|
|
from app.services.ontology_field_registry import normalize_ontology_form_values
|
2026-05-22 10:42:31 +08:00
|
|
|
|
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,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
"has_local_transport": any(self._is_local_transport_receipt_card(card) for card in document_cards),
|
2026-05-22 10:42:31 +08:00
|
|
|
|
"required_missing_labels": required_missing_labels,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
"optional_missing_labels": [],
|
2026-05-22 10:42:31 +08:00
|
|
|
|
"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()
|
|
|
|
|
|
]
|
2026-06-02 14:01:51 +08:00
|
|
|
|
if not required_labels:
|
2026-05-22 10:42:31 +08:00
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
required_text = ";".join(required_labels)
|
|
|
|
|
|
return [
|
|
|
|
|
|
UserAgentReviewRiskBrief(
|
|
|
|
|
|
title="差旅票据待补充",
|
2026-06-02 14:01:51 +08:00
|
|
|
|
level="warning",
|
|
|
|
|
|
content=required_text,
|
2026-05-22 10:42:31 +08:00
|
|
|
|
detail=(
|
|
|
|
|
|
"系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。"
|
2026-06-02 14:01:51 +08:00
|
|
|
|
+ f"当前必须补充:{required_text}。"
|
2026-05-22 10:42:31 +08:00
|
|
|
|
),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
suggestion="请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。",
|
2026-05-22 10:42:31 +08:00
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-06-03 15:46:56 +08:00
|
|
|
|
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
|
2026-05-22 10:42:31 +08:00
|
|
|
|
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:
|
2026-06-03 15:46:56 +08:00
|
|
|
|
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
|
2026-05-22 10:42:31 +08:00
|
|
|
|
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 (
|
|
|
|
|
|
"提交前请先补全信息:",
|
2026-06-02 14:01:51 +08:00
|
|
|
|
"自动检测暂未通过,原因如下:",
|
|
|
|
|
|
"自动检测未通过,原因如下:",
|
|
|
|
|
|
"自动检测暂未通过:",
|
|
|
|
|
|
"自动检测未通过:",
|
2026-05-22 10:42:31 +08:00
|
|
|
|
"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)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
if item.strip()
|
|
|
|
|
|
and not item.strip().startswith("AI预审暂未通过")
|
|
|
|
|
|
and not item.strip().startswith("自动检测暂未通过")
|
2026-05-22 10:42:31 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return list(dict.fromkeys(reason for reason in reasons if reason))
|
|
|
|
|
|
|