feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -5,7 +5,11 @@ 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 = {
|
||||
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
"expense_application": "other",
|
||||
"application": "other",
|
||||
}
|
||||
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationHandoffMixin:
|
||||
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user