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

251 lines
12 KiB
Python

from __future__ import annotations
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
"travel_application": "travel",
"purchase_application": "office",
"meeting_application": "meeting",
"expense_application": "other",
"application": "other",
}
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
class ExpenseClaimApplicationHandoffMixin:
@staticmethod
def _resolve_reimbursement_type_from_application(expense_type: str | None) -> str:
normalized = str(expense_type or "").strip().lower()
if normalized in APPLICATION_REIMBURSEMENT_TYPE_MAP:
return APPLICATION_REIMBURSEMENT_TYPE_MAP[normalized]
if normalized.endswith("_application"):
return normalized.removesuffix("_application") or "other"
return normalized or "other"
@staticmethod
def _resolve_application_detail(application_claim: ExpenseClaim) -> dict[str, str]:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail":
continue
detail = flag.get("application_detail") or flag.get("applicationDetail") or {}
if isinstance(detail, dict):
return {str(key): str(value or "").strip() for key, value in detail.items()}
return {}
@staticmethod
def _build_application_handoff_detail(application_claim: ExpenseClaim) -> dict[str, str]:
detail = ExpenseClaimApplicationHandoffMixin._resolve_application_detail(application_claim)
application_time = str(detail.get("time") or "").strip()
if not application_time and application_claim.occurred_at is not None:
application_time = application_claim.occurred_at.isoformat()
application_amount = str(detail.get("amount") or "").strip()
if not application_amount:
application_amount = str(application_claim.amount or Decimal("0.00"))
return {
"application_type": str(detail.get("application_type") or application_claim.expense_type or "").strip(),
"application_content": " / ".join(
item
for item in [
str(detail.get("application_type") or application_claim.expense_type or "").strip(),
str(detail.get("location") or application_claim.location or "").strip(),
]
if item
),
"application_reason": str(detail.get("reason") or application_claim.reason or "").strip(),
"application_days": str(detail.get("days") or "").strip(),
"application_location": str(detail.get("location") or application_claim.location or "").strip(),
"application_amount": application_amount,
"application_time": application_time,
"application_transport_mode": str(detail.get("transport_mode") or "").strip(),
"application_lodging_daily_cap": str(detail.get("lodging_daily_cap") or "").strip(),
"application_subsidy_daily_cap": str(detail.get("subsidy_daily_cap") or "").strip(),
"application_transport_policy": str(detail.get("transport_policy") or "").strip(),
"application_policy_estimate": str(detail.get("policy_estimate") or "").strip(),
"application_rule_name": str(detail.get("rule_name") or "").strip(),
"application_rule_version": str(detail.get("rule_version") or "").strip(),
}
def _create_reimbursement_draft_from_application(
self,
*,
application_claim: ExpenseClaim,
approval_flag: dict[str, Any],
operator: str,
) -> ExpenseClaim:
occurred_at = application_claim.occurred_at or datetime.now(UTC)
created_at = datetime.now(UTC)
draft_claim = ExpenseClaim(
claim_no=self._generate_claim_no(occurred_at),
employee_id=application_claim.employee_id,
employee_name=application_claim.employee_name,
department_id=application_claim.department_id,
department_name=application_claim.department_name,
project_code=application_claim.project_code,
expense_type=self._resolve_reimbursement_type_from_application(application_claim.expense_type),
reason=application_claim.reason,
location=application_claim.location,
amount=application_claim.amount or Decimal("0.00"),
currency=application_claim.currency or "CNY",
invoice_count=0,
occurred_at=occurred_at,
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[
{
"source": "application_handoff",
"event_type": "expense_application_to_reimbursement_draft",
"handoff_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "申请转报销草稿",
"message": (
f"费用申请 {application_claim.claim_no} 已由 {operator} 确认审核,"
"系统已生成报销草稿。"
),
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
"application_budget_amount": str(application_claim.amount or Decimal("0.00")),
"application_detail": self._build_application_handoff_detail(application_claim),
"application_approval_event_id": str(approval_flag.get("approval_event_id") or ""),
"leader_opinion": str(
approval_flag.get("leader_opinion") or approval_flag.get("opinion") or ""
).strip(),
"budget_opinion": str(approval_flag.get("budget_opinion") or "").strip(),
"created_at": created_at.isoformat(),
}
],
)
self.db.add(draft_claim)
self.db.flush()
approval_flag["generated_draft_claim_id"] = draft_claim.id
approval_flag["generated_draft_claim_no"] = draft_claim.claim_no
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}"
return draft_claim
@staticmethod
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
application_ids: set[str] = set()
application_nos: set[str] = set()
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_reference = any(
str(flag.get(key) or "").strip()
for key in (
"application_claim_id",
"applicationClaimId",
"application_claim_no",
"applicationClaimNo",
)
)
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
continue
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
if application_id:
application_ids.add(application_id)
if application_no:
application_nos.add(application_no)
return application_ids, application_nos
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
conditions = []
if application_ids:
conditions.append(ExpenseClaim.id.in_(application_ids))
if application_nos:
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
if not conditions:
return []
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
return [claim for claim in claims if self._is_expense_application_claim(claim)]
def _archive_linked_applications_after_reimbursement_paid(
self,
*,
reimbursement_claim: ExpenseClaim,
payment_flag: dict[str, Any],
operator: str,
current_user: Any,
) -> list[dict[str, str]]:
archived_applications: list[dict[str, str]] = []
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
for application_claim in self._find_linked_application_claims(reimbursement_claim):
previous_status = str(application_claim.status or "").strip()
previous_stage = str(application_claim.approval_stage or "").strip()
if previous_stage == APPLICATION_ARCHIVE_STAGE:
continue
normalized_status = previous_status.lower()
if normalized_status not in {"approved", "completed"}:
continue
before_json = self._serialize_claim(application_claim)
archive_flag = with_risk_business_stage(
{
"source": "application_archive_sync",
"event_type": "expense_application_archived_by_reimbursement",
"archive_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "申请归档",
"message": (
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
"系统同步将申请单归档。"
),
"operator": operator,
"operator_username": getattr(current_user, "username", ""),
"operator_role_codes": [
str(item).strip().lower()
for item in getattr(current_user, "role_codes", [])
if str(item).strip()
],
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
"reimbursement_claim_id": reimbursement_claim.id,
"reimbursement_claim_no": reimbursement_claim.claim_no,
"payment_event_id": payment_event_id,
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"next_status": "approved",
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
"created_at": datetime.now(UTC).isoformat(),
},
"expense_application",
)
application_claim.status = "approved"
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
archived_applications.append(
{
"application_claim_id": application_claim.id,
"application_claim_no": str(application_claim.claim_no or "").strip(),
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
}
)
self.audit_service.log_action(
actor=operator,
action="expense_application.archive_by_reimbursement",
resource_type="expense_claim",
resource_id=application_claim.id,
before_json=before_json,
after_json=self._serialize_claim(application_claim),
)
return archived_applications