"""产品管理服务层。 负责产品分类和产品主档(含规格明细)的增删改查业务逻辑。 依赖 ProductRepository 进行数据持久化,依赖 audit_service 记录操作审计日志。 产品采用"产品名称 + 规格"的二级结构,同一产品名称下可有多条规格记录。 """ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from backend.app.core.cache import cache_delete_pattern, cache_get, cache_set, make_cache_key from backend.app.core.error_codes import ErrorCode from backend.app.core.exceptions import AppException from backend.app.repositories.product_repository import ProductRepository from backend.app.services.audit_service import audit_service class ProductService: """产品服务,封装产品分类和产品规格的 CRUD 业务逻辑。 依赖: ProductRepository - 产品数据访问 audit_service - 操作审计日志 """ def __init__(self) -> None: self.repository = ProductRepository() def list_categories(self, session: Session | None = None, filters: dict | None = None) -> dict: """查询产品分类列表。 Args: session: 数据库会话 filters: 查询过滤条件 Returns: 包含 total、page_no、page_size、list 的分页字典 被调用路由: products.py - GET /products/categories """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: categories = self.repository.list_categories(session, filters or {}) return { "total": len(categories), "page_no": 1, "page_size": len(categories) or 20, "list": [ { "category_id": category.id, "category_name": category.category_name, "category_code": category.category_code, "sort_no": category.sort_no, "status": category.status, "remark": category.remark, } for category in categories ], } except SQLAlchemyError as exc: raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def create_category(self, payload: dict, session: Session | None = None) -> dict: """创建产品分类。 校验分类名称和编码非空,并检查编码唯一性。自动记录审计日志。 Args: payload: 包含 category_name、category_code 的请求体 session: 数据库会话 Returns: 包含 category_id、category_name、category_code、status 的结果字典 被调用路由: products.py - POST /products/categories """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: if not payload["category_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="分类名称不能为空", status_code=400) if not payload["category_code"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="分类编码不能为空", status_code=400) existed = self.repository.get_category_by_code(session, payload["category_code"]) if existed is not None: raise AppException(code=ErrorCode.DUPLICATE, message="分类编码已存在", status_code=400) category = self.repository.create_category(session, payload) audit_service.write_log( session, { "operate_type": "product_category_create", "biz_type": "product_category", "biz_id": category.id, "before_value": None, "after_value": {"category_name": category.category_name, "category_code": category.category_code}, "remark": f"新增产品分类 {category.category_name}", }, ) session.commit() cache_delete_pattern("product:*") return { "category_id": category.id, "category_name": category.category_name, "category_code": category.category_code, "status": category.status, } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def update_category(self, category_id: int, payload: dict, session: Session | None = None) -> dict: """更新产品分类。 校验分类编码唯一性(排除自身)。 Args: category_id: 分类 ID payload: 包含更新字段的请求体 session: 数据库会话 Returns: 更新结果字典 被调用路由: products.py - PUT /products/categories/{category_id} """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: if not payload["category_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="分类名称不能为空", status_code=400) if not payload["category_code"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="分类编码不能为空", status_code=400) category = self.repository.get_category(session, category_id) if category is None: raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在", status_code=404) existed = self.repository.get_category_by_code(session, payload["category_code"]) if existed is not None and existed.id != category_id: raise AppException(code=ErrorCode.DUPLICATE, message="分类编码已存在", status_code=400) self.repository.update_category(session, category, payload) session.commit() cache_delete_pattern("product:*") return {"category_id": category_id, "updated": True} except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def _group_products(self, products: list, categories: dict[int, object]) -> list[dict]: """将产品规格列表按产品名称分组。 同一产品名称下的多条规格记录合并为一条,规格列表放在 specifications 字段中。 自动将第一条规格标记为默认(若无显式默认)。 Args: products: 产品 ORM 对象列表 categories: 分类 ID 到分类 ORM 对象的映射 Returns: 按产品名称分组后的产品列表 """ grouped: dict[str, dict] = {} for product in products: key = product.product_name category_name = categories[product.category_id].category_name if product.category_id in categories else (product.category or "") entry = grouped.setdefault( key, { "product_name": product.product_name, "product_id": product.id, "category_id": product.category_id, "category_name": category_name, "status": product.status, "remark": product.remark, "specifications": [], }, ) entry["specifications"].append( { "product_id": product.id, "specification": product.specification, "unit": product.unit, "cost_price": float(product.cost_price or 0), "sale_price": float(product.sale_price or 0), "status": product.status, "remark": product.remark, "is_default": bool(getattr(product, "is_default", 0)), } ) for entry in grouped.values(): if entry["specifications"] and not any(spec["is_default"] for spec in entry["specifications"]): entry["specifications"][0]["is_default"] = True return list(grouped.values()) def list_products(self, filters: dict | None = None, session: Session | None = None) -> dict: """查询产品列表(按名称分组)。 先查询所有产品规格,再按产品名称分组聚合。 Args: filters: 查询过滤条件 session: 数据库会话 Returns: 包含 total、page_no、page_size、list 的分页字典 被调用路由: products.py - GET /products """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) cache_key = make_cache_key("product:list", **(filters or {})) cached = cache_get(cache_key) if cached is not None: return cached try: products = self.repository.list_products(session, filters or {}) categories = {category.id: category for category in self.repository.list_categories(session, {})} grouped_list = self._group_products(products, categories) result = { "total": len(grouped_list), "page_no": 1, "page_size": len(grouped_list) or 20, "list": grouped_list, } cache_set(cache_key, result, ttl=120) return result except SQLAlchemyError as exc: raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def update_product(self, product_id: int, payload: dict, session: Session | None = None) -> dict: """更新产品主档信息(名称、分类、状态等)。 仅更新产品级别的公共字段,不影响规格明细。 Args: product_id: 产品 ID payload: 包含更新字段的请求体 session: 数据库会话 Returns: 更新结果字典 被调用路由: products.py - PUT /products/{product_id} """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: product = self.repository.get_product(session, product_id) if product is None: raise AppException(code=ErrorCode.NOT_FOUND, message="产品主档不存在", status_code=404) if not payload["product_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品名称不能为空", status_code=400) category = None if payload.get("category_id") is not None: category = self.repository.get_category(session, payload["category_id"]) if category is None or category.status != 1: raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在或已停用", status_code=404) product.product_name = payload["product_name"] product.category_id = payload.get("category_id") product.category = category.category_name if category else None product.status = payload.get("status", product.status) product.remark = payload.get("remark", product.remark) session.add(product) session.commit() cache_delete_pattern("product:*") return {"product_id": product.id, "product_name": product.product_name, "updated": True} except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def update_product_specification(self, product_id: int, payload: dict, session: Session | None = None) -> dict: """更新产品规格明细。 校验规格在同一产品下唯一,可修改规格名称、单位、价格等。 Args: product_id: 产品(规格) ID payload: 包含 specification、unit、cost_price、sale_price 等的请求体 session: 数据库会话 Returns: 更新结果字典 被调用路由: products.py - PUT /products/{product_id}/specification """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: product = self.repository.get_product(session, product_id) if product is None: raise AppException(code=ErrorCode.NOT_FOUND, message="产品规格不存在", status_code=404) if not payload["specification"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品规格不能为空", status_code=400) if not payload["unit"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品单位不能为空", status_code=400) existed = self.repository.get_product_by_name_and_specification(session, product.product_name, payload["specification"]) if existed is not None and existed.id != product_id: raise AppException(code=ErrorCode.DUPLICATE, message="同一产品下的规格不能重复", status_code=400) category = None if payload.get("category_id") is not None: category = self.repository.get_category(session, payload["category_id"]) if category is None or category.status != 1: raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在或已停用", status_code=404) product.specification = payload["specification"] product.unit = payload["unit"] product.category_id = payload.get("category_id") product.category = category.category_name if category else product.category product.cost_price = payload.get("cost_price", product.cost_price) product.sale_price = payload.get("sale_price", product.sale_price) product.status = payload.get("status", product.status) product.remark = payload.get("remark", product.remark) session.add(product) session.commit() cache_delete_pattern("product:*") return {"product_id": product.id, "product_name": product.product_name, "updated": True} except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def add_specification(self, product_id: int, payload: dict, session: Session | None = None) -> dict: """给已有产品追加一条规格明细。 根据 product_id 找到所属产品,校验规格名不重复后创建新规格记录。 Args: product_id: 产品组中任一规格的 ID payload: 包含 specification、unit、cost_price、sale_price 等的请求体 session: 数据库会话 Returns: 创建结果字典 被调用路由: products.py - POST /products/{product_id}/specifications """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: product = self.repository.get_product(session, product_id) if product is None: raise AppException(code=ErrorCode.NOT_FOUND, message="产品不存在", status_code=404) if not payload.get("specification", "").strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品规格不能为空", status_code=400) if not payload.get("unit", "").strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品单位不能为空", status_code=400) existed = self.repository.get_product_by_name_and_specification( session, product.product_name, payload["specification"] ) if existed is not None: raise AppException(code=ErrorCode.DUPLICATE, message="同一产品下的规格不能重复", status_code=400) category = None if payload.get("category_id") is not None: category = self.repository.get_category(session, payload["category_id"]) new_spec = self.repository.create_product(session, { "product_name": product.product_name, "specification": payload["specification"], "unit": payload["unit"], "category_id": payload.get("category_id") or product.category_id, "category": category.category_name if category else product.category, "cost_price": payload.get("cost_price", 0), "sale_price": payload.get("sale_price", 0), "status": payload.get("status", 1), "remark": payload.get("remark"), }) audit_service.write_log(session, { "operate_type": "product_spec_add", "biz_type": "product", "biz_id": new_spec.id, "after_value": {"product_name": product.product_name, "specification": payload["specification"]}, "remark": f"给产品 {product.product_name} 新增规格 {payload['specification']}", }) cache_delete_pattern("product:*") return {"product_id": new_spec.id, "product_name": product.product_name, "specification": new_spec.specification} except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def set_default_specification(self, product_id: int, session: Session | None = None) -> dict: """设置产品默认规格。 将指定规格标记为默认,同名产品下的其他规格清除默认标记。 Args: product_id: 产品(规格) ID session: 数据库会话 Returns: 设置结果字典 被调用路由: products.py - POST /products/{product_id}/set-default """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: product = self.repository.get_product(session, product_id) if product is None: raise AppException(code=ErrorCode.NOT_FOUND, message="产品规格不存在", status_code=404) siblings = self.repository.list_products(session, {"product_name": product.product_name}) for sibling in siblings: sibling.is_default = 1 if sibling.id == product.id else 0 session.add(sibling) session.flush() session.commit() cache_delete_pattern("product:*") return {"product_id": product.id, "product_name": product.product_name, "is_default": True} except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def create_product(self, payload: dict, session: Session | None = None) -> dict: """创建新产品(含规格明细)。 产品名称为公共字段,每条规格明细单独创建一条数据库记录。 同一产品下的规格名称不可重复。自动记录审计日志。 Args: payload: 包含 product_name、category_id、specifications 列表的请求体 session: 数据库会话 Returns: 创建结果字典,包含产品名称和所有规格明细 被调用路由: products.py - POST /products """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) try: if not payload["product_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品名称不能为空", status_code=400) specifications = payload.get("specifications") or [] if not specifications: raise AppException(code=ErrorCode.PARAM_ERROR, message="请至少填写一个规格明细", status_code=400) category = None if payload.get("category_id") is not None: category = self.repository.get_category(session, payload["category_id"]) if category is None or category.status != 1: raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在或已停用", status_code=404) created_specs = [] for spec in specifications: if not spec["specification"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品规格不能为空", status_code=400) if not spec["unit"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品单位不能为空", status_code=400) existed = self.repository.get_product_by_name_and_specification( session, payload["product_name"], spec["specification"], ) if existed is not None: raise AppException(code=ErrorCode.DUPLICATE, message="同一产品下的规格不能重复", status_code=400) product = self.repository.create_product( session, { "product_name": payload["product_name"], "specification": spec["specification"], "unit": spec["unit"], "category_id": spec.get("category_id") or payload.get("category_id"), "category": category.category_name if category else None, "cost_price": spec.get("cost_price", 0), "sale_price": spec.get("sale_price", 0), "status": spec.get("status", payload.get("status", 1)), "remark": spec.get("remark") or payload.get("remark"), }, ) created_specs.append(product) audit_service.write_log( session, { "operate_type": "product_create", "biz_type": "product", "biz_id": created_specs[0].id if created_specs else 0, "before_value": None, "after_value": { "product_name": payload["product_name"], "spec_count": len(created_specs), "category_id": payload.get("category_id"), }, "remark": f"新增产品 {payload['product_name']},共 {len(created_specs)} 个规格", }, ) session.commit() cache_delete_pattern("product:*") return { "product_name": payload["product_name"], "product_id": created_specs[0].id if created_specs else None, "specifications": [ { "product_id": item.id, "specification": item.specification, "unit": item.unit, "cost_price": float(item.cost_price or 0), "sale_price": float(item.sale_price or 0), "status": item.status, "remark": item.remark, "is_default": index == 0, } for index, item in enumerate(created_specs) ], } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc def get_product(self, product_id: int, session: Session | None = None) -> dict: """查询单个产品详情(含所有规格明细)。 通过产品名称查询同名所有规格,再按分组逻辑返回完整产品信息。 Args: product_id: 产品(规格) ID session: 数据库会话 Returns: 产品详情字典(含 specifications 列表) 被调用路由: products.py - GET /products/{product_id} """ if session is None: raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500) cache_key = f"product:detail:{product_id}" cached = cache_get(cache_key) if cached is not None: return cached try: product = self.repository.get_product(session, product_id) if product is None: raise AppException(code=ErrorCode.NOT_FOUND, message="产品不存在", status_code=404) products = self.repository.list_products(session, {"product_name": product.product_name}) categories = {category.id: category for category in self.repository.list_categories(session, {})} grouped = self._group_products(products, categories) if grouped: cache_set(cache_key, grouped[0], ttl=120) return grouped[0] raise AppException(code=ErrorCode.NOT_FOUND, message="产品不存在", status_code=404) except AppException: raise except SQLAlchemyError as exc: raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc product_service = ProductService()