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