From 659fc891794a5972527922cb7c8a6f7bb6b5581c Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 7 Aug 2026 17:24:28 +0800 Subject: [PATCH] feat: store and search customer name pinyin initials. Co-authored-by: Cursor --- backend/app/crud/crm/customer.py | 57 +++++++++++++++++++- backend/app/schemas/crm/customer.py | 6 +++ backend/tests/test_crm_name_pinyin_search.py | 40 +++++++++++++- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/backend/app/crud/crm/customer.py b/backend/app/crud/crm/customer.py index 183ca364c..1cb52564c 100644 --- a/backend/app/crud/crm/customer.py +++ b/backend/app/crud/crm/customer.py @@ -17,6 +17,7 @@ from app.crud.crm.data_scope_helpers import ( apply_crm_owner_scope, ensure_crm_owner_row_in_scope, ) +from app.utils.pinyin_abbr import resolve_name_pinyin_abbr def _ensure_customer_in_scope(db: Session, customer: CrmCustomer, user_info, tenant_no: str) -> None: @@ -109,6 +110,7 @@ def _row_to_dict(row: CrmCustomer) -> Dict[str, Any]: "id": row.id, "customerNo": row.customer_no or "", "customerName": row.customer_name or "", + "namePinyinAbbr": getattr(row, "name_pinyin_abbr", None) or "", "shortName": row.short_name or "", "customerType": row.customer_type or "", "mobile": row.mobile or "", @@ -179,11 +181,21 @@ def list_customer_options( CrmCustomer.customer_name.like(like), CrmCustomer.customer_no.like(like), CrmCustomer.short_name.like(like), + func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( + f"%{kw.lower()}%" + ), ) ) name = (customer_name or "").strip() if name: - q = q.filter(CrmCustomer.customer_name.like(f"%{name}%")) + q = q.filter( + or_( + CrmCustomer.customer_name.like(f"%{name}%"), + func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( + f"%{name.lower()}%" + ), + ) + ) limit = min(max(1, int(limit or 50)), 200) rows = q.order_by(CrmCustomer.create_time.desc()).limit(limit).all() @@ -237,6 +249,9 @@ def list_customers( CrmCustomer.customer_no.like(like), CrmCustomer.mobile.like(like), CrmCustomer.short_name.like(like), + func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( + f"%{kw.lower()}%" + ), ) ) @@ -266,7 +281,16 @@ def list_customers( ) if customer_name: - q = q.filter(CrmCustomer.customer_name.like(f"%{customer_name}%")) + cn = customer_name.strip() + if cn: + q = q.filter( + or_( + CrmCustomer.customer_name.like(f"%{cn}%"), + func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( + f"%{cn.lower()}%" + ), + ) + ) if customer_no: q = q.filter(CrmCustomer.customer_no.like(f"%{customer_no}%")) @@ -440,10 +464,21 @@ def create_customer( owner_id = (kwargs.pop("owner_user_id", None) or user_id or "").strip() or None owner_name = _resolve_user_display_name(db, owner_id) or (user_name or "") + # 拼音首字母:新增始终按提交值解析(空则由客户名称生成);兼容 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("customer_name"), + submitted=submitted, + submitted_provided=True, + ) + obj = CrmCustomer( id=generate_uuid(), customer_no=customer_no, customer_name=kwargs.get("customer_name"), + name_pinyin_abbr=name_pinyin_abbr, short_name=kwargs.get("short_name"), customer_type=kwargs.get("customer_type", "company"), mobile=kwargs.get("mobile"), @@ -503,6 +538,7 @@ def update_customer( raise ValueError("客户不存在") _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) + # name_pinyin_abbr 单独处理,避免双重赋值 _updatable = ( "customer_name", "short_name", "customer_type", "mobile", "telephone", "email", "website", "level", "source", "industry", "park_area", @@ -522,6 +558,23 @@ def update_customer( elif val is not None: setattr(obj, field, val) + # 拼音首字母:提交了则用提交值(空则重生);未提交则保留已有,已有空则按名称生成 + 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 "customer_name" in kwargs and kwargs.get("customer_name") is not None: + new_name = kwargs.get("customer_name") + else: + new_name = obj.customer_name + obj.name_pinyin_abbr = resolve_name_pinyin_abbr( + name=new_name, + submitted=submitted, + existing=obj.name_pinyin_abbr, + submitted_provided=submitted_provided, + ) + if "owner_user_id" in kwargs: owner_id = (kwargs.pop("owner_user_id") or "").strip() or None obj.owner_user_id = owner_id diff --git a/backend/app/schemas/crm/customer.py b/backend/app/schemas/crm/customer.py index 7d1678af4..460e2fa79 100644 --- a/backend/app/schemas/crm/customer.py +++ b/backend/app/schemas/crm/customer.py @@ -88,6 +88,9 @@ class CustomerCreateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) customer_name: str = Field(..., alias="customerName", description="客户名称") + name_pinyin_abbr: Optional[str] = Field( + default=None, alias="namePinyinAbbr", description="客户名称拼音首字母(小写)" + ) short_name: Optional[str] = Field(default=None, alias="shortName", description="客户简称") customer_type: str = Field(default="company", alias="customerType", description="客户类型") mobile: Optional[str] = Field(default=None, description="手机号") @@ -152,6 +155,9 @@ class CustomerUpdateBody(BaseModel): id: str = Field(..., description="客户ID") customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称") + name_pinyin_abbr: Optional[str] = Field( + default=None, alias="namePinyinAbbr", description="客户名称拼音首字母(小写)" + ) short_name: Optional[str] = Field(default=None, alias="shortName", description="客户简称") customer_type: Optional[str] = Field(default=None, alias="customerType", description="客户类型") mobile: Optional[str] = Field(default=None, description="手机号") diff --git a/backend/tests/test_crm_name_pinyin_search.py b/backend/tests/test_crm_name_pinyin_search.py index bac553d80..8fd94b587 100644 --- a/backend/tests/test_crm_name_pinyin_search.py +++ b/backend/tests/test_crm_name_pinyin_search.py @@ -1,8 +1,9 @@ -"""产品列表 keyword:名称/编号/拼音首字母 OR 匹配。""" +"""产品/客户列表 keyword:名称/编号/拼音首字母 OR 匹配。""" from __future__ import annotations from unittest.mock import MagicMock +from app.crud.crm.customer import list_customer_options, list_customers from app.crud.crm.product import list_products @@ -24,3 +25,40 @@ def test_list_products_keyword_includes_name_and_pinyin(): 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 + + +def test_list_customers_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_customers(db, "shanxia2.0", keyword="sxhg") + + assert q.filter.call_count >= 2 + keyword_expr = " ".join(str(a) for a in q.filter.call_args_list[1][0]) + assert "customer_name" in keyword_expr + assert "name_pinyin_abbr" in keyword_expr + + +def test_list_customer_options_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.limit.return_value = q + q.all.return_value = [] + + list_customer_options(db, "shanxia2.0", keyword="sxhg") + + assert q.filter.call_count >= 2 + keyword_expr = " ".join(str(a) for a in q.filter.call_args_list[1][0]) + assert "customer_name" in keyword_expr + assert "name_pinyin_abbr" in keyword_expr