"""产品管理业务编排。 产品主数据的两条硬约束(违反会算错钱): - ``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)