feat(claim): 重构报销审批流并收敛风险标记

- 直属领导兼任部门 P8 预算审批人时合并预算审批,直接流转至财务审批
- 预算超过警戒值时强制要求预算管理者填写审批意见
- 新增风险标记去重工具,消除各审核阶段重复风险卡片
- 新增工作流修复 Mixin,纠正重复预算审批阶段的历史数据
- 收紧单据删除权限至 admin,放宽预算分析可见范围至当前审核人
- 提交校验放宽已上传票据条目的 OCR 字段缺失并忽略尾部占位条目
This commit is contained in:
caoxiaozhu
2026-06-17 14:38:07 +08:00
parent 09a66c72cb
commit 1f4681f486
11 changed files with 372 additions and 27 deletions

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
class ExpenseClaimWorkflowRepairMixin:
def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None:
repaired_claims = [
claim
for claim in claims
if claim is not None and self._repair_duplicate_budget_approval_stage(claim)
]
if not repaired_claims:
return
self.db.commit()
for claim in repaired_claims:
self.db.refresh(claim)
def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool:
if self._is_expense_application_claim(claim):
return False
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
return False
if self._has_duplicate_budget_stage_repair_flag(claim):
return False
approval_event = self._find_duplicate_budget_handoff_event(claim)
if approval_event is None:
return False
claim.approval_stage = FINANCE_APPROVAL_STAGE
claim.risk_flags_json = [
*list(claim.risk_flags_json or []),
self._build_duplicate_budget_stage_repair_flag(approval_event),
]
return True
def _find_duplicate_budget_handoff_event(self, claim: ExpenseClaim) -> dict[str, Any] | None:
flags = [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "manual_approval"
and str(flag.get("event_type") or "").strip() == "expense_claim_approval"
and str(flag.get("previous_approval_stage") or "").strip() == DIRECT_MANAGER_APPROVAL_STAGE
and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE
]
for flag in reversed(flags):
operator = self._normalize_repair_identity(flag.get("operator"))
next_approver_name = self._normalize_repair_identity(flag.get("next_approver_name"))
if operator and next_approver_name and operator == next_approver_name:
return flag
budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if budget_manager is None:
continue
if operator and operator == self._normalize_repair_identity(budget_manager.name):
return flag
return None
def _has_duplicate_budget_stage_repair_flag(self, claim: ExpenseClaim) -> bool:
return any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "approval_flow_repair"
and str(flag.get("event_type") or "").strip() == "duplicate_budget_approval_stage_repaired"
for flag in list(claim.risk_flags_json or [])
)
def _build_duplicate_budget_stage_repair_flag(self, approval_event: dict[str, Any]) -> dict[str, Any]:
return with_risk_business_stage(
{
"source": "approval_flow_repair",
"event_type": "duplicate_budget_approval_stage_repaired",
"severity": "info",
"label": "重复预算审批已跳过",
"message": "系统识别直属领导与预算管理者为同一人,已跳过重复预算审批并流转至财务审批。",
"previous_approval_stage": BUDGET_MANAGER_APPROVAL_STAGE,
"next_approval_stage": FINANCE_APPROVAL_STAGE,
"related_approval_event_id": approval_event.get("approval_event_id"),
"budget_approval_merged": True,
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
"created_at": datetime.now(UTC).isoformat(),
},
risk_business_stage_for_claim(is_application_claim=False),
)
@staticmethod
def _normalize_repair_identity(value: Any) -> str:
return str(value or "").strip().lower()