feat(approval): 审批单/节点状态机与二次分配接入(开关自动通过)
- approval_orders/approval_steps 服务层:submit_order(不 commit 不嵌套 audit,业务写路径统一收口)+ act 节点动作 + 分页/详情 - 开关关闭=提单即两级「自动通过」留痕(action=3,operator 记系统); 开启=财务→领导流转,一驳全驳,驳回必填意见 - 节点准入严格按角色(status=1 仅财务、status=2 仅领导),admin 不代办; 模块准入 require_roles 依赖工厂(admin/finance/leader,营销后勤待 澄清 #6 后放开) - 业务接入:创建/编辑带方案即提单(空方案不提单);再编辑自动撤回旧 在途单(node=0/action=4 留痕);AP 编号冲突 create 内可重取(外层 重试兜底)、update 直接抛出防静默丢失 - conftest 兜底清理增审批单(AP 前缀)与开关参数种子基线恢复 - 集成 11 例:自动通过/两节点流转/驳回留痕/缺意见 400/节点准入 403/ admin 不代办/重复操作 400/撤旧建新/模块准入 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
680661a80b
commit
8ef26e22fb
10 files changed
+842
-1
No files matched your search
@@ -0,0 +1,330 @@
|
||||
"""审批流业务编排(2026-10-15 复核审批补充需求)。
|
||||
|
||||
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动
|
||||
(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`):
|
||||
|
||||
- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3,
|
||||
operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致;
|
||||
- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回
|
||||
即整体驳回(status=4),一期不做退回重走(简单状态机)。
|
||||
|
||||
红线:
|
||||
- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service
|
||||
的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径
|
||||
回滚不完整(见 audit_service 模块 docstring);
|
||||
- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不
|
||||
代办 —— 放行 admin 会让双人复核流退化成管理员单点审批;
|
||||
- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 +
|
||||
node=0/action=4 留痕),保证「一个对象至多一张在途单」。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
ACTION_APPROVE,
|
||||
ACTION_REJECT,
|
||||
MODULE_APPROVAL,
|
||||
NO_PREFIX_APPROVAL,
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, User
|
||||
from app.models.approval import (
|
||||
APPROVAL_ACTION_APPROVE,
|
||||
APPROVAL_ACTION_AUTO_APPROVE,
|
||||
APPROVAL_ACTION_WITHDRAW,
|
||||
APPROVAL_NODE_FINANCE,
|
||||
APPROVAL_NODE_LEADER,
|
||||
APPROVAL_NODE_WITHDRAW,
|
||||
APPROVAL_NODES,
|
||||
APPROVAL_STATUS_APPROVED,
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
APPROVAL_STATUS_REJECTED,
|
||||
APPROVAL_STATUS_WITHDRAWN,
|
||||
)
|
||||
from app.repositories import user_repository
|
||||
from app.schemas.approval import (
|
||||
ApprovalActionIn,
|
||||
ApprovalOrderDetailOut,
|
||||
ApprovalOrderOut,
|
||||
ApprovalQuery,
|
||||
ApprovalStepOut,
|
||||
)
|
||||
from app.services import audit_service, param_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 开关参数编码(与 param_registry 种子同源)
|
||||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||
1: "approval.secondary_allocation.enabled",
|
||||
2: "approval.monthly_settlement.enabled",
|
||||
}
|
||||
|
||||
_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底)
|
||||
|
||||
# 在途状态集合(同一引用对象至多一张在途单)
|
||||
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||
|
||||
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
|
||||
_NODE_ROLE_BY_STATUS = {
|
||||
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER,
|
||||
}
|
||||
|
||||
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
||||
|
||||
|
||||
def is_enabled(session: Session, order_type: int) -> bool:
|
||||
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||
if code is None:
|
||||
raise BadRequestError("未知的审批对象类型")
|
||||
return param_service.resolve_snapshot(session, date.today()).boolean(code)
|
||||
|
||||
|
||||
def submit_order(
|
||||
session: Session,
|
||||
*,
|
||||
order_type: int,
|
||||
ref_id: int,
|
||||
refer_no: str,
|
||||
submitter: User,
|
||||
retry_on_conflict: bool,
|
||||
) -> ApprovalOrder:
|
||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||
|
||||
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||
(全节点 action=3 留痕)。
|
||||
|
||||
:param retry_on_conflict: AP 编号撞唯一键时是否允许本函数内部
|
||||
rollback 后重取。**仅 create 传 True**——它外层有编号重试循环,
|
||||
rollback 后整体重来;update 路径必须传 False:内部 rollback 会把
|
||||
同事务的业务行修改一并回滚,静默继续会「返回 200 库里没改」。
|
||||
False 时 IntegrityError 直接抛出 → 审计上下文回滚并记失败 →
|
||||
全局 handler 收敛 400,用户重试即可。
|
||||
"""
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
|
||||
order = ApprovalOrder(
|
||||
order_no=next_no(
|
||||
session,
|
||||
model=ApprovalOrder,
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_APPROVAL,
|
||||
),
|
||||
order_type=order_type,
|
||||
ref_id=ref_id,
|
||||
refer_no=refer_no,
|
||||
submitter_id=submitter.id,
|
||||
submitter_name=submitter.username,
|
||||
)
|
||||
session.add(order)
|
||||
|
||||
if is_enabled(session, order_type):
|
||||
order.status = APPROVAL_STATUS_PENDING_FINANCE
|
||||
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||
logger.info("审批单待审 | order=%s", order.order_no)
|
||||
return order
|
||||
|
||||
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||||
order.status = APPROVAL_STATUS_APPROVED
|
||||
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||
session.add_all(
|
||||
[
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node,
|
||||
action=APPROVAL_ACTION_AUTO_APPROVE,
|
||||
operator_name="系统",
|
||||
)
|
||||
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
|
||||
return order
|
||||
|
||||
|
||||
def act(
|
||||
session: Session,
|
||||
order_id: int,
|
||||
payload: ApprovalActionIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> ApprovalOrderDetailOut:
|
||||
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
|
||||
|
||||
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
|
||||
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
|
||||
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
|
||||
"""
|
||||
codes = set(user_repository.get_role_codes(operator))
|
||||
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_APPROVAL,
|
||||
action=verb,
|
||||
summary=f"审批单 #{order_id} {verb}",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
order = session.get(ApprovalOrder, order_id, with_for_update=True)
|
||||
if order is None:
|
||||
raise NotFoundError("审批单不存在")
|
||||
if order.status not in _PENDING_STATUSES:
|
||||
raise BadRequestError("审批单已结束,不可重复操作")
|
||||
|
||||
required_role = _NODE_ROLE_BY_STATUS[order.status]
|
||||
if required_role not in codes:
|
||||
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
|
||||
|
||||
node_now = APPROVAL_NODES[order.status]
|
||||
if payload.action == APPROVAL_ACTION_APPROVE:
|
||||
order.status = (
|
||||
APPROVAL_STATUS_PENDING_LEADER
|
||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||
else APPROVAL_STATUS_APPROVED
|
||||
)
|
||||
else:
|
||||
order.status = APPROVAL_STATUS_REJECTED
|
||||
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node_now,
|
||||
action=payload.action,
|
||||
opinion=payload.opinion,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return _detail_of(session, order)
|
||||
|
||||
|
||||
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||
"""分页查询审批单(准入由 api 层依赖判定)。"""
|
||||
filters = _order_filters(query)
|
||||
rows = (
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.where(*filters)
|
||||
.order_by(ApprovalOrder.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
total = session.execute(
|
||||
select(func.count()).select_from(ApprovalOrder).where(*filters)
|
||||
).scalar_one()
|
||||
return PageResult[ApprovalOrderOut](
|
||||
records=[ApprovalOrderOut.model_validate(r) for r in rows],
|
||||
total=int(total),
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
|
||||
"""审批单详情(附节点时间线)。"""
|
||||
order = session.get(ApprovalOrder, order_id)
|
||||
if order is None:
|
||||
raise NotFoundError("审批单不存在")
|
||||
return _detail_of(session, order)
|
||||
|
||||
|
||||
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
|
||||
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
|
||||
pending = (
|
||||
session.execute(
|
||||
select(ApprovalOrder).where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for order in pending:
|
||||
order.status = APPROVAL_STATUS_WITHDRAWN
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=APPROVAL_NODE_WITHDRAW,
|
||||
action=APPROVAL_ACTION_WITHDRAW,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
if pending:
|
||||
session.flush()
|
||||
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
|
||||
|
||||
|
||||
def _flush_order(session: Session, order: ApprovalOrder, *, retry_on_conflict: bool) -> None:
|
||||
"""flush 提单;撞 AP 单号唯一键时按 retry_on_conflict 决定重取或抛出。
|
||||
|
||||
rollback 会连业务写路径的未提交变更一并回滚 —— 见 submit_order 的
|
||||
retry_on_conflict 说明(create 才允许内部重取)。
|
||||
"""
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
session.flush()
|
||||
return
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if is_duplicate_entry(exc) and retry_on_conflict and attempt < _NO_RETRY:
|
||||
order.id = None # rollback 后实例回 transient,主键重新分配
|
||||
order.order_no = next_no(
|
||||
session,
|
||||
model=ApprovalOrder,
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_APPROVAL,
|
||||
)
|
||||
session.add(order)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||
"""出参组装(含 steps 时间线)。"""
|
||||
steps = (
|
||||
session.execute(
|
||||
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
detail = ApprovalOrderDetailOut.model_validate(order)
|
||||
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
||||
return detail
|
||||
|
||||
|
||||
def _order_filters(query: ApprovalQuery) -> tuple:
|
||||
filters = []
|
||||
if query.keyword:
|
||||
like = f"%{query.keyword}%"
|
||||
filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like))
|
||||
if query.status is not None:
|
||||
filters.append(ApprovalOrder.status == query.status)
|
||||
if query.order_type is not None:
|
||||
filters.append(ApprovalOrder.order_type == query.order_type)
|
||||
return tuple(filters)
|
||||
Reference in new issue
Block a user