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 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(
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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:
|
||||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user