From bfa0d50597e403bd2fea95d7a7facb3c57cf077f Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 28 Jul 2026 09:21:31 +0800 Subject: [PATCH] feat(crm): persist product brands and aliases on create/update Co-authored-by: Cursor --- backend/app/api/crm.py | 8 +- backend/app/crud/crm/product.py | 292 ++++++++++++++++++++++++++++- backend/app/schemas/crm/product.py | 25 +++ 3 files changed, 318 insertions(+), 7 deletions(-) diff --git a/backend/app/api/crm.py b/backend/app/api/crm.py index cd6e752ad..a9d273fde 100644 --- a/backend/app/api/crm.py +++ b/backend/app/api/crm.py @@ -34,7 +34,6 @@ from app.utils.crm_export_columns import ( from app.utils.excel_export import export_rows_to_xlsx_response from app.crud.crm.product import ( _category_row_to_dict, - _product_row_to_dict, create_category, create_product, get_category, @@ -44,6 +43,7 @@ from app.crud.crm.product import ( list_product_options, list_products, product_no_exists, + product_to_dict, soft_delete_category, soft_delete_product, total_pages, @@ -624,7 +624,7 @@ def product_detail( if not obj: return error_response(404, "产品不存在") ensure_crm_create_by_row_in_scope(db, obj, user_info, tenant_no, message="无权访问该产品") - return success_response(_product_row_to_dict(obj)) + return success_response(product_to_dict(db, obj)) except ValueError as e: return error_response(400, str(e)) except Exception as e: @@ -648,7 +648,7 @@ def product_add( user_id=user_id, **body.model_dump(exclude_unset=True), ) - return success_response(_product_row_to_dict(obj), message="创建成功") + return success_response(product_to_dict(db, obj), message="创建成功") except ValueError as e: return error_response(400, str(e)) except Exception as e: @@ -669,7 +669,7 @@ def product_update( tenant_no = get_tenant_no(db, _user) user_info = crm_user_info(_user) obj = update_product(db, product_id, user_id=user_id, user_info=user_info, **data) - return success_response(_product_row_to_dict(obj), message="更新成功") + return success_response(product_to_dict(db, obj), message="更新成功") except ValueError as e: return error_response(400, str(e)) except Exception as e: diff --git a/backend/app/crud/crm/product.py b/backend/app/crud/crm/product.py index 4fbf965ff..766116c03 100644 --- a/backend/app/crud/crm/product.py +++ b/backend/app/crud/crm/product.py @@ -15,7 +15,15 @@ from app.crud.crm.data_scope_helpers import ( apply_crm_create_by_scope, ensure_crm_create_by_row_in_scope, ) -from app.models.crm_models import CrmProduct, CrmProductCategory, SysOss +from app.models.crm_models import ( + CrmProduct, + CrmProductAlias, + CrmProductBrand, + CrmProductCategory, + SysOss, +) +from app.models.pl_models import PlInventory, PlPlan, PlPlanProduct +from fastapi import HTTPException # ── 工具函数 ────────────────────────────────────────────────── @@ -282,7 +290,232 @@ def category_code_exists( # ── 产品 CRUD ──────────────────────────────────────────────── -def _product_row_to_dict(row: CrmProduct) -> Dict[str, Any]: +def _brand_row_to_dict(row: CrmProductBrand) -> Dict[str, Any]: + return { + "id": row.id, + "brandName": row.brand_name, + "status": row.status or "on", + "orderNum": row.order_num if row.order_num is not None else 0, + } + + +def _alias_row_to_dict(row: CrmProductAlias) -> Dict[str, Any]: + return { + "id": row.id, + "aliasName": row.alias_name, + "orderNum": row.order_num if row.order_num is not None else 0, + } + + +def _load_brands_aliases_maps( + db: Session, product_ids: List[str], tenant_no: str +) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, List[Dict[str, Any]]]]: + brands_map: Dict[str, List[Dict[str, Any]]] = {pid: [] for pid in product_ids} + aliases_map: Dict[str, List[Dict[str, Any]]] = {pid: [] for pid in product_ids} + if not product_ids: + return brands_map, aliases_map + brand_rows = ( + db.query(CrmProductBrand) + .filter( + CrmProductBrand.product_id.in_(product_ids), + CrmProductBrand.tenant_no == tenant_no, + CrmProductBrand.del_flag == 0, + ) + .order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc()) + .all() + ) + for row in brand_rows: + brands_map.setdefault(row.product_id, []).append(_brand_row_to_dict(row)) + alias_rows = ( + db.query(CrmProductAlias) + .filter( + CrmProductAlias.product_id.in_(product_ids), + CrmProductAlias.tenant_no == tenant_no, + CrmProductAlias.del_flag == 0, + ) + .order_by(CrmProductAlias.order_num.asc(), CrmProductAlias.create_time.asc()) + .all() + ) + for row in alias_rows: + aliases_map.setdefault(row.product_id, []).append(_alias_row_to_dict(row)) + return brands_map, aliases_map + + +def _assert_brand_deletable(db: Session, tenant_no: str, brand_id: str, brand_name: str) -> None: + if not brand_id: + return + inv = ( + db.query(PlInventory) + .filter( + PlInventory.tenant_no == tenant_no, + PlInventory.brand_id == brand_id, + PlInventory.del_flag == 0, + PlInventory.stock_qty > 0, + ) + .first() + ) + if inv: + raise HTTPException( + status_code=400, + detail=f"品牌「{brand_name or brand_id}」仍有库存,无法删除", + ) + plan_ref = ( + db.query(PlPlanProduct) + .join(PlPlan, PlPlan.id == PlPlanProduct.plan_id) + .filter( + PlPlanProduct.tenant_no == tenant_no, + PlPlanProduct.brand_id == brand_id, + PlPlanProduct.del_flag == 0, + PlPlan.del_flag == 0, + PlPlan.archive_status != "archived", + ) + .first() + ) + if plan_ref: + raise HTTPException( + status_code=400, + detail=f"品牌「{brand_name or brand_id}」仍被未归档计划引用,无法删除", + ) + + +def _replace_product_brands( + db: Session, + *, + product_id: str, + tenant_no: str, + user_id: Optional[str], + brands: Optional[List[Any]], +) -> None: + if brands is None: + return + existing = ( + db.query(CrmProductBrand) + .filter( + CrmProductBrand.product_id == product_id, + CrmProductBrand.tenant_no == tenant_no, + CrmProductBrand.del_flag == 0, + ) + .all() + ) + keep_ids = set() + for item in brands: + data = item if isinstance(item, dict) else item.model_dump(by_alias=False) + bid = (data.get("id") or "").strip() or None + name = (data.get("brand_name") or data.get("brandName") or "").strip() + if not name: + continue + if bid: + keep_ids.add(bid) + for row in existing: + if row.id not in keep_ids: + _assert_brand_deletable(db, tenant_no, row.id, row.brand_name) + row.del_flag = 1 + row.update_by = user_id + row.update_time = datetime.now() + existing_by_id = {r.id: r for r in existing} + for idx, item in enumerate(brands): + data = item if isinstance(item, dict) else item.model_dump(by_alias=False) + name = (data.get("brand_name") or data.get("brandName") or "").strip() + if not name: + continue + bid = (data.get("id") or "").strip() or None + status = (data.get("status") or "on").strip() or "on" + order_num = data.get("order_num") if data.get("order_num") is not None else data.get("orderNum") + if order_num is None: + order_num = idx + if bid and bid in existing_by_id: + row = existing_by_id[bid] + row.del_flag = 0 + row.brand_name = name + row.status = status + row.order_num = order_num + row.update_by = user_id + row.update_time = datetime.now() + else: + db.add( + CrmProductBrand( + id=generate_uuid(), + product_id=product_id, + brand_name=name, + status=status, + tenant_no=tenant_no, + order_num=order_num, + create_by=user_id, + create_dept_id="", + del_flag=0, + ) + ) + + +def _replace_product_aliases( + db: Session, + *, + product_id: str, + tenant_no: str, + user_id: Optional[str], + aliases: Optional[List[Any]], +) -> None: + if aliases is None: + return + existing = ( + db.query(CrmProductAlias) + .filter( + CrmProductAlias.product_id == product_id, + CrmProductAlias.tenant_no == tenant_no, + CrmProductAlias.del_flag == 0, + ) + .all() + ) + existing_by_id = {r.id: r for r in existing} + keep_ids = set() + for item in aliases: + data = item if isinstance(item, dict) else item.model_dump(by_alias=False) + aid = (data.get("id") or "").strip() or None + name = (data.get("alias_name") or data.get("aliasName") or "").strip() + if name and aid: + keep_ids.add(aid) + for row in existing: + if row.id not in keep_ids: + row.del_flag = 1 + row.update_by = user_id + row.update_time = datetime.now() + for idx, item in enumerate(aliases): + data = item if isinstance(item, dict) else item.model_dump(by_alias=False) + name = (data.get("alias_name") or data.get("aliasName") or "").strip() + if not name: + continue + aid = (data.get("id") or "").strip() or None + order_num = data.get("order_num") if data.get("order_num") is not None else data.get("orderNum") + if order_num is None: + order_num = idx + if aid and aid in existing_by_id: + row = existing_by_id[aid] + row.del_flag = 0 + row.alias_name = name + row.order_num = order_num + row.update_by = user_id + row.update_time = datetime.now() + else: + db.add( + CrmProductAlias( + id=generate_uuid(), + product_id=product_id, + alias_name=name, + tenant_no=tenant_no, + order_num=order_num, + create_by=user_id, + create_dept_id="", + del_flag=0, + ) + ) + + +def _product_row_to_dict( + row: CrmProduct, + *, + brands: Optional[List[Dict[str, Any]]] = None, + aliases: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: """产品 ORM 对象转 dict(camelCase 键名)""" unit = (row.unit or "").strip() return { @@ -308,11 +541,23 @@ def _product_row_to_dict(row: CrmProduct) -> Dict[str, Any]: "saleCount": row.sale_count if row.sale_count is not None else 0, "orderNum": row.order_num if row.order_num is not None else 0, "remark": row.remark or "", + "brands": brands if brands is not None else [], + "aliases": aliases if aliases is not None else [], "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", } +def product_to_dict(db: Session, row: CrmProduct) -> Dict[str, Any]: + """单产品详情(含品牌/别名)""" + brands_map, aliases_map = _load_brands_aliases_maps(db, [row.id], row.tenant_no) + return _product_row_to_dict( + row, + brands=brands_map.get(row.id, []), + aliases=aliases_map.get(row.id, []), + ) + + def list_products( db: Session, tenant_no: str, @@ -375,7 +620,16 @@ def list_products( .limit(page_size) .all() ) - return [_product_row_to_dict(r) for r in rows], int(total) + ids = [r.id for r in rows] + brands_map, aliases_map = _load_brands_aliases_maps(db, ids, tenant_no) + return [ + _product_row_to_dict( + r, + brands=brands_map.get(r.id, []), + aliases=aliases_map.get(r.id, []), + ) + for r in rows + ], int(total) def get_product(db: Session, product_id: str) -> Optional[CrmProduct]: @@ -525,6 +779,21 @@ def create_product( del_flag=0, ) db.add(obj) + db.flush() + _replace_product_brands( + db, + product_id=obj.id, + tenant_no=tenant_no, + user_id=user_id, + brands=kwargs.get("brands"), + ) + _replace_product_aliases( + db, + product_id=obj.id, + tenant_no=tenant_no, + user_id=user_id, + aliases=kwargs.get("aliases"), + ) db.commit() db.refresh(obj) return obj @@ -585,6 +854,23 @@ def update_product( else: obj.category_name = "" + if "brands" in kwargs: + _replace_product_brands( + db, + product_id=obj.id, + tenant_no=obj.tenant_no, + user_id=user_id, + brands=kwargs.get("brands"), + ) + if "aliases" in kwargs: + _replace_product_aliases( + db, + product_id=obj.id, + tenant_no=obj.tenant_no, + user_id=user_id, + aliases=kwargs.get("aliases"), + ) + obj.update_by = user_id obj.update_time = datetime.now() db.commit() diff --git a/backend/app/schemas/crm/product.py b/backend/app/schemas/crm/product.py index e3dc9c4e5..b18733322 100644 --- a/backend/app/schemas/crm/product.py +++ b/backend/app/schemas/crm/product.py @@ -76,6 +76,27 @@ class ProductListBody(BaseModel): supplier_id: Optional[str] = Field(default=None, alias="supplierId", description="报价/合同主体ID,传入则按危化品能力过滤") +class ProductBrandItem(BaseModel): + """产品品牌项""" + + model_config = ConfigDict(populate_by_name=True) + + id: Optional[str] = None + brand_name: str = Field(..., alias="brandName", max_length=100, description="品牌名称") + status: str = Field(default="on", description="状态 on/off") + order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号") + + +class ProductAliasItem(BaseModel): + """产品别名项""" + + model_config = ConfigDict(populate_by_name=True) + + id: Optional[str] = None + alias_name: str = Field(..., alias="aliasName", max_length=200, description="别名") + order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号") + + class ProductCreateBody(BaseModel): """产品新增请求体""" @@ -106,6 +127,8 @@ class ProductCreateBody(BaseModel): sale_count: int = Field(default=0, alias="saleCount", description="销售次数") order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号") remark: Optional[str] = Field(default=None, description="备注") + brands: Optional[List[ProductBrandItem]] = Field(default=None, description="产品品牌列表") + aliases: Optional[List[ProductAliasItem]] = Field(default=None, description="产品别名列表") class ProductUpdateBody(BaseModel): @@ -139,6 +162,8 @@ class ProductUpdateBody(BaseModel): sale_count: Optional[int] = Field(default=None, alias="saleCount", description="销售次数") order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号") remark: Optional[str] = Field(default=None, description="备注") + brands: Optional[List[ProductBrandItem]] = Field(default=None, description="产品品牌列表") + aliases: Optional[List[ProductAliasItem]] = Field(default=None, description="产品别名列表") class ProductDetailBody(BaseModel):