Files
X-Financial/server/src/app/services/expense_claim_read_model.py

270 lines
10 KiB
Python
Raw Normal View History

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()