六个 service 各自复制的 _is_duplicate_entry(口径已漂移:rating/product 用 _ERR_DUP_ENTRY 常量、其余四处写字面量 1062)收敛为共享函数; is_duplicate_entry 语义与文案注释各处保留,非 1062 完整性错误仍原样 上抛由全局处理器收敛。 Co-Authored-By: Claude Code <noreply@anthropic.com>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""产品管理业务编排。
|
||
|
||
产品主数据的两条硬约束(违反会算错钱):
|
||
|
||
- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算
|
||
(对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY
|
||
维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值,
|
||
故值域校验是业务规则而非格式校验
|
||
- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口),
|
||
service 层负责字符串到 Decimal 的转换与落库
|
||
|
||
另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec
|
||
组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。
|
||
删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。
|
||
"""
|
||
|
||
import logging
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.constants import (
|
||
ACTION_CREATE,
|
||
ACTION_DELETE,
|
||
ACTION_DISABLE,
|
||
ACTION_ENABLE,
|
||
ACTION_UPDATE,
|
||
MODULE_PRODUCT,
|
||
PRODUCT_CATEGORIES,
|
||
)
|
||
from app.core.db_errors import is_duplicate_entry
|
||
from app.core.exceptions import BadRequestError, NotFoundError
|
||
from app.core.response import PageResult
|
||
from app.models import Product, User
|
||
from app.repositories import product_repository
|
||
from app.schemas.product import (
|
||
ProductCreateIn,
|
||
ProductOut,
|
||
ProductQuery,
|
||
ProductUpdateIn,
|
||
)
|
||
from app.services import audit_service
|
||
from app.services.audit_service import AuditRecord, RequestMeta
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _to_out(product: Product) -> ProductOut:
|
||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||
return ProductOut(
|
||
id=product.id,
|
||
product_name=product.product_name,
|
||
spec_model=product.spec_model,
|
||
unit=product.unit,
|
||
stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None,
|
||
sale_price=str(product.sale_price) if product.sale_price is not None else None,
|
||
product_category=product.product_category,
|
||
status=product.status,
|
||
remark=product.remark,
|
||
create_time=product.create_time,
|
||
update_time=product.update_time,
|
||
)
|
||
|
||
|
||
def _to_decimal(value: str | None) -> Decimal | None:
|
||
"""数字字符串转 Decimal(可空列的空串/None 透传 None)。
|
||
|
||
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
|
||
二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析,
|
||
此处不再重复校验。
|
||
"""
|
||
if not value:
|
||
return None
|
||
return Decimal(str(value))
|
||
|
||
|
||
def _ensure_category(product_category: str) -> None:
|
||
"""类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。"""
|
||
if product_category not in PRODUCT_CATEGORIES:
|
||
raise BadRequestError("产品类别不合法")
|
||
|
||
|
||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||
|
||
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
|
||
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
|
||
"""
|
||
return is_duplicate_entry(exc)
|
||
|
||
|
||
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
|
||
if _is_duplicate_entry(exc):
|
||
raise BadRequestError("已存在同名称同规格的产品") from exc
|
||
raise exc
|
||
|
||
|
||
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||
"""分页查询产品(含停用)。"""
|
||
products = product_repository.list_products(
|
||
session,
|
||
offset=query.offset,
|
||
limit=query.page_size,
|
||
keyword=query.keyword,
|
||
product_category=query.product_category,
|
||
status=query.status,
|
||
)
|
||
total = product_repository.count_products(
|
||
session,
|
||
keyword=query.keyword,
|
||
product_category=query.product_category,
|
||
status=query.status,
|
||
)
|
||
return PageResult[ProductOut](
|
||
records=[_to_out(product) for product in products],
|
||
total=total,
|
||
page_num=query.page_num,
|
||
page_size=query.page_size,
|
||
)
|
||
|
||
|
||
def get_product(session: Session, product_id: int) -> ProductOut:
|
||
"""产品详情。
|
||
|
||
:raises NotFoundError: 产品不存在
|
||
"""
|
||
product = product_repository.get_by_id(session, product_id)
|
||
if product is None:
|
||
raise NotFoundError("产品不存在")
|
||
return _to_out(product)
|
||
|
||
|
||
def create_product(
|
||
session: Session,
|
||
payload: ProductCreateIn,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> ProductOut:
|
||
"""新增产品。类别须在合法值域内,新产品固定启用。
|
||
|
||
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
|
||
"""
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_PRODUCT,
|
||
action=ACTION_CREATE,
|
||
summary=f"新增产品 {payload.product_name}",
|
||
request_data=payload.model_dump(exclude_none=True),
|
||
),
|
||
meta=meta,
|
||
):
|
||
# 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记)
|
||
_ensure_category(payload.product_category)
|
||
spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重
|
||
if (
|
||
product_repository.get_by_name_spec(session, payload.product_name, spec_model)
|
||
is not None
|
||
):
|
||
raise BadRequestError("已存在同名称同规格的产品")
|
||
|
||
product = Product(
|
||
product_name=payload.product_name,
|
||
spec_model=spec_model,
|
||
unit=payload.unit,
|
||
stock_quantity=_to_decimal(payload.stock_quantity),
|
||
sale_price=_to_decimal(payload.sale_price),
|
||
product_category=payload.product_category,
|
||
status=1,
|
||
remark=payload.remark,
|
||
)
|
||
try:
|
||
product_repository.create(session, product)
|
||
# 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明)
|
||
out = _to_out(product)
|
||
session.commit()
|
||
except IntegrityError as exc:
|
||
# 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec
|
||
session.rollback()
|
||
_raise_if_duplicate(exc)
|
||
|
||
logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category)
|
||
return out
|
||
|
||
|
||
def update_product(
|
||
session: Session,
|
||
product_id: int,
|
||
payload: ProductUpdateIn,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> ProductOut:
|
||
"""编辑产品基本信息(全量替换,不含状态;启停走独立端点)。
|
||
|
||
:raises NotFoundError: 产品不存在
|
||
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
|
||
"""
|
||
product = product_repository.get_by_id(session, product_id)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_PRODUCT,
|
||
action=ACTION_UPDATE,
|
||
summary=f"修改产品 {product.product_name if product else f'#{product_id}'}",
|
||
request_data=payload.model_dump(exclude_none=True),
|
||
),
|
||
meta=meta,
|
||
):
|
||
# 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径)
|
||
_ensure_category(payload.product_category)
|
||
if product is None:
|
||
raise NotFoundError("产品不存在")
|
||
|
||
product.product_name = payload.product_name
|
||
product.spec_model = payload.spec_model or ""
|
||
product.unit = payload.unit
|
||
product.stock_quantity = _to_decimal(payload.stock_quantity)
|
||
product.sale_price = _to_decimal(payload.sale_price)
|
||
product.product_category = payload.product_category
|
||
product.remark = payload.remark
|
||
out = _to_out(product)
|
||
try:
|
||
session.commit()
|
||
except IntegrityError as exc:
|
||
# 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合)
|
||
session.rollback()
|
||
_raise_if_duplicate(exc)
|
||
|
||
logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category)
|
||
return out
|
||
|
||
|
||
def set_status(
|
||
session: Session,
|
||
product_id: int,
|
||
status: int,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> ProductOut:
|
||
"""启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。
|
||
|
||
:raises NotFoundError: 产品不存在
|
||
"""
|
||
product = product_repository.get_by_id(session, product_id)
|
||
verb = "启用" if status == 1 else "停用"
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_PRODUCT,
|
||
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
||
summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}",
|
||
request_data={"status": status},
|
||
),
|
||
meta=meta,
|
||
):
|
||
if product is None:
|
||
raise NotFoundError("产品不存在")
|
||
|
||
product.status = status
|
||
out = _to_out(product)
|
||
session.commit()
|
||
logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status)
|
||
return out
|
||
|
||
|
||
def delete_product(
|
||
session: Session,
|
||
product_id: int,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> None:
|
||
"""物理删除产品。
|
||
|
||
**被业务明细(发货/回款)引用的保护检查待业务数据表(V5)就位后补充**;
|
||
当前先以 ``FOR UPDATE`` 锁行删除 —— V5 接入后,校验与删除之间的并发
|
||
引用会因锁可见地失败,而非引用被静默悬空。
|
||
|
||
:raises NotFoundError: 产品不存在
|
||
"""
|
||
product = product_repository.get_by_id(session, product_id, for_update=True)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_PRODUCT,
|
||
action=ACTION_DELETE,
|
||
summary=f"删除产品 {product.product_name if product else f'#{product_id}'}",
|
||
),
|
||
meta=meta,
|
||
):
|
||
if product is None:
|
||
raise NotFoundError("产品不存在")
|
||
|
||
product_name = product.product_name # 固化:对象随删除失效
|
||
product_repository.delete(session, product)
|
||
session.commit()
|
||
logger.info("删除产品 | product_name=%s", product_name)
|