- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
251 lines
12 KiB
Python
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
|