feat/m3-increments #1
@ -96,7 +96,7 @@ def delete_customer(
|
||||
) -> ApiResponse[None]:
|
||||
"""删除客户(物理删除)。
|
||||
|
||||
被业务数据引用的保护检查待业务数据表(V5)就位后补充。
|
||||
已存在关联业务时返回 400(先处理相关业务单据)。
|
||||
"""
|
||||
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
|
||||
return ok(message="客户已删除")
|
||||
|
||||
@ -91,7 +91,7 @@ def delete_product(
|
||||
) -> ApiResponse[None]:
|
||||
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||
|
||||
被业务明细引用的保护检查待业务数据表(V5)就位后补充。
|
||||
被业务明细/发货明细引用时返回 400(引导改走停用)。
|
||||
"""
|
||||
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
|
||||
return ok(message="产品已删除")
|
||||
|
||||
@ -12,6 +12,7 @@ import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||
@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
|
||||
return _build_response(exc.status_code, message)
|
||||
|
||||
|
||||
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
|
||||
"""完整性错误全局兜底为 400。
|
||||
|
||||
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
|
||||
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
|
||||
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
|
||||
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
|
||||
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
|
||||
"""
|
||||
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
|
||||
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||
|
||||
|
||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底处理未预期异常。
|
||||
|
||||
@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Product
|
||||
from app.models import BusinessItem, Product, ShipmentItem
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -100,6 +100,25 @@ def _apply_filters(
|
||||
return stmt
|
||||
|
||||
|
||||
def count_refs(session: Session, product_id: int) -> int:
|
||||
"""统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。
|
||||
|
||||
两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与
|
||||
删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。
|
||||
"""
|
||||
stmt = select(
|
||||
select(func.count())
|
||||
.select_from(BusinessItem)
|
||||
.where(BusinessItem.product_id == product_id)
|
||||
.scalar_subquery()
|
||||
+ select(func.count())
|
||||
.select_from(ShipmentItem)
|
||||
.where(ShipmentItem.product_id == product_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
@ -111,6 +130,6 @@ def create(session: Session, product: Product) -> Product:
|
||||
|
||||
|
||||
def delete(session: Session, product: Product) -> None:
|
||||
"""物理删除产品。调用方须先完成引用保护校验(V5 就位后生效)。"""
|
||||
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
|
||||
session.delete(product)
|
||||
session.flush()
|
||||
|
||||
@ -7,29 +7,11 @@ Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
def _parse_non_negative(value: str, *, label: str) -> str:
|
||||
"""非负数字字符串校验:必须可被 Decimal 解析、为有限数且 ≥ 0。
|
||||
|
||||
``NaN`` 参与比较会抛 InvalidOperation、``Infinity`` 会在后续运算溢出,
|
||||
均须在入口用 ``is_finite()`` 拦下(与 schemas/param.py 的 _parse_amount
|
||||
同一口径)。
|
||||
"""
|
||||
try:
|
||||
amount = Decimal(value.strip())
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"{label}必须是有效数字:{value}") from exc
|
||||
if not amount.is_finite():
|
||||
raise ValueError(f"{label}必须为有限数字:{value}")
|
||||
if amount < 0:
|
||||
raise ValueError(f"{label}不得为负数:{value}")
|
||||
return value
|
||||
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
|
||||
|
||||
|
||||
class ProductOut(BaseModel):
|
||||
@ -70,18 +52,27 @@ class ProductCreateIn(BaseModel):
|
||||
@field_validator("stock_quantity")
|
||||
@classmethod
|
||||
def _check_stock_quantity(cls, value: str | None) -> str | None:
|
||||
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
|
||||
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||
|
||||
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
|
||||
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
|
||||
字段两处口径漂移)。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
return _parse_non_negative(value, label="数量")
|
||||
return _check_amount(value, "数量", AMOUNT_MAX)
|
||||
|
||||
@field_validator("sale_price")
|
||||
@classmethod
|
||||
def _check_sale_price(cls, value: str | None) -> str | None:
|
||||
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
|
||||
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||
|
||||
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
|
||||
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
return _parse_non_negative(value, label="销售单价")
|
||||
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
|
||||
|
||||
|
||||
class ProductUpdateIn(ProductCreateIn):
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
|
||||
|
||||
|
||||
72
backend/tests/unit/test_product_schema.py
Normal file
72
backend/tests/unit/test_product_schema.py
Normal file
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user