fix(m3): code-review 15 项修复(13 项落地,2 项依赖分摊语义记待办)

- 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成
  静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛);
  act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批
  记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照);
  keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽
  withdraw_pending_orders 供编辑清空方案时显式调用
- 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理;
  编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化
  语义,仅新增参与人做在职校验)
- 迁移:business_secondary_allocations 补 uk(business_id, personnel_id)
  (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证)
- 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦;
  company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit
  成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包
  (原直发 service.post 不解包信封,logoUrl 会被置 undefined)
- 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮
  (原语义永远删错对象,每轮净增一份整库拷贝)
- 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件;
  新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界);
  全量回归 701 passed
- 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-16 14:23:53 +08:00
1 parent fc8a42bcd9
commit 1c93ea0a4b
14 files changed
+338 -79

No files matched your search

+59 -47
View File
@@ -22,7 +22,6 @@ 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 (
@@ -33,13 +32,13 @@ from app.core.constants import (
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_REJECT,
APPROVAL_ACTION_WITHDRAW,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
@@ -71,8 +70,6 @@ ENABLE_CODE_BY_TYPE: dict[int, str] = {
2: "approval.monthly_settlement.enabled",
}
_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底)
# 在途状态集合(同一引用对象至多一张在途单)
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
@@ -100,19 +97,15 @@ def submit_order(
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,用户重试即可。
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
@@ -133,13 +126,13 @@ def submit_order(
if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
session.flush()
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.flush()
session.add_all(
[
ApprovalStep(
@@ -156,6 +149,29 @@ def submit_order(
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,
@@ -194,14 +210,15 @@ def act(
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status]
if payload.action == APPROVAL_ACTION_APPROVE:
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
)
else:
order.status = APPROVAL_STATUS_REJECTED
session.add(
ApprovalStep(
@@ -213,8 +230,23 @@ def act(
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)
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
session.flush()
detail = _detail_of(session, order)
session.commit()
return _detail_of(session, order)
return detail
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
@@ -279,33 +311,8 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
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 时间线)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
@@ -321,8 +328,13 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
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))
# 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: