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 delete, 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.models.hermes_report import HermesRiskReport from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.schemas.ontology import OntologyEntity, OntologyParseResult from app.schemas.reimbursement import ( ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimStandardAdjustmentPayload, 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.budget_types import BudgetControlError 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_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin 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_flags import dedupe_claim_risk_flags from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin from app.services.receipt_folder import ReceiptFolderService 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, STANDARD_ADJUSTMENT_RISK_SOURCE, ) 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 ExpenseClaimStandardAdjustmentMixin: @staticmethod def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None: try: raw_value = "" if value is None else value amount = Decimal(str(raw_value)).quantize(Decimal("0.01")) except (InvalidOperation, ValueError): return None return amount if amount >= Decimal("0.00") else None @staticmethod def _format_adjustment_money(value: Decimal) -> str: normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01")) return f"{normalized:.2f}" @staticmethod def _normalize_standard_adjustment_days(value: Any) -> int | None: if value is None: return None if isinstance(value, int): return value if 1 <= value <= 365 else None text = str(value or "").strip() if not text: return None match = re.search(r"\d{1,3}", text) if not match: return None days = int(match.group(0)) return days if 1 <= days <= 365 else None @staticmethod def _normalize_standard_adjustment_text(value: Any) -> str: text = str(value or "").strip() if not text or text in {"-", "N/A", "n/a"}: return "" if text in {"待补充", "未知", "暂无", "非必填"}: return "" return text def _iter_standard_adjustment_application_details(self, claim: ExpenseClaim) -> list[dict[str, Any]]: details: list[dict[str, Any]] = [] for flag in list(claim.risk_flags_json or []): if not isinstance(flag, dict): continue detail = flag.get("application_detail") or flag.get("applicationDetail") if isinstance(detail, dict): details.append(detail) related = flag.get("related_application") or flag.get("relatedApplication") if isinstance(related, dict): details.append(related) return details def _resolve_standard_adjustment_days( self, claim: ExpenseClaim, item: ExpenseClaimItem, entry: Any, ) -> int: direct_days = self._normalize_standard_adjustment_days(getattr(entry, "application_days", None)) if direct_days is not None: return direct_days for detail in self._iter_standard_adjustment_application_details(claim): for key in ("application_days", "applicationDays", "days"): detail_days = self._normalize_standard_adjustment_days(detail.get(key)) if detail_days is not None: return detail_days candidates = [ getattr(entry, "risk", None), getattr(entry, "title", None), item.item_reason, claim.reason, ] for text in candidates: match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or "")) if match: days = self._normalize_standard_adjustment_days(match.group(1)) if days is not None: return days return 1 def _resolve_standard_adjustment_location( self, claim: ExpenseClaim, item: ExpenseClaimItem, ) -> str: for value in (item.item_location, claim.location): text = self._normalize_standard_adjustment_text(value) if text: return text for detail in self._iter_standard_adjustment_application_details(claim): for key in ("application_location", "applicationLocation", "location", "city"): text = self._normalize_standard_adjustment_text(detail.get(key)) if text: return text return "" def _resolve_policy_standard_reimbursable_amount( self, *, claim: ExpenseClaim, item: ExpenseClaimItem, entry: Any, current_user: CurrentUserContext, ) -> Decimal | None: item_type = str(item.item_type or "").strip().lower() if item_type not in {"hotel", "hotel_ticket"}: return None location = self._resolve_standard_adjustment_location(claim, item) grade = str(claim.employee_grade or current_user.grade or "").strip() if not location or not grade: return None try: from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService result = TravelReimbursementCalculatorService(self.db).calculate( TravelReimbursementCalculatorRequest( days=self._resolve_standard_adjustment_days(claim, item, entry), location=location, grade=grade, ), current_user, ) except Exception: return None return self._normalize_standard_adjustment_amount(result.hotel_amount) def _resolve_standard_adjustment_reimbursable_amount( self, *, claim: ExpenseClaim, item: ExpenseClaimItem, entry: Any, original_amount: Decimal, current_user: CurrentUserContext, ) -> Decimal: policy_amount = self._resolve_policy_standard_reimbursable_amount( claim=claim, item=item, entry=entry, current_user=current_user, ) if policy_amount is not None: return min(max(policy_amount, Decimal("0.00")), original_amount) entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount) if entry_amount is not None: return min(max(entry_amount, Decimal("0.00")), original_amount) return original_amount def accept_standard_adjustment( self, *, claim_id: str, payload: ExpenseClaimStandardAdjustmentPayload, current_user: CurrentUserContext, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None self._ensure_draft_claim(claim) if self._is_expense_application_claim(claim): raise ValueError("费用申请单不支持按报销标准重算。") risk_entries = list(payload.risks or []) if not risk_entries: raise ValueError("请至少选择一条需要按职级标准重算的风险。") before_json = self._serialize_claim(claim) item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])} now_text = datetime.now(UTC).isoformat() adjustment_flags: list[dict[str, Any]] = [] for index, entry in enumerate(risk_entries, start=1): item_id = str(entry.item_id or "").strip() item = item_map.get(item_id) if item is None: continue original_amount = ( self._normalize_standard_adjustment_amount(entry.original_amount) or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01")) ) reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount( claim=claim, item=item, entry=entry, original_amount=original_amount, current_user=current_user, ) employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01")) item_label = ( str(item.item_reason or "").strip() or str(entry.title or "").strip() or f"费用明细第 {index} 条" ) source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip() message = ( f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 " f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 " f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 " f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。" ) adjustment_flags.append( with_risk_business_stage( { "source": STANDARD_ADJUSTMENT_RISK_SOURCE, "event_type": "standard_adjustment_accepted", "severity": "medium", "label": "接受职级标准审核", "title": "提交人接受职级最高报销标准", "message": message, "summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。", "suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。", "risk_id": str(entry.risk_id or "").strip(), "source_risk": source_risk, "item_id": item_id, "original_amount": self._format_adjustment_money(original_amount), "reimbursable_amount": self._format_adjustment_money(reimbursable_amount), "employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount), "risk_domain": "amount", "actionability": "review_decision", "visibility_scope": "leader", "created_at": now_text, }, "reimbursement", ) ) if not adjustment_flags: raise ValueError("未找到可按职级标准重算的费用明细。") preserved_flags = [ flag for flag in list(claim.risk_flags_json or []) if not ( isinstance(flag, dict) and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE ) ] claim.risk_flags_json = dedupe_claim_risk_flags([*preserved_flags, *adjustment_flags]) 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.standard_adjustment_accept", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim class ExpenseClaimItemActionMixin: 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_note is not None: item.item_note = self._normalize_optional_text(payload.item_note, 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._refresh_claim_pre_review_flags(claim, is_application_claim=False) 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_note=self._normalize_optional_text(payload.item_note, allow_empty=True) 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._refresh_claim_pre_review_flags(claim, is_application_claim=False) 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._refresh_claim_pre_review_flags(claim, is_application_claim=False) 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) try: budget_flags = self._reserve_budget_for_submission( claim, current_user, is_application_claim=is_application_claim, ) except BudgetControlError as exc: if is_application_claim: raise budget_flags = list(exc.flags or []) 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", ) if not self._has_ai_pre_review_flag(claim): self._refresh_claim_pre_review_flags(claim, is_application_claim=False) claim.status = "submitted" claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE claim.submitted_at = datetime.now(UTC) claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json) 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: 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: 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 operator = self._access_policy.resolve_current_user_display_name(current_user) if not self._is_expense_application_claim(claim): self._sync_linked_applications_after_reimbursement_deleted( reimbursement_claim=claim, operator=operator, current_user=current_user, ) self._release_budget_for_delete(claim, current_user) self._delete_claim_analysis_records(resource_id) self._attachment_storage.delete_claim_files(claim) ReceiptFolderService().unlink_receipts_for_claim(resource_id) 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 _delete_claim_analysis_records(self, claim_id: str) -> None: observation_ids = select(RiskObservation.id).where(RiskObservation.claim_id == claim_id) self.db.execute( delete(RiskObservationFeedback).where( RiskObservationFeedback.observation_id.in_(observation_ids) ) ) self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id)) self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id)) 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 class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin): 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.organization_unit), 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) claims = list(self.db.scalars(stmt).all()) self._repair_duplicate_budget_approval_stages(claims) return self._access_policy.attach_budget_approval_snapshots(claims) 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.organization_unit), 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) claims = list(self.db.scalars(stmt).all()) self._repair_duplicate_budget_approval_stages(claims) return self._access_policy.attach_budget_approval_snapshots(claims) 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.organization_unit), 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.organization_unit), 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) claim = self.db.scalar(stmt) if claim is not None: self._repair_duplicate_budget_approval_stages([claim]) return self._access_policy.attach_approval_snapshot(claim) 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.has_privileged_claim_access(current_user) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user) ): return True if self._access_policy.can_approve_claim(current_user, claim): 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 "待补充" if not self._is_expense_application_claim(claim): self._refresh_claim_pre_review_flags(claim, is_application_claim=False) 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