fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT 外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构), docstring 与两个 api 层过时注释同步更新 - 产品 schema 复用 schemas/business._check_amount:sale_price 走 (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写 _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4) 静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径 - 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为 400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整 异常记日志;rating/customer 的「非 1062 按 500」注释同步更正 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
9a70264b51
commit
91fc6f6293
11 files changed
+243
-40
No files matched your search
@@ -380,16 +380,17 @@ def update_business(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
business, company_name, owner_name, assistant_name = row
|
||||
business, _, owner_name, assistant_name = row
|
||||
|
||||
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
|
||||
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
|
||||
|
||||
if payload.customer_id != business.customer_id:
|
||||
if business_repository.has_docs(session, business_id):
|
||||
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
|
||||
if payload.customer_id != business.customer_id and business_repository.has_docs(
|
||||
session, business_id
|
||||
):
|
||||
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
|
||||
customer = _ensure_customer(session, payload.customer_id)
|
||||
if payload.owner_personnel_id is not None:
|
||||
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
|
||||
|
||||
@@ -113,7 +113,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
|
||||
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
|
||||
非 1062 原样上抛,由全局处理器按 500 收敛。
|
||||
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
|
||||
"""
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
@@ -278,13 +278,14 @@ def delete_product(
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> None:
|
||||
"""物理删除产品。
|
||||
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||
|
||||
**被业务明细(发货/回款)引用的保护检查待业务数据表(V5)就位后补充**;
|
||||
当前先以 ``FOR UPDATE`` 锁行删除 —— V5 接入后,校验与删除之间的并发
|
||||
引用会因锁可见地失败,而非引用被静默悬空。
|
||||
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
|
||||
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
|
||||
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
|
||||
|
||||
:raises NotFoundError: 产品不存在
|
||||
:raises BadRequestError: 产品已被业务或发货明细引用
|
||||
"""
|
||||
product = product_repository.get_by_id(session, product_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
@@ -299,8 +300,15 @@ def delete_product(
|
||||
):
|
||||
if product is None:
|
||||
raise NotFoundError("产品不存在")
|
||||
if product_repository.count_refs(session, product_id):
|
||||
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
|
||||
|
||||
product_name = product.product_name # 固化:对象随删除失效
|
||||
product_repository.delete(session, product)
|
||||
session.commit()
|
||||
try:
|
||||
product_repository.delete(session, product)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
|
||||
session.rollback()
|
||||
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
|
||||
logger.info("删除产品 | product_name=%s", product_name)
|
||||
@@ -105,8 +105,9 @@ def _ensure_no_overlap(
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||||
|
||||
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
|
||||
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
|
||||
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
|
||||
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
|
||||
500 只留给程序缺陷。
|
||||
"""
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
Reference in new issue
Block a user