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

feat(crm): wire customer and pool list filters to column params.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
fbf830172f

+ 9 - 12
backend/app/api/crm.py

@@ -1403,15 +1403,15 @@ def pool_customer_list(
 ):
 ):
     try:
     try:
         tenant_no = get_tenant_no(db, _user)
         tenant_no = get_tenant_no(db, _user)
-        page = body.page if body else 1
-        page_size = body.pageSize if body else 10
+        list_body = body or PoolCustomerListBody()
+        page = list_body.page
+        page_size = list_body.pageSize
         rows, total = list_pool_customers(
         rows, total = list_pool_customers(
-            db, tenant_no,
-            page=page, page_size=page_size,
-            keyword=body.keyword if body else None,
-            level=body.level if body else None,
-            industry=body.industry if body else None,
-            park_area=body.parkArea if body else None,
+            db,
+            tenant_no,
+            page=page,
+            page_size=page_size,
+            **list_body.to_list_kwargs(),
         )
         )
         return success_response({
         return success_response({
             "list": rows,
             "list": rows,
@@ -1445,10 +1445,7 @@ def pool_customer_export(
             page=1,
             page=1,
             page_size=EXPORT_MAX_ROWS,
             page_size=EXPORT_MAX_ROWS,
             max_page_size=EXPORT_MAX_ROWS,
             max_page_size=EXPORT_MAX_ROWS,
-            keyword=list_body.keyword,
-            level=list_body.level,
-            industry=list_body.industry,
-            park_area=list_body.parkArea,
+            **list_body.to_list_kwargs(),
         )
         )
         rows = _filter_export_rows_by_ids(rows, list_body.ids)
         rows = _filter_export_rows_by_ids(rows, list_body.ids)
         # map telephone -> phone for shared column if needed
         # map telephone -> phone for shared column if needed

+ 10 - 53
backend/app/crud/crm/customer.py

@@ -17,6 +17,7 @@ from app.crud.crm.data_scope_helpers import (
     apply_crm_owner_scope,
     apply_crm_owner_scope,
     ensure_crm_owner_row_in_scope,
     ensure_crm_owner_row_in_scope,
 )
 )
+from app.services.crm.customer_list_filters import build_customer_column_filters
 from app.utils.pinyin_abbr import resolve_name_pinyin_abbr
 from app.utils.pinyin_abbr import resolve_name_pinyin_abbr
 
 
 
 
@@ -220,21 +221,10 @@ def list_customers(
     page_size: int = 10,
     page_size: int = 10,
     keyword: str = None,
     keyword: str = None,
     view_mode: str = None,
     view_mode: str = None,
-    customer_name: str = None,
-    customer_no: str = None,
-    status: str = None,
-    deal_status: str = None,
-    level: str = None,
-    source: str = None,
-    customer_type: str = None,
-    industry: str = None,
-    park_area: str = None,
-    owner_user_id: str = None,
-    create_time_start: str = None,
-    create_time_end: str = None,
     user_info=None,
     user_info=None,
     skip_data_scope: bool = False,
     skip_data_scope: bool = False,
     max_page_size: Optional[int] = None,
     max_page_size: Optional[int] = None,
+    **filter_params,
 ) -> Tuple[List[Dict[str, Any]], int]:
 ) -> Tuple[List[Dict[str, Any]], int]:
     q = db.query(CrmCustomer).filter(
     q = db.query(CrmCustomer).filter(
         CrmCustomer.tenant_no == tenant_no,
         CrmCustomer.tenant_no == tenant_no,
@@ -281,41 +271,11 @@ def list_customers(
             )
             )
         )
         )
 
 
-    if 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}%"))
-
-    if status:
-        q = q.filter(CrmCustomer.status == status)
-    if deal_status:
-        q = q.filter(CrmCustomer.deal_status == deal_status)
-    if level:
-        q = q.filter(CrmCustomer.level == level)
-    if source:
-        q = q.filter(CrmCustomer.source == source)
-    if customer_type:
-        q = q.filter(CrmCustomer.customer_type == customer_type)
-    if industry:
-        q = q.filter(CrmCustomer.industry == industry)
-    if park_area:
-        q = q.filter(CrmCustomer.park_area == park_area)
-    if owner_user_id:
-        q = q.filter(CrmCustomer.owner_user_id == owner_user_id)
-    if create_time_start:
-        q = q.filter(CrmCustomer.create_time >= create_time_start)
-    if create_time_end:
-        q = q.filter(CrmCustomer.create_time <= create_time_end)
+    # 列头 / 旧单值 / 日期 / 数字:统一走 builder(与 export 共用)
+    for expr in build_customer_column_filters(**filter_params):
+        q = q.filter(expr)
 
 
+    status = filter_params.get("status")
     pool_scope_exempt = skip_data_scope or view_mode == "pool" or status == "pool"
     pool_scope_exempt = skip_data_scope or view_mode == "pool" or status == "pool"
     if user_info and not pool_scope_exempt:
     if user_info and not pool_scope_exempt:
         q = apply_crm_owner_scope(q, CrmCustomer, user_info, db, tenant_no)
         q = apply_crm_owner_scope(q, CrmCustomer, user_info, db, tenant_no)
@@ -347,25 +307,22 @@ def list_pool_customers(
     page: int = 1,
     page: int = 1,
     page_size: int = 10,
     page_size: int = 10,
     keyword: str = None,
     keyword: str = None,
-    level: str = None,
-    industry: str = None,
-    park_area: str = None,
     user_info=None,
     user_info=None,
     max_page_size: Optional[int] = None,
     max_page_size: Optional[int] = None,
+    **filter_params,
 ) -> Tuple[List[Dict[str, Any]], int]:
 ) -> Tuple[List[Dict[str, Any]], int]:
+    params = dict(filter_params)
+    params["status"] = "pool"
     return list_customers(
     return list_customers(
         db,
         db,
         tenant_no,
         tenant_no,
         page=page,
         page=page,
         page_size=page_size,
         page_size=page_size,
         keyword=keyword,
         keyword=keyword,
-        status="pool",
-        level=level,
-        industry=industry,
-        park_area=park_area,
         user_info=user_info,
         user_info=user_info,
         skip_data_scope=True,
         skip_data_scope=True,
         max_page_size=max_page_size,
         max_page_size=max_page_size,
+        **params,
     )
     )
 
 
 
 

+ 168 - 32
backend/app/schemas/crm/customer.py

@@ -21,36 +21,184 @@ class CustomerOptionsBody(BaseModel):
     limit: int = Field(default=50, ge=1, le=200, description="返回条数上限")
     limit: int = Field(default=50, ge=1, le=200, description="返回条数上限")
 
 
 
 
-# ── 客户列表 ──────────────────────────────────────────────────
+# ── 客户列头筛选字段(客户列表 / 公海共用)──────────────────────
+
 
 
+class CustomerColumnFilterMixin(BaseModel):
+    """列头漏斗参数:前端 camelCase ↔ 后端 snake_case,供 build_customer_column_filters。"""
 
 
-class CustomerListBody(BaseModel):
     model_config = ConfigDict(populate_by_name=True)
     model_config = ConfigDict(populate_by_name=True)
 
 
-    page: int = Field(default=1, ge=1, description="页码")
-    pageSize: int = Field(default=10, ge=1, le=500, description="每页条数")
-    keyword: Optional[str] = Field(default=None, description="搜索关键词(客户名称/编号/手机号)")
-    search_keyword: Optional[str] = Field(
-        default=None, alias="searchKeyword", description="快速搜索关键词(与 keyword 等价)"
-    )
+    # 文本
     customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称")
     customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称")
     customer_no: Optional[str] = Field(default=None, alias="customerNo", description="客户编号")
     customer_no: Optional[str] = Field(default=None, alias="customerNo", description="客户编号")
