2026-05-22 10:42:31 +08:00
|
|
|
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"
|
2026-05-27 17:31:27 +08:00
|
|
|
if "预算" in normalized:
|
|
|
|
|
return "budget"
|
2026-05-22 10:42:31 +08:00
|
|
|
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()
|