Files
X-Financial/server/src/app/services/expense_claims.py
caoxiaozhu e124e4bbcb feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
2026-06-06 17:19:07 +08:00

1024 lines
41 KiB
Python

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_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.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 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 "待补充"
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
@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 = [*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
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)
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._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