后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
849 lines
30 KiB
Python
849 lines
30 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 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_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_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_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
|
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
|
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(
|
|
ExpenseClaimApplicationHandoffMixin,
|
|
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 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 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.db.scalar(stmt)
|
|
|
|
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)
|
|
|
|
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"}
|
|
)
|
|
]
|
|
submit_flag = {
|
|
"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(),
|
|
}
|
|
claim.status = "submitted"
|
|
claim.approval_stage = "直属领导审批"
|
|
claim.risk_flags_json = [*preserved_flags, submit_flag]
|
|
claim.submitted_at = submitted_at
|
|
else:
|
|
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._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
|
|
|
|
if not self._access_policy.can_return_claim(current_user, claim):
|
|
raise ValueError("只有财务人员、高级管理人员或当前审批人可以退回报销单。")
|
|
|
|
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("已完成单据不允许退回。")
|
|
|
|
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)
|
|
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"]
|
|
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": "expense_claim_return",
|
|
"return_event_id": str(uuid.uuid4()),
|
|
"severity": "medium",
|
|
"label": "人工退回",
|
|
"message": message,
|
|
"reason": return_reason,
|
|
"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
|
|
|
|
claim.status = "returned"
|
|
claim.approval_stage = "待提交"
|
|
claim.submitted_at = None
|
|
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
|
|
|
|
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
|
|
|
|
def approve_claim(
|
|
self,
|
|
claim_id: str,
|
|
current_user: CurrentUserContext,
|
|
*,
|
|
opinion: 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 != "submitted":
|
|
raise ValueError("只有审批中的单据可以审批通过。")
|
|
|
|
previous_stage = str(claim.approval_stage or "").strip()
|
|
is_application_claim = self._is_expense_application_claim(claim)
|
|
if previous_stage == "直属领导审批":
|
|
if not self._access_policy.can_approve_claim(current_user, claim):
|
|
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
|
approval_source = "manual_approval"
|
|
if is_application_claim:
|
|
event_type = "expense_application_approval"
|
|
label = "领导审批通过"
|
|
next_status = "approved"
|
|
next_stage = "审批完成"
|
|
default_message = "{operator} 已确认审核,申请流程完成并生成报销草稿。"
|
|
else:
|
|
event_type = "expense_claim_approval"
|
|
label = "领导审批通过"
|
|
next_status = "submitted"
|
|
next_stage = "财务审批"
|
|
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
|
elif previous_stage == "财务审批":
|
|
if is_application_claim:
|
|
raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。")
|
|
if not self._access_policy.can_approve_claim(current_user, claim):
|
|
raise ValueError("只有财务人员可以完成财务终审。")
|
|
approval_source = "finance_approval"
|
|
event_type = "expense_claim_finance_approval"
|
|
label = "财务审核通过"
|
|
next_status = "approved"
|
|
next_stage = "归档入账"
|
|
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
|
else:
|
|
raise ValueError("当前节点不支持审批通过。")
|
|
|
|
approval_opinion = str(opinion or "").strip()
|
|
if previous_stage == "直属领导审批" and not approval_opinion:
|
|
raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。")
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
|
approval_flag = {
|
|
"source": approval_source,
|
|
"event_type": event_type,
|
|
"approval_event_id": str(uuid.uuid4()),
|
|
"severity": "info",
|
|
"label": label,
|
|
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
|
"opinion": approval_opinion,
|
|
"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": str(claim.status or "").strip(),
|
|
"previous_approval_stage": previous_stage,
|
|
"next_status": next_status,
|
|
"next_approval_stage": next_stage,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
claim.status = next_status
|
|
claim.approval_stage = next_stage
|
|
if claim.submitted_at is None:
|
|
claim.submitted_at = datetime.now(UTC)
|
|
if is_application_claim and previous_stage == "直属领导审批":
|
|
generated_draft = self._create_reimbursement_draft_from_application(
|
|
application_claim=claim,
|
|
approval_flag=approval_flag,
|
|
operator=operator,
|
|
)
|
|
claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag]
|
|
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=operator,
|
|
action="expense_claim.approve",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return claim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|