+    short_name: Optional[str] = Field(default=None, alias="shortName", description="客户简称")
+    mobile: Optional[str] = Field(default=None, description="手机号")
+    telephone: Optional[str] = Field(default=None, description="固定电话")
+    email: Optional[str] = Field(default=None, description="邮箱")
+    website: Optional[str] = Field(default=None, description="客户网站")
+    address: Optional[str] = Field(default=None, description="详细地址")
+    province: Optional[str] = Field(default=None, description="省")
+    city: Optional[str] = Field(default=None, description="市")
+    district: Optional[str] = Field(default=None, description="区")
+    owner_user_name: Optional[str] = Field(default=None, alias="ownerUserName", description="负责人姓名")
+    remark: Optional[str] = Field(default=None, description="备注")
+
+    # 枚举单值(兼容旧入参)+ 多选 *In
     customer_type: Optional[str] = Field(default=None, alias="customerType", description="客户类型")
     customer_type: Optional[str] = Field(default=None, alias="customerType", description="客户类型")
+    customer_type_in: Optional[str] = Field(default=None, alias="customerTypeIn", description="客户类型多选(逗号)")
     level: Optional[str] = Field(default=None, description="客户级别")
     level: Optional[str] = Field(default=None, description="客户级别")
+    level_in: Optional[str] = Field(default=None, alias="levelIn", description="客户级别多选(逗号)")
     source: Optional[str] = Field(default=None, description="客户来源")
     source: Optional[str] = Field(default=None, description="客户来源")
+    source_in: Optional[str] = Field(default=None, alias="sourceIn", description="客户来源多选(逗号)")
     industry: Optional[str] = Field(default=None, description="所属行业")
     industry: Optional[str] = Field(default=None, description="所属行业")
+    industry_in: Optional[str] = Field(default=None, alias="industryIn", description="所属行业多选(逗号)")
     park_area: Optional[str] = Field(default=None, alias="parkArea", description="所在园区")
     park_area: Optional[str] = Field(default=None, alias="parkArea", description="所在园区")
+    park_area_in: Optional[str] = Field(default=None, alias="parkAreaIn", description="所在园区多选(逗号)")
+    scale: Optional[str] = Field(default=None, description="企业规模")
+    scale_in: Optional[str] = Field(default=None, alias="scaleIn", description="企业规模多选(逗号)")
     status: Optional[str] = Field(default=None, description="客户状态")
     status: Optional[str] = Field(default=None, description="客户状态")
+    status_in: Optional[str] = Field(default=None, alias="statusIn", description="客户状态多选(逗号)")
     deal_status: Optional[str] = Field(default=None, alias="dealStatus", description="成交状态")
     deal_status: Optional[str] = Field(default=None, alias="dealStatus", description="成交状态")
+    deal_status_in: Optional[str] = Field(default=None, alias="dealStatusIn", description="成交状态多选(逗号)")
+
     owner_user_id: Optional[str] = Field(default=None, alias="ownerUserId", description="负责人ID")
     owner_user_id: Optional[str] = Field(default=None, alias="ownerUserId", description="负责人ID")
