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

@@ -2,9 +2,11 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from app.api.deps import CurrentUserContext
from app.services.budget import BudgetService
from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
@@ -76,7 +78,16 @@ class ExpenseClaimApprovalFlowMixin:
next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
else:
if requires_budget_review:
merged_budget_approval = (
requires_budget_review
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
)
if merged_budget_approval:
label = "领导及预算审核通过"
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}"
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
@@ -120,6 +131,19 @@ class ExpenseClaimApprovalFlowMixin:
raise ValueError("当前节点不支持审批通过。")
approval_opinion = str(opinion or "").strip()
if (
previous_stage == BUDGET_MANAGER_APPROVAL_STAGE
and self._budget_approval_opinion_required(claim)
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if (
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
and merged_budget_approval
and self._budget_approval_opinion_required(claim)
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
approval_opinion = "同意"
@@ -327,3 +351,28 @@ class ExpenseClaimApprovalFlowMixin:
if opinion:
return opinion
return ""
def _budget_approval_opinion_required(self, claim) -> bool:
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)
else {}
)
over_budget_amount = self._budget_decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return True
after_usage_rate = self._budget_decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._budget_decimal(metrics.get("claim_amount_ratio"))
warning_threshold = self._budget_decimal(context.get("warning_threshold") or "80.00")
return max(after_usage_rate, claim_amount_ratio) >= warning_threshold
@staticmethod
def _budget_decimal(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")