refactor(server): split oversized backend services
This commit is contained in:
392
server/src/app/services/expense_claim_ontology_resolvers.py
Normal file
392
server/src/app/services/expense_claim_ontology_resolvers.py
Normal file
@@ -0,0 +1,392 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimUpdate,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.document_intelligence import build_document_insight
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_constants import (
|
||||
EXPENSE_TYPE_LABELS,
|
||||
MAX_DRAFT_CLAIMS_PER_USER,
|
||||
EDITABLE_CLAIM_STATUSES,
|
||||
SYSTEM_GENERATED_ITEM_TYPES,
|
||||
TRAVEL_DETAIL_ITEM_TYPES,
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
DOCUMENT_TYPE_ITEM_TYPE_MAP,
|
||||
DOCUMENT_TYPE_SCENE_MAP,
|
||||
DOCUMENT_FACT_ITEM_TYPES,
|
||||
ROUTE_DESCRIPTION_ITEM_TYPES,
|
||||
DOCUMENT_TRIP_DATE_LABELS,
|
||||
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS,
|
||||
DOCUMENT_TRIP_DATE_KEYS,
|
||||
DOCUMENT_GENERIC_DATE_KEYS,
|
||||
DOCUMENT_INVOICE_DATE_KEYS,
|
||||
DOCUMENT_TRIP_DATE_LABEL_TOKENS,
|
||||
DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
|
||||
DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
|
||||
DOCUMENT_ROUTE_FORMAT_PATTERN,
|
||||
DOCUMENT_ROUTE_TEXT_PATTERN,
|
||||
DOCUMENT_ROUTE_ORIGIN_LABELS,
|
||||
DOCUMENT_ROUTE_DESTINATION_LABELS,
|
||||
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
EXPENSE_SCENE_KEYWORDS,
|
||||
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES,
|
||||
DOCUMENT_SCENE_LABELS,
|
||||
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
|
||||
PERSISTENT_EXPENSE_REVIEW_ACTIONS,
|
||||
RETURN_REASON_OPTIONS,
|
||||
MAX_CLAIM_NO_RETRY_ATTEMPTS,
|
||||
DOCUMENT_DATE_PATTERN,
|
||||
SYSTEM_GENERATED_REASON_PREFIXES,
|
||||
LEADING_REASON_TIME_PATTERNS,
|
||||
AI_REVIEW_LOOKBACK_DAYS,
|
||||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
||||
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
|
||||
TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES,
|
||||
TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES,
|
||||
TRAVEL_POLICY_CITY_TIERS,
|
||||
TRAVEL_POLICY_CITY_MATCH_ORDER,
|
||||
TRAVEL_POLICY_BAND_LABELS,
|
||||
TRAVEL_POLICY_HOTEL_LIMITS,
|
||||
TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS,
|
||||
TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS,
|
||||
TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS,
|
||||
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
|
||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||
from app.services.expense_amounts import (
|
||||
extract_amount_candidates,
|
||||
format_decimal_amount,
|
||||
is_amount_match_date_fragment,
|
||||
is_date_like_amount_candidate,
|
||||
is_probable_year_amount,
|
||||
parse_document_amount_value,
|
||||
parse_plain_document_amount_value,
|
||||
resolve_document_field_amount,
|
||||
resolve_document_item_amount,
|
||||
resolve_document_text_amount,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
DEFAULT_SCENE_RULE_ASSET_CODE,
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
build_default_expense_rule_catalog,
|
||||
resolve_document_type_label,
|
||||
)
|
||||
from app.services.ocr import OcrService
|
||||
|
||||
|
||||
class ExpenseClaimOntologyResolverMixin:
|
||||
def _resolve_employee(
|
||||
self,
|
||||
*,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
user_id: str | None,
|
||||
) -> Employee | None:
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
if normalized_user_id:
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
||||
.where(func.lower(Employee.email) == normalized_user_id.lower())
|
||||
.limit(1)
|
||||
)
|
||||
employee = self.db.scalar(stmt)
|
||||
if employee is not None:
|
||||
return employee
|
||||
|
||||
employee_name = self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=None,
|
||||
)
|
||||
if not employee_name:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
||||
.where(Employee.name == employee_name)
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_employee_name(
|
||||
*,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
user_id: str | None,
|
||||
fallback: str = "待补充",
|
||||
) -> str:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
for key in ("reporter_name", "employee_name", "claimant_name"):
|
||||
value = str(review_form_values.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
for item in ontology.entities:
|
||||
if item.type == "employee" and item.value.strip():
|
||||
return item.value.strip()
|
||||
for key in ("name", "user_name", "employee_name"):
|
||||
value = str(context_json.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return str(user_id or fallback).strip() or fallback
|
||||
|
||||
@staticmethod
|
||||
def _resolve_department_name(
|
||||
*,
|
||||
employee: Employee | None,
|
||||
context_json: dict[str, Any],
|
||||
fallback: str = "待补充",
|
||||
) -> str:
|
||||
if employee is not None and employee.organization_unit is not None:
|
||||
return employee.organization_unit.name
|
||||
|
||||
request_context = context_json.get("request_context")
|
||||
if isinstance(request_context, dict):
|
||||
for key in ("department", "department_name", "deptName"):
|
||||
value = str(request_context.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
|
||||
for key in ("department_name", "department"):
|
||||
value = str(context_json.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _resolve_project_code(entities: list[OntologyEntity]) -> str | None:
|
||||
for item in entities:
|
||||
if item.type == "project" and item.normalized_value.strip():
|
||||
return item.normalized_value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
compact = str(
|
||||
review_form_values.get("expense_type")
|
||||
or review_form_values.get("reimbursement_type")
|
||||
or ""
|
||||
).replace(" ", "")
|
||||
if compact:
|
||||
if "招待" in compact or ("客户" in compact and any(word in compact for word in ("吃饭", "宴请", "请客", "用餐"))):
|
||||
return "entertainment"
|
||||
if any(word in compact for word in ("差旅", "出差", "机票", "行程")):
|
||||
return "travel"
|
||||
if any(word in compact for word in ("住宿", "酒店", "宾馆")):
|
||||
return "hotel"
|
||||
if any(word in compact for word in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
|
||||
return "transport"
|
||||
if any(word in compact for word in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
|
||||
return "meal"
|
||||
if "会务" in compact:
|
||||
return "meeting"
|
||||
if any(word in compact for word in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")):
|
||||
return "office"
|
||||
if any(word in compact for word in ("培训费", "培训", "讲师费", "课时费", "课程费")):
|
||||
return "training"
|
||||
if any(word in compact for word in ("通讯费", "话费", "流量费", "宽带费")):
|
||||
return "communication"
|
||||
if any(word in compact for word in ("福利费", "团建", "慰问", "节日福利", "体检费")):
|
||||
return "welfare"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_expense_type(
|
||||
entities: list[OntologyEntity],
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> str | None:
|
||||
explicit_expense_type = ExpenseClaimOntologyResolverMixin._resolve_explicit_review_expense_type(context_json)
|
||||
if explicit_expense_type:
|
||||
return explicit_expense_type
|
||||
for item in entities:
|
||||
if item.type == "expense_type":
|
||||
normalized = item.normalized_value.strip()
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_reason(
|
||||
*,
|
||||
message: str,
|
||||
context_json: dict[str, Any],
|
||||
allow_message_fallback: bool,
|
||||
) -> str | None:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
for key in ("reason", "business_reason"):
|
||||
value = str(review_form_values.get(key) or "").strip()
|
||||
if value:
|
||||
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
|
||||
|
||||
explicit_text = context_json.get("user_input_text")
|
||||
if isinstance(explicit_text, str):
|
||||
normalized_explicit_text = explicit_text.strip()
|
||||
if normalized_explicit_text:
|
||||
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(normalized_explicit_text)[:500] or None
|
||||
return None
|
||||
|
||||
request_context = context_json.get("request_context")
|
||||
if (
|
||||
isinstance(request_context, dict)
|
||||
and str(context_json.get("entry_source") or "").strip() == "detail"
|
||||
):
|
||||
for key in ("reason", "title"):
|
||||
value = str(request_context.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
if not allow_message_fallback:
|
||||
return None
|
||||
|
||||
normalized_message = str(message or "").strip()
|
||||
compact_message = re.sub(r"\s+", "", normalized_message)
|
||||
if compact_message.startswith(SYSTEM_GENERATED_REASON_PREFIXES):
|
||||
return None
|
||||
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(normalized_message)[:500] or None
|
||||
|
||||
@staticmethod
|
||||
def _strip_leading_time_from_reason(value: str) -> str:
|
||||
reason = str(value or "").strip()
|
||||
for pattern in LEADING_REASON_TIME_PATTERNS:
|
||||
next_reason = pattern.sub("", reason).strip()
|
||||
if next_reason != reason:
|
||||
return next_reason
|
||||
return reason
|
||||
|
||||
@staticmethod
|
||||
def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
for key in ("business_location", "location"):
|
||||
value = str(review_form_values.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
|
||||
request_context = context_json.get("request_context")
|
||||
if (
|
||||
isinstance(request_context, dict)
|
||||
and str(context_json.get("entry_source") or "").strip() == "detail"
|
||||
):
|
||||
for key in ("city", "location"):
|
||||
value = str(request_context.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
compact = str(message or "").replace(" ", "")
|
||||
city_match = re.search(
|
||||
r"去(?P<city>[\u4e00-\u9fa5]{2,8}?)(?:出差|拜访|参会|见客户|客户现场|支撑|支持|部署|实施|处理|协助)",
|
||||
compact,
|
||||
)
|
||||
if city_match:
|
||||
return city_match.group("city").strip()
|
||||
if "客户现场" in compact:
|
||||
return "客户现场"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_occurred_at(
|
||||
ontology: OntologyParseResult,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> datetime | None:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
for key in ("occurred_date", "time_range", "business_time"):
|
||||
value = str(review_form_values.get(key) or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = date.fromisoformat(value)
|
||||
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
start_date = ontology.time_range.start_date
|
||||
if start_date:
|
||||
try:
|
||||
parsed = date.fromisoformat(start_date)
|
||||
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_amount(
|
||||
entities: list[OntologyEntity],
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> Decimal | None:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
raw_value = str(review_form_values.get("amount") or "").strip()
|
||||
if raw_value:
|
||||
compact = raw_value.replace("元", "").replace(",", "").strip()
|
||||
try:
|
||||
return Decimal(compact).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
pass
|
||||
for item in entities:
|
||||
if item.type != "amount" or item.role == "threshold":
|
||||
continue
|
||||
try:
|
||||
return Decimal(item.normalized_value).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_attachment_names(context_json: dict[str, Any]) -> list[str]:
|
||||
names = context_json.get("attachment_names")
|
||||
if not isinstance(names, list):
|
||||
return []
|
||||
return [str(name).strip() for name in names if str(name).strip()]
|
||||
|
||||
def _resolve_attachment_count(self, context_json: dict[str, Any]) -> int:
|
||||
names = self._resolve_attachment_names(context_json)
|
||||
if names:
|
||||
return len(names)
|
||||
try:
|
||||
return max(0, int(context_json.get("attachment_count") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
Reference in New Issue
Block a user