+
+    # 创建时间(begin/end 与 createTimeFrom/To 并存;range 由 CustomerListBody 解析)
+    begin_time: Optional[str] = Field(default=None, alias="beginTime", description="创建时间起")
+    end_time: Optional[str] = Field(default=None, alias="endTime", description="创建时间止")
+    create_time_from: Optional[str] = Field(default=None, alias="createTimeFrom", description="创建时间起")
+    create_time_to: Optional[str] = Field(default=None, alias="createTimeTo", description="创建时间止")
+
+    # 其它日期 From/To
+    last_time_from: Optional[str] = Field(default=None, alias="lastTimeFrom")
+    last_time_to: Optional[str] = Field(default=None, alias="lastTimeTo")
+    next_time_from: Optional[str] = Field(default=None, alias="nextTimeFrom")
+    next_time_to: Optional[str] = Field(default=None, alias="nextTimeTo")
+    deal_time_from: Optional[str] = Field(default=None, alias="dealTimeFrom")
+    deal_time_to: Optional[str] = Field(default=None, alias="dealTimeTo")
+    into_pool_time_from: Optional[str] = Field(default=None, alias="intoPoolTimeFrom")
+    into_pool_time_to: Optional[str] = Field(default=None, alias="intoPoolTimeTo")
+    claim_time_from: Optional[str] = Field(default=None, alias="claimTimeFrom")
+    claim_time_to: Optional[str] = Field(default=None, alias="claimTimeTo")
+    protect_time_from: Optional[str] = Field(default=None, alias="protectTimeFrom")
+    protect_time_to: Optional[str] = Field(default=None, alias="protectTimeTo")
+    update_time_from: Optional[str] = Field(default=None, alias="updateTimeFrom")
+    update_time_to: Optional[str] = Field(default=None, alias="updateTimeTo")
+
+    # 数字 Op / Min / Max / 单值
+    deal_amount_op: Optional[str] = Field(default=None, alias="dealAmountOp")
+    deal_amount: Optional[str] = Field(default=None, alias="dealAmount")
+    deal_amount_min: Optional[str] = Field(default=None, alias="dealAmountMin")
+    deal_amount_max: Optional[str] = Field(default=None, alias="dealAmountMax")
+    follow_count_op: Optional[str] = Field(default=None, alias="followCountOp")
+    follow_count: Optional[str] = Field(default=None, alias="followCount")
+    follow_count_min: Optional[str] = Field(default=None, alias="followCountMin")
+    follow_count_max: Optional[str] = Field(default=None, alias="followCountMax")
+    contact_count_op: Optional[str] = Field(default=None, alias="contactCountOp")
+    contact_count: Optional[str] = Field(default=None, alias="contactCount")
+    contact_count_min: Optional[str] = Field(default=None, alias="contactCountMin")
+    contact_count_max: Optional[str] = Field(default=None, alias="contactCountMax")
+    opportunity_count_op: Optional[str] = Field(default=None, alias="opportunityCountOp")
+    opportunity_count: Optional[str] = Field(default=None, alias="opportunityCount")
+    opportunity_count_min: Optional[str] = Field(default=None, alias="opportunityCountMin")
+    opportunity_count_max: Optional[str] = Field(default=None, alias="opportunityCountMax")
+    contract_count_op: Optional[str] = Field(default=None, alias="contractCountOp")
+    contract_count: Optional[str] = Field(default=None, alias="contractCount")
+    contract_count_min: Optional[str] = Field(default=None, alias="contractCountMin")
+    contract_count_max: Optional[str] = Field(default=None, alias="contractCountMax")
+
+    def column_filter_kwargs(self) -> dict:
+        """snake_case 列筛参数,供 CRUD / build_customer_column_filters。"""
+        return {
+            "customer_name": self.customer_name,
+            "customer_no": self.customer_no,
+            "short_name": self.short_name,
+            "mobile": self.mobile,
+            "telephone": self.telephone,
+            "email": self.email,
+            "website": self.website,
+            "address": self.address,
+            "province": self.province,
+            "city": self.city,
+            "district": self.district,
+            "owner_user_name": self.owner_user_name,
+            "remark": self.remark,
+            "customer_type": self.customer_type,
+            "customer_type_in": self.customer_type_in,
+            "level": self.level,
+            "level_in": self.level_in,
+            "source": self.source,
+            "source_in": self.source_in,
+            "industry": self.industry,
+            "industry_in": self.industry_in,
+            "park_area": self.park_area,
+            "park_area_in": self.park_area_in,
+            "scale": self.scale,
+            "scale_in": self.scale_in,
+            "status": self.status,
+            "status_in": self.status_in,
+            "deal_status": self.deal_status,
+            "deal_status_in": self.deal_status_in,
+            "owner_user_id": self.owner_user_id,
+            "begin_time": self.begin_time,
+            "end_time": self.end_time,
+            "create_time_from": self.create_time_from,
+            "create_time_to": self.create_time_to,
+            "last_time_from": self.last_time_from,
+            "last_time_to": self.last_time_to,
+            "next_time_from": self.next_time_from,
+            "next_time_to": self.next_time_to,
+            "deal_time_from": self.deal_time_from,
+            "deal_time_to": self.deal_time_to,
+            "into_pool_time_from": self.into_pool_time_from,
+            "into_pool_time_to": self.into_pool_time_to,
+            "claim_time_from": self.claim_time_from,
+            "claim_time_to": self.claim_time_to,
+            "protect_time_from": self.protect_time_from,
+            "protect_time_to": self.protect_time_to,
+            "update_time_from": self.update_time_from,
+            "update_time_to": self.update_time_to,
+            "deal_amount_op": self.deal_amount_op,
+            "deal_amount": self.deal_amount,
+            "deal_amount_min": self.deal_amount_min,
+            "deal_amount_max": self.deal_amount_max,
+            "follow_count_op": self.follow_count_op,
+            "follow_count": self.follow_count,
+            "follow_count_min": self.follow_count_min,
+            "follow_count_max": self.follow_count_max,
+            "contact_count_op": self.contact_count_op,
+            "contact_count": self.contact_count,
+            "contact_count_min": self.contact_count_min,
+            "contact_count_max": self.contact_count_max,
+            "opportunity_count_op": self.opportunity_count_op,
+            "opportunity_count": self.opportunity_count,
+            "opportunity_count_min": self.opportunity_count_min,
+            "opportunity_count_max": self.opportunity_count_max,
+            "contract_count_op": self.contract_count_op,
+            "contract_count": self.contract_count,
+            "contract_count_min": self.contract_count_min,
+            "contract_count_max": self.contract_count_max,
+        }
+
+
+# ── 客户列表 ──────────────────────────────────────────────────
+
+
+class CustomerListBody(CustomerColumnFilterMixin):
+    model_config = ConfigDict(populate_by_name=True)
+
+    page: int = Field(default=1, ge=1, description="页码")
+    pageSize: int = Field(default=10, ge=1, le=500, description="每页条数")
+    keyword: Optional[str] = Field(default=None, description="搜索关键词(客户名称/编号/手机号)")
+    search_keyword: Optional[str] = Field(
+        default=None, alias="searchKeyword", description="快速搜索关键词(与 keyword 等价)"
+    )
     view_type: Optional[str] = Field(default=None, alias="viewType", description="视图类型(mine/all/toFollow/key)")
     view_type: Optional[str] = Field(default=None, alias="viewType", description="视图类型(mine/all/toFollow/key)")
     view_mode: Optional[str] = Field(default=None, alias="viewMode", description="视图模式(mine/all/toFollow/key)")
     view_mode: Optional[str] = Field(default=None, alias="viewMode", description="视图模式(mine/all/toFollow/key)")
     create_time_range: Optional[List[str]] = Field(
     create_time_range: Optional[List[str]] = Field(
         default=None, alias="createTimeRange", description="创建时间范围 [start, end]"
         default=None, alias="createTimeRange", description="创建时间范围 [start, end]"
     )
     )
