ソースを参照

feat: store and search product name pinyin initials.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 ヶ月 前
親
コミット
ba3f2c19bb

+ 49 - 5
backend/app/crud/crm/product.py

@@ -23,6 +23,7 @@ from app.models.crm_models import (
     SysOss,
 )
 from app.models.pl_models import PlInventory, PlPlan, PlPlanProduct
+from app.utils.pinyin_abbr import resolve_name_pinyin_abbr
 from fastapi import HTTPException
 
 
@@ -522,6 +523,7 @@ def _product_row_to_dict(
         "id": row.id,
         "productNo": row.product_no,
         "productName": row.product_name,
+        "namePinyinAbbr": getattr(row, "name_pinyin_abbr", None) or "",
         "categoryId": row.category_id or "",
         "categoryName": row.category_name or "",
         "model": row.model or "",
@@ -578,11 +580,18 @@ def list_products(
     if user_info:
         q = apply_crm_create_by_scope(q, CrmProduct, user_info, db, tenant_no)
 
-    # keyword 筛选
+    # keyword 筛选:名称 / 编号 / 拼音首字母
     kw = (keyword or "").strip()
     if kw:
         like = f"%{kw}%"
-        q = q.filter(or_(CrmProduct.product_name.like(like), CrmProduct.product_no.like(like)))
+        pinyin_like = f"%{kw.lower()}%"
+        q = q.filter(
+            or_(
+                CrmProduct.product_name.like(like),
+                CrmProduct.product_no.like(like),
+                func.lower(func.coalesce(CrmProduct.name_pinyin_abbr, "")).like(pinyin_like),
+            )
+        )
 
     # category_id 筛选
     if category_id:
@@ -751,10 +760,21 @@ def create_product(
         if cat:
             category_name = cat.category_name
 
+    # 拼音首字母:新增始终按提交值解析(空则由产品名称生成);兼容 snake / camel 键
+    submitted = kwargs.get("name_pinyin_abbr")
+    if submitted is None and "namePinyinAbbr" in kwargs:
+        submitted = kwargs.get("namePinyinAbbr")
+    name_pinyin_abbr = resolve_name_pinyin_abbr(
+        name=kwargs.get("product_name"),
+        submitted=submitted,
+        submitted_provided=True,
+    )
+
     obj = CrmProduct(
         id=generate_uuid(),
         product_no=product_no,
         product_name=kwargs.get("product_name"),
+        name_pinyin_abbr=name_pinyin_abbr,
         category_id=category_id,
         category_name=category_name or "",
         model=kwargs.get("model"),
@@ -815,7 +835,7 @@ def update_product(
         db, obj, user_info, obj.tenant_no, message="无权访问该产品"
     )
 
-    # 逐字段更新
+    # 逐字段更新(name_pinyin_abbr 单独处理,避免双重赋值)
     for field in (
         "product_no",
         "product_name",
@@ -838,6 +858,23 @@ def update_product(
         if field in kwargs and kwargs[field] is not None:
             setattr(obj, field, kwargs[field])
 
+    # 拼音首字母:提交了则用提交值(空则重生);未提交则保留已有,已有空则按名称生成
+    name_keys = ("name_pinyin_abbr", "namePinyinAbbr")
+    submitted_provided = any(k in kwargs for k in name_keys)
+    submitted = kwargs.get("name_pinyin_abbr")
+    if submitted is None:
+        submitted = kwargs.get("namePinyinAbbr")
+    if "product_name" in kwargs and kwargs.get("product_name") is not None:
+        new_name = kwargs.get("product_name")
+    else:
+        new_name = obj.product_name
+    obj.name_pinyin_abbr = resolve_name_pinyin_abbr(
+        name=new_name,
+        submitted=submitted,
+        existing=obj.name_pinyin_abbr,
+        submitted_provided=submitted_provided,
+    )
+
     if "barcode" in kwargs:
         barcode = _normalize_text(kwargs.get("barcode"))
         if barcode and barcode_exists(db, barcode, tenant_no=obj.tenant_no, exclude_id=product_id):
@@ -941,11 +978,18 @@ def list_product_options(
         CrmProduct.status == "on",
     )
     # 下拉选项供合同/报价等选用,不做 create_by 过滤(产品管理分页列表仍保留数据权限)
-    # keyword 仅按产品名称模糊匹配(不匹配编号,避免短数字如「32」误命中 SXWL00732x)
+    # keyword:产品名称 + 拼音首字母(不匹配编号,避免短数字如「32」误命中 SXWL00732x)
     _ = user_info
     kw = (keyword or "").strip()
     if kw:
-        q = q.filter(CrmProduct.product_name.like(f"%{kw}%"))
+        q = q.filter(
+            or_(
+                CrmProduct.product_name.like(f"%{kw}%"),
+                func.lower(func.coalesce(CrmProduct.name_pinyin_abbr, "")).like(
+                    f"%{kw.lower()}%"
+                ),
+            )
+        )
     if category_id:
         q = q.filter(CrmProduct.category_id == category_id)
     q = q.order_by(CrmProduct.create_time.desc())

+ 6 - 0
backend/app/schemas/crm/product.py

@@ -104,6 +104,9 @@ class ProductCreateBody(BaseModel):
 
     product_no: Optional[str] = Field(default=None, alias="productNo", description="产品编号(不传则自动生成,格式 SXWL000001)")
     product_name: str = Field(..., alias="productName", description="产品名称")
+    name_pinyin_abbr: Optional[str] = Field(
+        default=None, alias="namePinyinAbbr", description="产品名称拼音首字母(小写)"
+    )
     category_id: Optional[str] = Field(default=None, alias="categoryId", description="分类ID")
     category_name: Optional[str] = Field(default=None, alias="categoryName", description="分类名称")
     model: Optional[str] = Field(default=None, description="规格型号")
@@ -139,6 +142,9 @@ class ProductUpdateBody(BaseModel):
     id: str = Field(..., description="产品ID")
     product_no: Optional[str] = Field(default=None, alias="productNo", description="产品编号")
     product_name: Optional[str] = Field(default=None, alias="productName", description="产品名称")
+    name_pinyin_abbr: Optional[str] = Field(
+        default=None, alias="namePinyinAbbr", description="产品名称拼音首字母(小写)"
+    )
     category_id: Optional[str] = Field(default=None, alias="categoryId", description="分类ID")
     category_name: Optional[str] = Field(default=None, alias="categoryName", description="分类名称")
     model: Optional[str] = Field(default=None, description="规格型号")

+ 26 - 0
backend/tests/test_crm_name_pinyin_search.py

@@ -0,0 +1,26 @@
+"""产品列表 keyword:名称/编号/拼音首字母 OR 匹配。"""
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from app.crud.crm.product import list_products
+
+
+def test_list_products_keyword_includes_name_and_pinyin():
+    db = MagicMock()
+    q = MagicMock()
+    db.query.return_value = q
+    q.filter.return_value = q
+    q.order_by.return_value = q
+    q.with_entities.return_value = q
+    q.scalar.return_value = 0
+    q.offset.return_value = q
+    q.limit.return_value = q
+    q.all.return_value = []
+
+    list_products(db, "shanxia2.0", keyword="gyls")
+
+    assert q.filter.call_count >= 2
+    keyword_expr = " ".join(str(a) for a in q.filter.call_args_list[1][0])
+    assert "product_name" in keyword_expr
+    assert "name_pinyin_abbr" in keyword_expr

+ 2 - 1
backend/tests/test_crm_product_options_keyword.py

@@ -1,4 +1,4 @@
-"""产品下拉 options:keyword 仅按产品名称模糊匹配,不匹配产品编号。"""
+"""产品下拉 options:keyword 按产品名称/拼音首字母模糊匹配,不匹配产品编号。"""
 from __future__ import annotations
 
 from unittest.mock import MagicMock
@@ -20,4 +20,5 @@ def test_list_product_options_keyword_only_filters_product_name():
     assert q.filter.call_count >= 2
     keyword_expr = " ".join(str(a) for a in q.filter.call_args_list[1][0])
     assert "product_name" in keyword_expr
+    assert "name_pinyin_abbr" in keyword_expr
     assert "product_no" not in keyword_expr