feat: 完善审批退回流程与报销申请关联

后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-27 14:35:17 +08:00
parent 7d32eae74e
commit cbb98f4469
30 changed files with 1794 additions and 250 deletions

View File

@@ -95,35 +95,19 @@ class ExpenseClaimAccessPolicy:
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if self.has_privileged_claim_access(current_user):
return True
role_codes = self.normalize_role_codes(current_user)
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
return False
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != "直属领导审批":
normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted":
return False
current_employee = self.resolve_current_employee(current_user)
if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id:
return False
claim_employee = claim.employee
if current_employee is not None and claim_employee is not None:
if claim_employee.manager_id == current_employee.id:
return True
if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id:
return True
approver_name = str(
current_employee.name if current_employee is not None and current_employee.name else current_user.name or ""
).strip()
if not approver_name:
return False
return self.resolve_claim_manager_name(claim) == approver_name
stage = str(claim.approval_stage or "").strip()
if stage == "直属领导审批":
return self.is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
claim,
current_user,
)
return False
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
stage = str(claim.approval_stage or "").strip()
@@ -131,7 +115,10 @@ class ExpenseClaimAccessPolicy:
return self.is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
role_codes = self.normalize_role_codes(current_user)
return current_user.is_admin or "finance" in role_codes
return (
(current_user.is_admin or "finance" in role_codes)
and not self.is_claim_owned_by_current_user(claim, current_user)
)
return False
def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:

View File

@@ -220,6 +220,11 @@ RETURN_REASON_OPTIONS = {
"business_explanation": "业务事由/地点/人员信息不完整",
"duplicate_or_abnormal": "疑似重复或异常票据",
"approval_question": "审批人需要补充说明",
"application_info_incomplete": "申请信息不完整",
"application_business_need_unclear": "业务必要性说明不足",
"application_budget_basis_missing": "预算测算依据不足",
"application_policy_mismatch": "制度口径不匹配",
"application_attachment_needed": "前置材料需补充",
}
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_DATE_PATTERN = re.compile(

View File

@@ -578,10 +578,26 @@ class ExpenseClaimService(
previous_status = str(claim.status or "").strip()
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
previous_stage_key = self._normalize_return_stage_key(previous_stage)
is_application_claim = self._is_expense_application_claim(claim)
is_direct_manager_return = previous_stage_key == "direct_manager"
return_event_type = (
"expense_application_return"
if is_application_claim and is_direct_manager_return
else "expense_claim_return"
)
return_label = (
"领导退回"
if is_application_claim and is_direct_manager_return
else "人工退回"
)
return_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
if is_application_claim and is_direct_manager_return and not any(
code.startswith("application_") for code in normalized_reason_codes
):
raise ValueError("申请单退回必须选择至少一个退单类型。")
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
return_count = len(existing_return_flags) + 1
@@ -600,12 +616,17 @@ class ExpenseClaimService(
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
return_flag = {
"source": "manual_return",
"event_type": "expense_claim_return",
"event_type": return_event_type,
"return_event_id": str(uuid.uuid4()),
"severity": "medium",
"label": "人工退回",
"label": return_label,
"node_key": "returned",
"node_label": "退回",
"approval_node": "退回",
"message": message,
"reason": return_reason,
"opinion": message,
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
"reason_codes": normalized_reason_codes,
"risk_points": risk_points,
"operator": operator,