diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py index 7827b4f..26b6d67 100644 --- a/backend/app/api/v1/base/customers.py +++ b/backend/app/api/v1/base/customers.py @@ -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="客户已删除") diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py index fce3f27..4255248 100644 --- a/backend/app/api/v1/base/products.py +++ b/backend/app/api/v1/base/products.py @@ -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="产品已删除") diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py index 78fc7bb..0436def 100644 --- a/backend/app/core/exception_handlers.py +++ b/backend/app/core/exception_handlers.py @@ -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) diff --git a/backend/app/repositories/product_repository.py b/backend/app/repositories/product_repository.py index b399362..1dbaf61 100644 --- a/backend/app/repositories/product_repository.py +++ b/backend/app/repositories/product_repository.py @@ -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() diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 67e6639..cf44256 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -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): diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 5261f9b..85ca8d4 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -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, "负责") diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index 14cf578..d8243eb 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -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) diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 066791e..38e70bb 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -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) diff --git a/backend/app/services/rating_service.py b/backend/app/services/rating_service.py index 05d9071..32d73be 100644 --- a/backend/app/services/rating_service.py +++ b/backend/app/services/rating_service.py @@ -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) diff --git a/backend/tests/integration/test_products.py b/backend/tests/integration/test_products.py index 4789fbd..d940002 100644 --- a/backend/tests/integration/test_products.py +++ b/backend/tests/integration/test_products.py @@ -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: """类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。""" diff --git a/backend/tests/unit/test_product_schema.py b/backend/tests/unit/test_product_schema.py new file mode 100644 index 0000000..6c1cfa2 --- /dev/null +++ b/backend/tests/unit/test_product_schema.py @@ -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"