-    begin_time: Optional[str] = Field(default=None, alias="beginTime", description="创建时间起")
-    end_time: Optional[str] = Field(default=None, alias="endTime", description="创建时间止")
-
 
 
     ids: Optional[List[str]] = Field(default=None, description="导出指定ID列表")
     ids: Optional[List[str]] = Field(default=None, description="导出指定ID列表")
     columns: Optional[List[str]] = Field(default=None, description="导出列(前端 visibleColumns)")
     columns: Optional[List[str]] = Field(default=None, description="导出列(前端 visibleColumns)")
@@ -58,31 +206,19 @@ class CustomerListBody(BaseModel):
     def to_list_kwargs(self, *, view_mode_override: Optional[str] = None) -> dict:
     def to_list_kwargs(self, *, view_mode_override: Optional[str] = None) -> dict:
         """解析列表查询参数,供 API 与 CRUD 共用。"""
         """解析列表查询参数,供 API 与 CRUD 共用。"""
         keyword = (self.keyword or self.search_keyword or "").strip() or None
         keyword = (self.keyword or self.search_keyword or "").strip() or None
-        create_time_start = None
-        create_time_end = None
-        if self.create_time_range and len(self.create_time_range) == 2:
-            create_time_start = self.create_time_range[0]
-            create_time_end = self.create_time_range[1]
-        else:
-            create_time_start = self.begin_time
-            create_time_end = self.end_time
         view_mode = view_mode_override or self.view_mode or self.view_type or "all"
         view_mode = view_mode_override or self.view_mode or self.view_type or "all"
