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.document_numbering import is_application_claim_no from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin 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 ExpenseClaimService( ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ): def __init__(self, db: Session) -> None: self.db = db self.audit_service = AuditLogService(db) self._access_policy = ExpenseClaimAccessPolicy(db) self._attachment_storage = ExpenseClaimAttachmentStorage() self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage) @staticmethod def _is_expense_application_claim(claim: ExpenseClaim) -> bool: claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper() expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower() document_type = str( getattr(claim, "document_type_code", "") or getattr(claim, "document_type", "") or "" ).strip().lower() return ( is_application_claim_no(claim_no) or expense_type == "application" or expense_type.endswith("_application") or document_type in {"application", "expense_application"} ) def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]: issues: list[str] = [] if self._is_missing_value(claim.employee_name): issues.append("申请人未完善") if self._is_missing_value(claim.department_name): issues.append("所属部门未完善") if self._is_missing_value(claim.expense_type): issues.append("申请类型未完善") if self._is_missing_value(claim.reason): issues.append("申请事由未完善") if self._is_missing_value(claim.location): issues.append("业务地点未完善") if claim.amount is None or claim.amount <= Decimal("0.00"): issues.append("预计总费用未完善") if claim.occurred_at is None: issues.append("申请时间未完善") return issues def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: stmt = ( select(ExpenseClaim) .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) ) stmt = self._access_policy.apply_claim_scope(stmt, current_user) return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all())) def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: stmt = ( select(ExpenseClaim) .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) ) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all())) def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: stmt = ( select(ExpenseClaim) .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) ) stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user) return list(self.db.scalars(stmt).all()) def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: stmt = ( select(ExpenseClaim) .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .where(ExpenseClaim.id == claim_id) ) stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt)) def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool: if claim is None: return self._access_policy.is_budget_manager_user(current_user) if current_user.is_admin: return True role_codes = self._access_policy.normalize_role_codes(current_user) if "executive" in role_codes: return True if self._access_policy.is_claim_owned_by_current_user(claim, current_user): return False return self._access_policy.is_department_p8_budget_monitor(current_user, claim) def update_claim( self, *, claim_id: str, payload: ExpenseClaimUpdate, current_user: CurrentUserContext, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None self._ensure_draft_pending_claim(claim) before_json = self._serialize_claim(claim) if payload.reason is not None: claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充" self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=current_user.name or current_user.username, action="expense_claim.update", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim def update_claim_item( self, *, claim_id: str, item_id: str, payload: ExpenseClaimItemUpdate, current_user: CurrentUserContext, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None self._ensure_draft_claim(claim) item = next((entry for entry in claim.items if entry.id == item_id), None) if item is None: raise LookupError("Item not found") self._ensure_mutable_claim_item(item) before_json = self._serialize_claim(claim) if payload.item_date is not None: item.item_date = payload.item_date if payload.item_type is not None: item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type if payload.item_reason is not None: item.item_reason = ( self._normalize_optional_text(payload.item_reason, allow_empty=True) or "" ) if payload.item_location is not None: item.item_location = ( self._normalize_optional_text(payload.item_location, allow_empty=True) or "" ) if payload.item_amount is not None: amount = payload.item_amount.quantize(Decimal("0.01")) if amount < Decimal("0.00"): raise ValueError("费用金额不能小于 0。") item.item_amount = amount if payload.invoice_id is not None: item.invoice_id = self._normalize_optional_text(payload.invoice_id, allow_empty=True) self._refresh_item_attachment_analysis(item) self._sync_claim_from_items(claim) self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=current_user.name or current_user.username, action="expense_claim.item_update", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim def create_claim_item( self, *, claim_id: str, payload: ExpenseClaimItemCreate | None, current_user: CurrentUserContext, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None self._ensure_draft_claim(claim) before_json = self._serialize_claim(claim) payload = payload or ExpenseClaimItemCreate() occurred_at = claim.occurred_at if claim.occurred_at is not None else datetime.now(UTC) item_amount = Decimal("0.00") if payload.item_amount is not None: item_amount = payload.item_amount.quantize(Decimal("0.01")) if item_amount < Decimal("0.00"): raise ValueError("费用金额不能小于 0。") item = ExpenseClaimItem( claim_id=claim.id, item_date=payload.item_date or occurred_at.date(), item_type=self._normalize_optional_text( payload.item_type, fallback=str(claim.expense_type or "").strip() or "other", ) or "other", item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "", item_location=self._normalize_optional_text(payload.item_location, fallback="") or "", item_amount=item_amount, invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True), ) claim.items.append(item) self.db.add(item) self._sync_claim_from_items(claim) self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=current_user.name or current_user.username, action="expense_claim.item_create", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim def delete_claim_item( self, *, claim_id: str, item_id: str, current_user: CurrentUserContext, ) -> dict[str, Any] | None: claim, item = self._get_claim_item_or_raise( claim_id=claim_id, item_id=item_id, current_user=current_user, ) if claim is None: return None self._ensure_draft_claim(claim) before_json = self._serialize_claim(claim) item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type) self._attachment_storage.delete_item_files(item) claim.items = [entry for entry in claim.items if entry.id != item.id] self.db.delete(item) self._sync_claim_from_items(claim) self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=current_user.name or current_user.username, action="expense_claim.item_delete", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return { "message": f"费用明细“{item_label}”已删除。", "claim_id": claim.id, "item_id": item.id, } def submit_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None self._ensure_draft_claim(claim) self._access_policy.backfill_claim_identity_from_current_user(claim, current_user) is_application_claim = self._is_expense_application_claim(claim) if not is_application_claim: self._sync_claim_from_items(claim) missing_fields = ( self._validate_application_claim_for_submission(claim) if is_application_claim else self._validate_claim_for_submission(claim) ) if missing_fields: raise ExpenseClaimSubmissionBlockedError(missing_fields) budget_flags = self._reserve_budget_for_submission( claim, current_user, is_application_claim=is_application_claim, ) before_json = self._serialize_claim(claim) if is_application_claim: submitted_at = datetime.now(UTC) preserved_flags = [ flag for flag in list(claim.risk_flags_json or []) if not ( isinstance(flag, dict) and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"} ) ] platform_review = self.evaluate_platform_risk_rules( claim, business_stage="expense_application", ) platform_flags = list(platform_review.get("flags") or []) submit_flag = with_risk_business_stage( { "source": "application_submission", "event_type": "expense_application_submission", "severity": "info", "label": "申请提交", "message": "费用申请已提交至直属领导审批,请等待审核结果。", "previous_status": str(claim.status or "").strip(), "previous_approval_stage": str(claim.approval_stage or "").strip(), "next_status": "submitted", "next_approval_stage": "直属领导审批", "created_at": submitted_at.isoformat(), }, "expense_application", ) claim.status = "submitted" claim.approval_stage = "直属领导审批" claim.risk_flags_json = self._append_budget_flags( [*preserved_flags, submit_flag, *platform_flags], budget_flags, business_stage="expense_application", ) claim.submitted_at = submitted_at else: claim.risk_flags_json = self._append_budget_flags( claim.risk_flags_json, budget_flags, business_stage="reimbursement", ) review_result = self._run_ai_submission_review(claim) claim.status = str(review_result.get("status") or "supplement") claim.approval_stage = str(review_result.get("approval_stage") or "待补充") claim.risk_flags_json = list(review_result.get("risk_flags") or []) claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=current_user.name or current_user.username, action="expense_claim.submit", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) if str(claim.status or "").strip().lower() == "submitted": self._delete_claim_assistant_sessions(claim.id) return claim def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None and ( current_user.is_admin or self._access_policy.has_archive_center_access(current_user) ): candidate_claim = self.db.scalar( select(ExpenseClaim) .options( selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) .where(ExpenseClaim.id == claim_id) ) if candidate_claim is not None and ( current_user.is_admin or self._access_policy.is_archived_claim(candidate_claim) ): claim = candidate_claim if claim is None: return None if self._access_policy.is_archived_claim(claim) and not current_user.is_admin: raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。") if not self._access_policy.has_claim_delete_access(current_user): self._ensure_draft_claim(claim) if not self._access_policy.is_claim_owned_by_current_user(claim, current_user): raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。") before_json = self._serialize_claim(claim) resource_id = claim.id self._release_budget_for_delete(claim, current_user) self._attachment_storage.delete_claim_files(claim) self.db.delete(claim) self.db.commit() self.audit_service.log_action( actor=current_user.name or current_user.username, action="expense_claim.delete", resource_type="expense_claim", resource_id=resource_id, before_json=before_json, after_json=None, ) self._delete_claim_assistant_sessions(resource_id) return claim def return_claim( self, claim_id: str, current_user: CurrentUserContext, *, reason: str | None = None, reason_codes: list[str] | None = None, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None normalized_status = str(claim.status or "").strip().lower() if normalized_status == "draft": raise ValueError("草稿状态无需退回。") if normalized_status == "returned": raise ValueError("该单据已处于退回待提交状态,无需重复退回。") if normalized_status in {"approved", "completed", "paid"}: raise ValueError("已完成单据不允许退回。") if not self._access_policy.can_return_claim(current_user, claim): raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") before_json = self._serialize_claim(claim) operator = self._access_policy.resolve_current_user_display_name(current_user) previous_status = str(claim.status or "").strip() previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" previous_stage_key = self._normalize_return_stage_key(previous_stage) is_application_claim = self._is_expense_application_claim(claim) is_direct_manager_return = previous_stage_key == "direct_manager" is_budget_return = previous_stage_key == "budget" is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return) return_event_type = ( "expense_application_return" if is_application_return else "expense_claim_return" ) return_label = ( "领导退回" if is_application_claim and is_direct_manager_return else "预算退回" if is_application_claim and is_budget_return else "人工退回" ) return_reason = str(reason or "").strip() reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) normalized_reason_codes = reason_code_payload["reason_codes"] unknown_reason_codes = reason_code_payload["unknown_reason_codes"] if is_application_return and not any( code.startswith("application_") for code in normalized_reason_codes ): raise ValueError("申请单退回必须选择至少一个退单类型。") risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes] existing_return_flags = self._collect_return_flags(claim.risk_flags_json) return_count = len(existing_return_flags) + 1 stage_return_count = ( sum( 1 for flag in existing_return_flags if ( str(flag.get("return_stage_key") or "").strip() or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip()) ) == previous_stage_key ) + 1 ) message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points) return_flag = { "source": "manual_return", "event_type": return_event_type, "return_event_id": str(uuid.uuid4()), "severity": "medium", "label": return_label, "node_key": "returned", "node_label": "退回", "approval_node": "退回", "message": message, "reason": return_reason, "opinion": message, "leader_opinion": message if is_application_claim and is_direct_manager_return else "", "budget_opinion": message if is_application_claim and is_budget_return else "", "reason_codes": normalized_reason_codes, "risk_points": risk_points, "operator": operator, "operator_username": current_user.username, "operator_role_codes": [ str(item).strip().lower() for item in current_user.role_codes if str(item).strip() ], "previous_status": previous_status, "previous_approval_stage": previous_stage, "return_stage": previous_stage, "return_stage_key": previous_stage_key, "next_status": "returned", "next_approval_stage": "待提交", "return_count": return_count, "stage_return_count": stage_return_count, "created_at": datetime.now(UTC).isoformat(), } if unknown_reason_codes: return_flag["unknown_reason_codes"] = unknown_reason_codes budget_flags = self._release_budget_for_return( claim, current_user, reason=message, ) claim.status = "returned" claim.approval_stage = "待提交" claim.submitted_at = None claim.risk_flags_json = self._append_budget_flags( [*list(claim.risk_flags_json or []), return_flag], budget_flags, business_stage="expense_application" if is_application_claim else "reimbursement", ) self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=operator, action="expense_claim.return", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim