diff --git a/backend/app/api/crm.py b/backend/app/api/crm.py index f0e8e69e4..f70cfa5db 100644 --- a/backend/app/api/crm.py +++ b/backend/app/api/crm.py @@ -1403,15 +1403,15 @@ def pool_customer_list( ): try: 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( - 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({ "list": rows, @@ -1445,10 +1445,7 @@ def pool_customer_export( page=1, 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) # map telephone -> phone for shared column if needed diff --git a/backend/app/crud/crm/customer.py b/backend/app/crud/crm/customer.py index d37764c4f..128efc223 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.services.crm.customer_list_filters import build_customer_column_filters from app.utils.pinyin_abbr import resolve_name_pinyin_abbr @@ -220,21 +221,10 @@ def list_customers( page_size: int = 10, keyword: 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, skip_data_scope: bool = False, max_page_size: Optional[int] = None, + **filter_params, ) -> Tuple[List[Dict[str, Any]], int]: q = db.query(CrmCustomer).filter( 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" if user_info and not pool_scope_exempt: q = apply_crm_owner_scope(q, CrmCustomer, user_info, db, tenant_no) @@ -347,25 +307,22 @@ def list_pool_customers( page: int = 1, page_size: int = 10, keyword: str = None, - level: str = None, - industry: str = None, - park_area: str = None, user_info=None, max_page_size: Optional[int] = None, + **filter_params, ) -> Tuple[List[Dict[str, Any]], int]: + params = dict(filter_params) + params["status"] = "pool" return list_customers( db, tenant_no, page=page, page_size=page_size, keyword=keyword, - status="pool", - level=level, - industry=industry, - park_area=park_area, user_info=user_info, skip_data_scope=True, max_page_size=max_page_size, + **params, ) diff --git a/backend/app/schemas/crm/customer.py b/backend/app/schemas/crm/customer.py index 585dbdc06..cb270442d 100644 --- a/backend/app/schemas/crm/customer.py +++ b/backend/app/schemas/crm/customer.py @@ -21,10 +21,171 @@ class CustomerOptionsBody(BaseModel): limit: int = Field(default=50, ge=1, le=200, description="返回条数上限") +# ── 客户列头筛选字段(客户列表 / 公海共用)────────────────────── + + +class CustomerColumnFilterMixin(BaseModel): + """列头漏斗参数:前端 camelCase ↔ 后端 snake_case,供 build_customer_column_filters。""" + + model_config = ConfigDict(populate_by_name=True) + + # 文本 + customer_name: Optional[str] = Field(default=None, alias="customerName", 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_in: Optional[str] = Field(default=None, alias="customerTypeIn", 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_in: Optional[str] = Field(default=None, alias="sourceIn", 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_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_in: Optional[str] = Field(default=None, alias="statusIn", 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") + + # 创建时间(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(BaseModel): +class CustomerListBody(CustomerColumnFilterMixin): model_config = ConfigDict(populate_by_name=True) page: int = Field(default=1, ge=1, description="页码") @@ -33,24 +194,11 @@ class CustomerListBody(BaseModel): search_keyword: Optional[str] = Field( default=None, alias="searchKeyword", description="快速搜索关键词(与 keyword 等价)" ) - customer_name: Optional[str] = Field(default=None, alias="customerName", description="客户名称") - customer_no: Optional[str] = Field(default=None, alias="customerNo", description="客户编号") - customer_type: Optional[str] = Field(default=None, alias="customerType", description="客户类型") - level: Optional[str] = Field(default=None, description="客户级别") - source: Optional[str] = Field(default=None, description="客户来源") - industry: Optional[str] = Field(default=None, description="所属行业") - park_area: Optional[str] = Field(default=None, alias="parkArea", description="所在园区") - status: Optional[str] = Field(default=None, description="客户状态") - deal_status: Optional[str] = Field(default=None, alias="dealStatus", description="成交状态") - owner_user_id: Optional[str] = Field(default=None, alias="ownerUserId", description="负责人ID") 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)") create_time_range: Optional[List[str]] = Field( 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列表") 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: """解析列表查询参数,供 API 与 CRUD 共用。""" 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" - return { + kwargs = { "keyword": keyword, "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 # ── 客户新增 ────────────────────────────────────────────────── diff --git a/backend/app/schemas/crm/customer_pool.py b/backend/app/schemas/crm/customer_pool.py index e26b4a063..92f51ba64 100644 --- a/backend/app/schemas/crm/customer_pool.py +++ b/backend/app/schemas/crm/customer_pool.py @@ -3,19 +3,26 @@ from typing import List, Optional 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) page: int = Field(default=1, ge=1) pageSize: int = Field(default=10, ge=1, le=500) 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列表") 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): model_config = ConfigDict(populate_by_name=True)