approvals.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. """审批严格关联轮次和指定处理人,管理员也不能代签或审核自己的申请。"""
  2. from sqlalchemy import select, update
  3. from app.domain.models import ApprovalChain, ApprovalRecord, ApprovalRound, AuditRun, Reimbursement, User, Role
  4. from app.application.services import alert, get, log, serialize
  5. from app.infrastructure.database import transaction
  6. from app.shared.common import BusinessError, ROLES, transition, utcnow
  7. async def validate_chain(session, body):
  8. """链配置在保存阶段就验证人员和角色,减少运行后才发现无人可审的挂单。"""
  9. from app.shared.common import cents
  10. minimum = cents(body.amount_min) if body.amount_min is not None else None
  11. maximum = cents(body.amount_max) if body.amount_max is not None else None
  12. if minimum is not None and maximum is not None and minimum > maximum:
  13. raise BusinessError("金额下限不能大于上限")
  14. seen, roles = set(), []
  15. for node in body.nodes:
  16. user = await get(session, User, node.approver_id)
  17. role = await get(session, Role, user.role_id)
  18. if not user.active or role.code != node.role or node.approver_id in seen:
  19. raise BusinessError("审批人必须有效、角色一致且不能重复")
  20. seen.add(node.approver_id)
  21. roles.append(role.code)
  22. if body.route in {"to_finance", "to_manual"} and "auditor" not in roles:
  23. raise BusinessError("财务和人工复核链必须包含审核员")
  24. if body.route == "to_supervisor" and not any(ROLES[role] >= ROLES["supervisor"] for role in roles):
  25. raise BusinessError("主管链必须包含有审批权限的人")
  26. if body.department_id:
  27. from app.domain.models import Department
  28. department = await get(session, Department, body.department_id)
  29. if not department.active:
  30. raise BusinessError("不能为停用部门配置新链")
  31. return minimum, maximum
  32. async def start_approval(audit_id):
  33. """审核事件可能重复消费,唯一 audit_id 和状态护栏保证只启动一个轮次。"""
  34. async with transaction() as session:
  35. audit = await get(session, AuditRun, audit_id)
  36. reimbursement = await get(session, Reimbursement, audit.reimbursement_id)
  37. if await session.scalar(select(ApprovalRound).where(ApprovalRound.audit_id == audit_id)):
  38. return
  39. if reimbursement.revision != audit.revision or reimbursement.status != "pending_audit" or audit.status != "completed":
  40. return
  41. if audit.decision == "auto_approve":
  42. transition(reimbursement, "in_approval")
  43. round_item = ApprovalRound(reimbursement_id=reimbursement.id, revision=audit.revision, audit_id=audit.id,
  44. chain_snapshot={"system": True, "nodes": []}, status="approved")
  45. session.add(round_item)
  46. await session.flush()
  47. session.add(ApprovalRecord(round_id=round_item.id, node_index=0, approver_id=None, result="approve",
  48. opinion="低风险且全部必需检查通过,系统自动批准", decided_at=utcnow()))
  49. transition(reimbursement, "approved")
  50. else:
  51. candidates = (await session.scalars(select(ApprovalChain).where(ApprovalChain.active.is_(True), ApprovalChain.route == audit.decision))).all()
  52. candidates = [chain for chain in candidates if (chain.type is None or chain.type == reimbursement.type)
  53. and (chain.department_id is None or chain.department_id == reimbursement.department_id)
  54. and (chain.amount_min_cents is None or chain.amount_min_cents <= reimbursement.total_cents)
  55. and (chain.amount_max_cents is None or chain.amount_max_cents >= reimbursement.total_cents)]
  56. candidates.sort(key=lambda chain: (sum(x is not None for x in [chain.type, chain.department_id, chain.amount_min_cents, chain.amount_max_cents]), chain.priority, chain.version, chain.id), reverse=True)
  57. valid = []
  58. for chain in candidates:
  59. users = [await session.get(User, node["approver_id"]) for node in chain.nodes_json]
  60. roles = [await session.get(Role, user.role_id) if user else None for user in users]
  61. if all(user and user.active and user.id != reimbursement.user_id and role and role.code == node["role"]
  62. for user, role, node in zip(users, roles, chain.nodes_json)):
  63. valid.append(chain)
  64. if not valid:
  65. await alert(session, f"chain:{audit_id}", "chain_missing", "没有匹配的有效且非自审审批链,请经理配置审批链", "error", audit_id, reimbursement.id)
  66. # 先提交告警,再由外层异常触发 outbox 退避重试。
  67. missing = True
  68. else:
  69. missing = False
  70. chain = valid[0]
  71. transition(reimbursement, "in_approval")
  72. round_item = ApprovalRound(reimbursement_id=reimbursement.id, revision=audit.revision, audit_id=audit.id,
  73. chain_snapshot=serialize(chain))
  74. session.add(round_item)
  75. await session.flush()
  76. session.add(ApprovalRecord(round_id=round_item.id, node_index=0, approver_id=chain.nodes_json[0]["approver_id"]))
  77. if audit.decision == "auto_approve" or not missing:
  78. log(session, None, "approval.started", "reimbursement", reimbursement.id, after={"status": reimbursement.status}, trace=f"audit-{audit_id}")
  79. if audit.decision != "auto_approve" and missing:
  80. raise BusinessError("没有有效审批链", 409, "CHAIN_MISSING")
  81. async def decide_approval(session, approval_id, actor, body):
  82. """条件更新防重签,先验证轮次和身份,签署结果与下一节点同事务提交。"""
  83. record = await get(session, ApprovalRecord, approval_id)
  84. round_item = await get(session, ApprovalRound, record.round_id)
  85. reimbursement = await get(session, Reimbursement, round_item.reimbursement_id)
  86. if record.approver_id != actor["id"] or reimbursement.user_id == actor["id"]:
  87. raise BusinessError("只能由指定的非提交人审批", 403, "APPROVER_REQUIRED")
  88. if record.result is not None or round_item.status != "active" or reimbursement.revision != round_item.revision or reimbursement.status != "in_approval":
  89. raise BusinessError("审批已处理或不是当前轮次", 409, "APPROVAL_CONFLICT")
  90. # 用户角色变化立即生效,冻结人员安排不意味着允许已降级身份继续签署。
  91. expected_role = round_item.chain_snapshot["nodes"][record.node_index]["role"]
  92. if actor["role"] != expected_role:
  93. raise BusinessError("审批人当前角色与冻结链不一致,需恢复身份后处理", 409, "CHAIN_INVALID")
  94. changed = await session.execute(update(ApprovalRecord).where(ApprovalRecord.id == record.id, ApprovalRecord.result.is_(None))
  95. .values(result=body.action, opinion=body.opinion, decided_at=utcnow()))
  96. if changed.rowcount != 1:
  97. raise BusinessError("审批已由其他请求处理", 409, "APPROVAL_CONFLICT")
  98. if body.action == "reject":
  99. round_item.status = "rejected"
  100. transition(reimbursement, "rejected")
  101. elif body.action == "return":
  102. round_item.status = "returned"
  103. transition(reimbursement, "draft")
  104. reimbursement.revision += 1
  105. else:
  106. nodes = round_item.chain_snapshot["nodes"]
  107. next_index = record.node_index + 1
  108. if next_index >= len(nodes):
  109. round_item.status = "approved"
  110. transition(reimbursement, "approved")
  111. else:
  112. user = await get(session, User, nodes[next_index]["approver_id"])
  113. role = await get(session, Role, user.role_id)
  114. if not user.active or user.id == reimbursement.user_id or role.code != nodes[next_index]["role"]:
  115. raise BusinessError("下一审批人已停用或属于提交人,不能推进", 409, "CHAIN_INVALID")
  116. session.add(ApprovalRecord(round_id=round_item.id, node_index=next_index, approver_id=user.id))
  117. log(session, actor, "approval." + body.action, "reimbursement", reimbursement.id,
  118. after={"approval_id": approval_id, "opinion": body.opinion, "status": reimbursement.status}, trace=f"audit-{round_item.audit_id}")
  119. await session.flush()
  120. return {"approval": serialize(record), "round": serialize(round_item), "reimbursement": serialize(reimbursement)}