fix(business): 编辑/流转/归属加锁与终态编辑门槛,has_docs 单点收口

- get_with_names 加 for_update 参数:update/change_status/change_ownership
  以锁定读取行,与发货/回款写路径同一锁口径——「有发货禁换客户」的
  快照读校验不再被并发新增发货旁路(此前 delete 有锁、唯独编辑无锁)
- update_business 补终态门槛(明细/汇总是 M3 核算基线,与发货/回款
  服务禁写终态同口径);前端编辑按钮按终态禁用并加 tooltip
- 新增 business_repository.has_docs(单条 OR exists):替换 update
  换客户与 delete 两处内联双查询,M3 新增挂靠单据类型时只改一处
- 出参组装复用首查 row:change_ownership 的姓名侧按「变更项取校验
  函数返回的新人名、未变项沿用首查名」处理(首查名是变更前快照)
- 新增终态编辑 400 且明细未动的集成用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-14 17:30:21 +08:00
1 parent a0086554b1
commit 6d612daf30
4 files changed
+108 -33

No files matched your search

+39 -25
View File
@@ -363,9 +363,9 @@ def update_business(
发货/回款语义断裂(明细同理随业务走)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 归属人员不合法 / 有发货或回款时换客户
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
"""
row = business_repository.get_with_names(session, business_id)
row = business_repository.get_with_names(session, business_id, for_update=True)
business = row[0] if row else None
with audit_service.audit(
session,
@@ -380,18 +380,17 @@ def update_business(
):
if row is None:
raise NotFoundError("业务不存在")
business, _, _, _ = row
business, company_name, owner_name, assistant_name = row
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
if payload.customer_id != business.customer_id:
has_docs = session.query(Shipment.id).filter(
Shipment.business_id == business_id
).first() is not None or (
session.query(Receipt.id).filter(Receipt.business_id == business_id).first()
is not None
)
if has_docs:
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
_ensure_customer(session, payload.customer_id)
customer = _ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
@@ -408,7 +407,9 @@ def update_business(
session, business_id, _build_item_rows(business_id, payload.items)
)
_recalc_totals(session, business)
out = _to_out(business_repository.get_with_names(session, business_id))
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
session.commit()
logger.info("修改业务 | business_id=%s", business_id)
return out
@@ -442,12 +443,7 @@ def delete_business(
):
if business is None:
raise NotFoundError("业务不存在")
if (
session.query(Shipment.id).filter(Shipment.business_id == business_id).first()
is not None
or session.query(Receipt.id).filter(Receipt.business_id == business_id).first()
is not None
):
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
business_no = business.business_no
@@ -484,7 +480,7 @@ def change_status(
:raises NotFoundError: 业务不存在
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
"""
row = business_repository.get_with_names(session, business_id)
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
@@ -522,7 +518,8 @@ def change_status(
operator_name=operator.username,
),
)
out = _to_out(business_repository.get_with_names(session, business_id))
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
out = _to_out(row)
session.commit()
logger.info(
"业务状态变更 | business_id=%s | %s -> %s",
@@ -549,7 +546,7 @@ def change_ownership(
:raises NotFoundError: 业务不存在
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
"""
row = business_repository.get_with_names(session, business_id)
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
@@ -563,11 +560,19 @@ def change_ownership(
):
if row is None:
raise NotFoundError("业务不存在")
business, _, _, _ = row
if payload.new_owner_personnel_id is not None:
business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_assistant_personnel_id is not None:
if payload.new_owner_personnel_id is not None
else None
)
new_assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
@@ -592,7 +597,16 @@ def change_ownership(
operator_name=operator.username,
),
)
out = _to_out(business_repository.get_with_names(session, business_id))
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
out = _to_out(
(
business,
row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",