refactor(server): split oversized backend services
This commit is contained in:
269
server/src/app/services/expense_claim_read_model.py
Normal file
269
server/src/app/services/expense_claim_read_model.py
Normal file
@@ -0,0 +1,269 @@
|
||||
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 ExpenseClaimReadModelMixin:
|
||||
@staticmethod
|
||||
def _serialize_claim(claim: ExpenseClaim) -> dict[str, Any]:
|
||||
return {
|
||||
"id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"employee_name": claim.employee_name,
|
||||
"department_name": claim.department_name,
|
||||
"project_code": claim.project_code,
|
||||
"expense_type": claim.expense_type,
|
||||
"reason": claim.reason,
|
||||
"location": claim.location,
|
||||
"amount": float(claim.amount),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
"status": claim.status,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"risk_flags_json": list(claim.risk_flags_json or []),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _collect_return_flags(risk_flags: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(risk_flags, list):
|
||||
return []
|
||||
|
||||
return [
|
||||
flag
|
||||
for flag in risk_flags
|
||||
if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_return_reason_codes(reason_codes: list[str] | None) -> list[str]:
|
||||
return ExpenseClaimReadModelMixin._normalize_return_reason_code_payload(reason_codes)["reason_codes"]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_return_reason_code_payload(reason_codes: list[str] | None) -> dict[str, list[str]]:
|
||||
normalized_codes: list[str] = []
|
||||
unknown_codes: list[str] = []
|
||||
for item in reason_codes or []:
|
||||
code = str(item or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
if code in RETURN_REASON_OPTIONS and code not in normalized_codes:
|
||||
normalized_codes.append(code)
|
||||
elif code not in RETURN_REASON_OPTIONS and code not in unknown_codes:
|
||||
unknown_codes.append(code)
|
||||
return {
|
||||
"reason_codes": normalized_codes,
|
||||
"unknown_reason_codes": unknown_codes,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _merge_persistent_claim_risk_flags(*, existing_flags: list[Any], next_flags: list[Any]) -> list[Any]:
|
||||
if not next_flags:
|
||||
return list(existing_flags or [])
|
||||
|
||||
merged_flags = list(next_flags or [])
|
||||
next_return_markers = {
|
||||
ExpenseClaimReadModelMixin._build_return_flag_marker(flag)
|
||||
for flag in merged_flags
|
||||
if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"
|
||||
}
|
||||
for flag in list(existing_flags or []):
|
||||
if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"):
|
||||
continue
|
||||
marker = ExpenseClaimReadModelMixin._build_return_flag_marker(flag)
|
||||
if marker in next_return_markers:
|
||||
continue
|
||||
merged_flags.append(flag)
|
||||
next_return_markers.add(marker)
|
||||
return merged_flags
|
||||
|
||||
@staticmethod
|
||||
def _build_return_flag_marker(flag: dict[str, Any]) -> tuple[str, str, str]:
|
||||
event_id = str(flag.get("return_event_id") or "").strip()
|
||||
if event_id:
|
||||
return ("event_id", event_id, "")
|
||||
return (
|
||||
str(flag.get("return_count") or "").strip(),
|
||||
str(flag.get("created_at") or "").strip(),
|
||||
str(flag.get("message") or flag.get("reason") or "").strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_default_return_message(*, operator: str, risk_points: list[str]) -> str:
|
||||
if risk_points:
|
||||
return f"{operator} 退回该报销单:{'、'.join(risk_points)}。请申请人调整后重新提交。"
|
||||
return f"{operator} 已退回该报销单,请申请人调整后重新提交。"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_return_stage_key(stage: str | None) -> str:
|
||||
normalized = str(stage or "").strip()
|
||||
if "直属" in normalized or "领导" in normalized or "负责人" in normalized:
|
||||
return "direct_manager"
|
||||
if "财务" in normalized:
|
||||
return "finance"
|
||||
if "AI" in normalized or "预审" in normalized:
|
||||
return "ai_review"
|
||||
if "归档" in normalized or "入账" in normalized:
|
||||
return "archive"
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _is_editable_claim_status(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in EDITABLE_CLAIM_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: str | None, *, fallback: str = "", allow_empty: bool = False) -> str | None:
|
||||
normalized = str(value or "").strip()
|
||||
if normalized:
|
||||
return normalized
|
||||
if allow_empty:
|
||||
return None
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _normalize_sort_datetime(value: datetime | None) -> datetime:
|
||||
if value is None:
|
||||
return datetime.max.replace(tzinfo=UTC)
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _is_missing_value(value: Any) -> bool:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return True
|
||||
compact = text.replace(" ", "")
|
||||
return compact in {"待补充", "暂无", "无", "未知", "处理中"}
|
||||
|
||||
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
|
||||
if not self._is_editable_claim_status(claim.status):
|
||||
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None:
|
||||
status = str(claim.status or "").strip().lower()
|
||||
if status != "draft":
|
||||
raise ValueError("只有草稿待提交状态的报销单才允许编辑附加说明。")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_mutable_claim_item(item: ExpenseClaimItem) -> None:
|
||||
if str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES:
|
||||
raise ValueError("系统自动计算的费用明细不可手动修改。")
|
||||
|
||||
def _delete_claim_assistant_sessions(self, claim_id: str | None) -> None:
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
|
||||
AgentConversationService(self.db).delete_conversations_for_draft_claim(
|
||||
claim_id=claim_id,
|
||||
source="user_message",
|
||||
session_type="expense",
|
||||
)
|
||||
|
||||
def _ensure_ready(self) -> None:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
Reference in New Issue
Block a user