"""审批流业务编排(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)