Просмотр исходного кода

feat(crm): persist product brands and aliases on create/update

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
bfa0d50597
3 измененных файлов с 318 добавлено и 7 удалено
  1. 4 4
      backend/app/api/crm.py
  2. 289 3
      backend/app/crud/crm/product.py
  3. 25 0
      backend/app/schemas/crm/product.py

+ 4 - 4
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:

+ 289 - 3
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()

+ 25 - 0
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):