- 经理聚合分成放大 100 倍:参数按百分数存储(12.5=12.5%),parse_fraction 后调用方又 ×100、函数内部再 ÷100,净效应「绩效×12.5」;去掉调用方 ×100,rate_text 改用实际参数原文(原硬编码 12.5%+5% 参数调整后失真) - 审批联动只认终审:财务节点通过(1→2)不再提前 mark_locked/调整单生效, 否则领导驳回后 locked_at/locked_by 残留、两级审批形同虚设;驳回在任何 节点即时联动;mark_rejected 补清锁定字段 - 调整单新建不可用:ENABLE_CODE_BY_TYPE 未登记 type=3 → is_enabled 恒 400; 补登记 approval.adjustment.enabled。另修复叠加缺陷——开关关闭分支手工 ApprovalStep(order_id=调整单id),而该外键指向 approval_orders,必然 errno 1452;统一改走 submit_order(自动通过留痕由它负责) - 重算双重计提:业务级一次性计提的「进度登记」回款无生效台账行,重算会 凭空落新行;无旧行一律 400 - 前端重算传错 id:台账行主键当 receipt_id 传给重算接口,会作废无关回款 的台账;改传 row.receipt_id(聚合行防御) - quantize 显式 ROUND_HALF_UP:9 处金额 quantize 缺省银行家舍入, x.xx5 与四舍五入口径差 1 分(docstring 却声称 HALF_UP) 测试:显式控制审批开关(V8 起种子默认开启,用例不再依赖默认值); 新增回归 4 例(经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前 锁定),test_calc+test_settlements 22 passed Co-Authored-By: Claude Code <noreply@anthropic.com>
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""审批流业务编排(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.orm import Session
|
||
|
||
from app.core.constants import (
|
||
ACTION_APPROVE,
|
||
ACTION_REJECT,
|
||
MODULE_APPROVAL,
|
||
NO_PREFIX_APPROVAL,
|
||
ROLE_FINANCE,
|
||
ROLE_LEADER,
|
||
)
|
||
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_REJECT,
|
||
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 种子同源;新增审批对象类型必须在此登记,
|
||
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||
1: "approval.secondary_allocation.enabled",
|
||
2: "approval.monthly_settlement.enabled",
|
||
3: "approval.adjustment.enabled",
|
||
}
|
||
|
||
# 在途状态集合(同一引用对象至多一张在途单)
|
||
_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,
|
||
) -> ApprovalOrder:
|
||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||
|
||
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
|
||
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
|
||
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||
"""
|
||
_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
|
||
session.flush()
|
||
logger.info("审批单待审 | order=%s", order.order_no)
|
||
return order
|
||
|
||
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||
order.status = APPROVAL_STATUS_APPROVED
|
||
session.flush()
|
||
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 withdraw_pending_orders(
|
||
session: Session, *, order_type: int, ref_id: int, operator: User
|
||
) -> int:
|
||
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
|
||
|
||
返回撤回张数(0 = 本就无在途单)。
|
||
"""
|
||
before = len(
|
||
session.execute(
|
||
select(ApprovalOrder.id).where(
|
||
ApprovalOrder.order_type == order_type,
|
||
ApprovalOrder.ref_id == ref_id,
|
||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
if before:
|
||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
|
||
return before
|
||
|
||
|
||
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]
|
||
rejected = payload.action == APPROVAL_ACTION_REJECT
|
||
if rejected:
|
||
order.status = APPROVAL_STATUS_REJECTED
|
||
else:
|
||
order.status = (
|
||
APPROVAL_STATUS_PENDING_LEADER
|
||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||
else APPROVAL_STATUS_APPROVED
|
||
)
|
||
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
||
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
||
# 驳回在任何节点都是终态,即时联动。
|
||
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
||
|
||
session.add(
|
||
ApprovalStep(
|
||
order_id=order.id,
|
||
node=node_now,
|
||
action=payload.action,
|
||
opinion=payload.opinion,
|
||
operator_id=operator.id,
|
||
operator_name=operator.username,
|
||
)
|
||
)
|
||
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
|
||
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
|
||
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
|
||
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
|
||
# 委托 business_repository 完成 —— service 层互调、不触 commit。
|
||
if rejected and order.order_type == 1:
|
||
from app.repositories import business_repository
|
||
|
||
business_repository.delete_allocations(session, order.ref_id)
|
||
|
||
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
||
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
||
if order.order_type == 2 or order.order_type == 3:
|
||
from app.models import AdjustmentOrder
|
||
from app.models.settlement import (
|
||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||
)
|
||
from app.models.settlement import (
|
||
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
|
||
)
|
||
from app.services import settlement_service
|
||
|
||
if order.order_type == 2:
|
||
from app.models.settlement import MonthlySettlement
|
||
|
||
target = session.get(MonthlySettlement, order.ref_id)
|
||
if target is not None:
|
||
if rejected:
|
||
settlement_service.mark_rejected(session, target, operator=operator)
|
||
elif final_approved:
|
||
settlement_service.mark_locked(session, target, operator=operator)
|
||
else:
|
||
adj = session.get(AdjustmentOrder, order.ref_id)
|
||
if adj is not None:
|
||
if rejected:
|
||
adj.status = ADJ_REJECTED
|
||
elif final_approved:
|
||
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||
|
||
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
||
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
||
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
|
||
session.flush()
|
||
detail = _detail_of(session, order)
|
||
session.commit()
|
||
return detail
|
||
|
||
|
||
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 _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
||
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:
|
||
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
|
||
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
|
||
# 下划线当单字符通配,搜出无关单据
|
||
filters.append(
|
||
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
|
||
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
|
||
)
|
||
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)
|