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:
liuwenxin 2026-10-14 17:30:21 +08:00
parent a0086554b1
commit 6d612daf30
4 changed files with 108 additions and 33 deletions

View File

@ -7,7 +7,7 @@ models/customer.py 定义)。
from datetime import date from datetime import date
from sqlalchemy import Row, Select, delete, func, or_, select from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.models import ( from app.models import (
@ -17,18 +17,35 @@ from app.models import (
Customer, Customer,
OwnershipChangeLog, OwnershipChangeLog,
Personnel, Personnel,
Receipt,
Shipment,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
"""按主键取业务行;for_update 供删除保护与状态机使用。""" """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
stmt = select(Business).where(Business.id == business_id) stmt = select(Business).where(Business.id == business_id)
if for_update: if for_update:
stmt = stmt.with_for_update() stmt = stmt.with_for_update()
return session.execute(stmt).scalars().first() return session.execute(stmt).scalars().first()
def has_docs(session: Session, business_id: int) -> bool:
"""业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。
单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件,
编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。
"""
stmt = select(
or_(
exists().where(Shipment.business_id == business_id),
exists().where(Receipt.business_id == business_id),
)
)
return bool(session.execute(stmt).scalar())
def create(session: Session, business: Business) -> None: def create(session: Session, business: Business) -> None:
"""新增业务行(不 commit,flush 让自增 id 就位)。""" """新增业务行(不 commit,flush 让自增 id 就位)。"""
session.add(business) session.add(business)
@ -36,19 +53,26 @@ def create(session: Session, business: Business) -> None:
def get_with_names( def get_with_names(
session: Session, business_id: int session: Session, business_id: int, *, for_update: bool = False
) -> tuple[Business, str, str | None, str | None] | None: ) -> tuple[Business, str, str | None, str | None] | None:
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。""" """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
「有发货禁换客户」的快照读校验不再被并发新增旁路。
"""
owner = aliased(Personnel) owner = aliased(Personnel)
assistant = aliased(Personnel) assistant = aliased(Personnel)
row = session.execute( stmt = (
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name) select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
.join(Customer, Customer.id == Business.customer_id) .join(Customer, Customer.id == Business.customer_id)
.outerjoin(owner, owner.id == Business.owner_personnel_id) .outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.where(Business.id == business_id) .where(Business.id == business_id)
).first() )
return row if for_update:
stmt = stmt.with_for_update()
return session.execute(stmt).first()
def _apply_filters( def _apply_filters(

View File

@ -363,9 +363,9 @@ def update_business(
发货/回款语义断裂(明细同理随业务走)。 发货/回款语义断裂(明细同理随业务走)。
:raises NotFoundError: 业务不存在 :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 business = row[0] if row else None
with audit_service.audit( with audit_service.audit(
session, session,
@ -380,18 +380,17 @@ def update_business(
): ):
if row is None: if row is None:
raise NotFoundError("业务不存在") 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: if payload.customer_id != business.customer_id:
has_docs = session.query(Shipment.id).filter( if business_repository.has_docs(session, business_id):
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:
raise BadRequestError("业务已有发货或回款记录,不能更换客户") raise BadRequestError("业务已有发货或回款记录,不能更换客户")
_ensure_customer(session, payload.customer_id) customer = _ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None: if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责") _ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None: 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) session, business_id, _build_item_rows(business_id, payload.items)
) )
_recalc_totals(session, business) _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() session.commit()
logger.info("修改业务 | business_id=%s", business_id) logger.info("修改业务 | business_id=%s", business_id)
return out return out
@ -442,12 +443,7 @@ def delete_business(
): ):
if business is None: if business is None:
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
if ( if business_repository.has_docs(session, business_id):
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
):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
business_no = business.business_no business_no = business.business_no
@ -484,7 +480,7 @@ def change_status(
:raises NotFoundError: 业务不存在 :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)
with audit_service.audit( with audit_service.audit(
session, session,
operator=operator, operator=operator,
@ -522,7 +518,8 @@ def change_status(
operator_name=operator.username, operator_name=operator.username,
), ),
) )
out = _to_out(business_repository.get_with_names(session, business_id)) # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
out = _to_out(row)
session.commit() session.commit()
logger.info( logger.info(
"业务状态变更 | business_id=%s | %s -> %s", "业务状态变更 | business_id=%s | %s -> %s",
@ -549,7 +546,7 @@ def change_ownership(
:raises NotFoundError: 业务不存在 :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)
with audit_service.audit( with audit_service.audit(
session, session,
operator=operator, operator=operator,
@ -563,11 +560,19 @@ def change_ownership(
): ):
if row is None: if row is None:
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
business, _, _, _ = row business, _, old_owner_name, old_assistant_name = row
if payload.new_owner_personnel_id is not None: # 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责") _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, "协助") _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 owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
@ -592,7 +597,16 @@ def change_ownership(
operator_name=operator.username, 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() session.commit()
logger.info( logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",

View File

@ -362,6 +362,31 @@ class TestEdit:
] ]
assert logs["total"] == 0, "编辑端点改归属却无留痕" assert logs["total"] == 0, "编辑端点改归属却无留痕"
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(client, headers, customer["id"])
_terminate(client, headers, data["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
items=[_item(quantity="999", sale_price="999.00")],
),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "已完成或已终止" in resp.json()["message"]
# 明细与汇总未被改写(护栏:校验在 replace_items 之前生效)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert Decimal(detail["total_sale_amount"]) == Decimal("10000")
assert len(detail["items"]) == 1
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
class TestStatus: class TestStatus:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""

View File

@ -627,7 +627,19 @@ onMounted(() => {
<el-table-column label="操作" width="300" fixed="right"> <el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button> <el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button> <el-tooltip content="已完成或已终止的业务禁止编辑" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top"> <el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top">
<span class="op-wrap"> <span class="op-wrap">
<el-button <el-button