-        return {
+        kwargs = {
             "keyword": keyword,
             "keyword": keyword,
             "view_mode": view_mode,
             "view_mode": view_mode,
-            "customer_name": self.customer_name,
-            "customer_no": self.customer_no,
-            "customer_type": self.customer_type,
-            "level": self.level,
-            "source": self.source,
-            "industry": self.industry,
-            "park_area": self.park_area,
-            "status": self.status,
-            "deal_status": self.deal_status,
-            "owner_user_id": self.owner_user_id,
-            "create_time_start": create_time_start,
-            "create_time_end": create_time_end,
+            **self.column_filter_kwargs(),
         }
         }
+        # createTimeRange 优先于 begin/end / createTimeFrom/To
+        if self.create_time_range and len(self.create_time_range) == 2:
+            kwargs["begin_time"] = self.create_time_range[0]
+            kwargs["end_time"] = self.create_time_range[1]
+            kwargs["create_time_from"] = None
+            kwargs["create_time_to"] = None
+        return kwargs
 
 
 
 
 # ── 客户新增 ──────────────────────────────────────────────────
 # ── 客户新增 ──────────────────────────────────────────────────

+ 11 - 4
backend/app/schemas/crm/customer_pool.py

@@ -3,19 +3,26 @@ from typing import List, Optional
 
 
 from pydantic import BaseModel, ConfigDict, Field
 from pydantic import BaseModel, ConfigDict, Field
 
 
+from app.schemas.crm.customer import CustomerColumnFilterMixin
 
 
-class PoolCustomerListBody(BaseModel):
+
+class PoolCustomerListBody(CustomerColumnFilterMixin):
     model_config = ConfigDict(populate_by_name=True)
     model_config = ConfigDict(populate_by_name=True)
 
 
     page: int = Field(default=1, ge=1)
     page: int = Field(default=1, ge=1)
     pageSize: int = Field(default=10, ge=1, le=500)
     pageSize: int = Field(default=10, ge=1, le=500)
     keyword: Optional[str] = Field(default=None)
     keyword: Optional[str] = Field(default=None)
-    level: Optional[str] = Field(default=None)
-    industry: Optional[str] = Field(default=None)
-    parkArea: Optional[str] = Field(default=None, alias="parkArea")
     ids: Optional[List[str]] = Field(default=None, description="导出指定ID列表")
     ids: Optional[List[str]] = Field(default=None, description="导出指定ID列表")
     columns: Optional[List[str]] = Field(default=None, description="导出列(前端 visibleColumns)")
     columns: Optional[List[str]] = Field(default=None, description="导出列(前端 visibleColumns)")
 
 
+    def to_list_kwargs(self) -> dict:
+        """列筛 + keyword;公海范围由 list_pool_customers 强制 status=pool。"""
+        keyword = (self.keyword or "").strip() or None
+        return {
+            "keyword": keyword,
+            **self.column_filter_kwargs(),
+        }
+
 
 
 class PoolAssignBody(BaseModel):
 class PoolAssignBody(BaseModel):
     model_config = ConfigDict(populate_by_name=True)
     model_config = ConfigDict(populate_by_name=True)