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:
parent
a0086554b1
commit
6d612daf30
@ -7,7 +7,7 @@ models/customer.py 定义)。
|
||||
|
||||
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 app.models import (
|
||||
@ -17,18 +17,35 @@ from app.models import (
|
||||
Customer,
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
|
||||
|
||||
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)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
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:
|
||||
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
|
||||
session.add(business)
|
||||
@ -36,19 +53,26 @@ def create(session: Session, business: Business) -> None:
|
||||
|
||||
|
||||
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:
|
||||
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。"""
|
||||
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
|
||||
|
||||
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
|
||||
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
|
||||
「有发货禁换客户」的快照读校验不再被并发新增旁路。
|
||||
"""
|
||||
owner = aliased(Personnel)
|
||||
assistant = aliased(Personnel)
|
||||
row = session.execute(
|
||||
stmt = (
|
||||
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
|
||||
.join(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -362,6 +362,31 @@ class TestEdit:
|
||||
]
|
||||
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:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
@ -627,7 +627,19 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<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">
|
||||
<span class="op-wrap">
|
||||
<el-button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user