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
@@ -30,6 +30,8 @@ from app.models import OperationLog
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_PRODUCTS = "/api/v1/base/products"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
|
||||
|
||||
def _pname() -> str:
|
||||
@@ -67,6 +69,48 @@ def _logs_of(username: str) -> list[OperationLog]:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_business_for_product(client: TestClient):
|
||||
"""造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。
|
||||
|
||||
business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源;
|
||||
客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。
|
||||
"""
|
||||
|
||||
def _factory(headers: dict, product_id: int) -> dict:
|
||||
customer = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert customer.status_code == 200, customer.text
|
||||
business = client.post(
|
||||
_BUSINESSES,
|
||||
json={
|
||||
"customer_id": customer.json()["data"]["id"],
|
||||
"business_date": "2026-01-01",
|
||||
"items": [
|
||||
{
|
||||
"product_id": product_id,
|
||||
"product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}",
|
||||
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert business.status_code == 200, business.text
|
||||
return business.json()["data"]
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
class TestAuth:
|
||||
"""鉴权:登录后接口,裸访问一律 401。"""
|
||||
|
||||
@@ -120,6 +164,58 @@ class TestCrudFlow:
|
||||
assert "产品不存在" in detail.json()["message"]
|
||||
|
||||
|
||||
class TestDeleteProtection:
|
||||
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
|
||||
|
||||
def test_被业务明细引用_400且产品仍在(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
business = make_business_for_product(normal["headers"], product["id"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "引用" in resp.json()["message"]
|
||||
# 产品仍在(引导改走停用而非物理删除)
|
||||
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
assert still.status_code == 200
|
||||
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
|
||||
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
assert detail["items"][0]["product_id"] == product["id"]
|
||||
|
||||
def test_被发货明细引用_400(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
make_business_for_product(normal["headers"], product["id"])
|
||||
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
|
||||
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
business_id = businesses["list"][0]["id"]
|
||||
shipment = client.post(
|
||||
"/api/v1/business/shipments",
|
||||
json={
|
||||
"business_id": business_id,
|
||||
"ship_date": "2026-01-01",
|
||||
"items": [
|
||||
{
|
||||
"product_id": product["id"],
|
||||
"product_name": product["product_name"],
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=normal["headers"],
|
||||
)
|
||||
assert shipment.status_code == 200, shipment.text
|
||||
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
class TestValidationRules:
|
||||
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""产品 schema 的金额口径单测(秒级,无外部依赖)。
|
||||
|
||||
守:产品 sale_price/stock_quantity 若私写校验不查小数位与容量,5 位小数
|
||||
会被 Numeric 列静默截断(舍入即算错钱)、超容量会以 500 暴露 —— 校验
|
||||
口径必须与 schemas/business._check_amount 一致(shipment/receipt 同源)。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.product import ProductCreateIn
|
||||
|
||||
|
||||
class TestSalePrice:
|
||||
"""单价校验:scale=4 对齐 Numeric(14,4) 列。"""
|
||||
|
||||
def test_四位小数通过_回显原样提交(self) -> None:
|
||||
"""带精度的历史单价必须能原样提交(fba8cfc 教训:编辑被永远拒收)。"""
|
||||
body = ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
sale_price="5000.1234",
|
||||
)
|
||||
assert body.sale_price == "5000.1234"
|
||||
|
||||
def test_五位小数拒绝而非静默截断(self) -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
sale_price="1.23456",
|
||||
)
|
||||
assert "最多四位小数" in str(exc.value)
|
||||
|
||||
def test_超容量拒绝(self) -> None:
|
||||
"""DECIMAL(14,4) 整数部分 10 位:超出即拒绝而非溢出 500。"""
|
||||
with pytest.raises(ValidationError):
|
||||
ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
sale_price="99999999999.0000",
|
||||
)
|
||||
|
||||
def test_空串归一None_负数拒绝(self) -> None:
|
||||
empty = ProductCreateIn(
|
||||
product_name="t_产品", product_category="municipal_water", sale_price=""
|
||||
)
|
||||
assert empty.sale_price is None
|
||||
with pytest.raises(ValidationError):
|
||||
ProductCreateIn(
|
||||
product_name="t_产品", product_category="municipal_water", sale_price="-1"
|
||||
)
|
||||
|
||||
|
||||
class TestStockQuantity:
|
||||
"""数量校验:默认 scale=2 对齐 Numeric(14,2) 列。"""
|
||||
|
||||
def test_三位小数拒绝(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
stock_quantity="1.001",
|
||||
)
|
||||
|
||||
def test_两位小数通过(self) -> None:
|
||||
body = ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
stock_quantity="1.55",
|
||||
)
|
||||
assert body.stock_quantity == "1.55"
|
||||
Reference in new issue
Block a user