From 3bb905bc2045867dc9fbb199ef7a4db096aa2f6f Mon Sep 17 00:00:00 2001 From: zlwu Date: Sun, 6 Sep 2026 08:17:06 +0800 Subject: [PATCH] feat(crm): add customer list column filter builder. Co-authored-by: Cursor --- backend/app/services/crm/__init__.py | 1 + .../app/services/crm/customer_list_filters.py | 223 ++++++++++++++++++ .../tests/test_crm_customer_list_filters.py | 59 +++++ 3 files changed, 283 insertions(+) create mode 100644 backend/app/services/crm/__init__.py create mode 100644 backend/app/services/crm/customer_list_filters.py create mode 100644 backend/tests/test_crm_customer_list_filters.py diff --git a/backend/app/services/crm/__init__.py b/backend/app/services/crm/__init__.py new file mode 100644 index 000000000..1c042454d --- /dev/null +++ b/backend/app/services/crm/__init__.py @@ -0,0 +1 @@ +"""CRM 模块 Service 包。""" diff --git a/backend/app/services/crm/customer_list_filters.py b/backend/app/services/crm/customer_list_filters.py new file mode 100644 index 000000000..cb121bd34 --- /dev/null +++ b/backend/app/services/crm/customer_list_filters.py @@ -0,0 +1,223 @@ +"""CRM 客户列表列头/导出共用过滤条件拼装。 + +不含 tenant_no / del_flag / view_mode(由 list_customers 负责)。 +create_by_name / update_by_name / create_dept_name 为展示字段(join 补全),此处不筛。 +""" +from __future__ import annotations + +from datetime import datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any, List, Optional + +from sqlalchemy import func, or_ + +from app.models.crm_models import CrmCustomer + +_ENUM_FIELDS = ( + "customer_type", + "level", + "source", + "status", + "deal_status", + "park_area", + "industry", + "scale", +) + +_TEXT_FIELDS = ( + "customer_no", + "short_name", + "mobile", + "telephone", + "email", + "website", + "address", + "province", + "city", + "district", + "owner_user_name", + "remark", +) + +_DATE_FIELDS = ( + "last_time", + "next_time", + "deal_time", + "into_pool_time", + "claim_time", + "protect_time", + "update_time", +) + +_NUMBER_FIELDS = ( + "deal_amount", + "follow_count", + "contact_count", + "opportunity_count", + "contract_count", +) + +_OPS = frozenset({"between", "eq", "gt", "gte", "lt", "lte"}) + +_FMT = "%Y-%m-%d %H:%M:%S" +_DATE_FMT = "%Y-%m-%d" + + +def _split_csv(raw: Optional[str]) -> List[str]: + if not raw or not str(raw).strip(): + return [] + return [s.strip() for s in str(raw).split(",") if s.strip()] + + +def _parse_number(raw: Optional[str], *, as_int: bool = False): + text = str(raw or "").strip() + if not text: + return None + try: + if as_int: + return int(Decimal(text)) + return Decimal(text) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _parse_day_start(raw: Optional[str]) -> Optional[datetime]: + if raw is None: + return None + if isinstance(raw, datetime): + return raw + text = str(raw).strip() + if not text: + return None + for fmt in (_FMT, _DATE_FMT): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +def _append_enum_filters(filters: list, params: dict) -> None: + for field in _ENUM_FIELDS: + values = _split_csv(params.get(f"{field}_in")) + if not values: + single = params.get(field) + if single is not None and str(single).strip(): + values = [str(single).strip()] + if values: + col = getattr(CrmCustomer, field) + filters.append(col.in_(values)) + + +def _append_text_filters(filters: list, params: dict) -> None: + name = (params.get("customer_name") or "").strip() + if name: + filters.append( + or_( + CrmCustomer.customer_name.like(f"%{name}%"), + func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( + f"%{name.lower()}%" + ), + ) + ) + + for field in _TEXT_FIELDS: + raw = params.get(field) + kw = str(raw).strip() if raw is not None else "" + if kw: + col = getattr(CrmCustomer, field) + filters.append(col.like(f"%{kw}%")) + + +def _append_create_time_filters(filters: list, params: dict) -> None: + start_raw = ( + params.get("begin_time") + or params.get("create_time_from") + or params.get("create_time_start") + ) + end_raw = ( + params.get("end_time") + or params.get("create_time_to") + or params.get("create_time_end") + ) + start = _parse_day_start(start_raw) + if start is not None: + filters.append(CrmCustomer.create_time >= start) + + if end_raw is None or not str(end_raw).strip(): + return + end_text = str(end_raw).strip() + # 仅日期 → 闭区间到当天结束(次日不含) + try: + day = datetime.strptime(end_text, _DATE_FMT) + filters.append(CrmCustomer.create_time < day + timedelta(days=1)) + return + except ValueError: + pass + end = _parse_day_start(end_raw) + if end is not None: + filters.append(CrmCustomer.create_time <= end) + + +def _append_date_range_filters(filters: list, params: dict) -> None: + for field in _DATE_FIELDS: + col = getattr(CrmCustomer, field) + start = _parse_day_start(params.get(f"{field}_from")) + end_raw = params.get(f"{field}_to") + if start is not None: + filters.append(col >= start) + if end_raw is not None and str(end_raw).strip(): + end_text = str(end_raw).strip() + try: + day = datetime.strptime(end_text, _DATE_FMT) + filters.append(col < day + timedelta(days=1)) + except ValueError: + end = _parse_day_start(end_raw) + if end is not None: + filters.append(col <= end) + + +def _append_number_filters(filters: list, params: dict) -> None: + for field in _NUMBER_FIELDS: + op = (params.get(f"{field}_op") or "").strip() or "between" + if op not in _OPS: + continue + col = getattr(CrmCustomer, field) + as_int = field != "deal_amount" + if op == "between": + vmin = _parse_number(params.get(f"{field}_min"), as_int=as_int) + vmax = _parse_number(params.get(f"{field}_max"), as_int=as_int) + if vmin is not None: + filters.append(col >= vmin) + if vmax is not None: + filters.append(col <= vmax) + else: + val = _parse_number(params.get(field), as_int=as_int) + if val is None: + continue + if op == "eq": + filters.append(col == val) + elif op == "gt": + filters.append(col > val) + elif op == "gte": + filters.append(col >= val) + elif op == "lt": + filters.append(col < val) + elif op == "lte": + filters.append(col <= val) + + +def build_customer_column_filters(**params: Any) -> list: + """根据列筛参数返回 SQLAlchemy 二元表达式列表(不含 tenant/del_flag/view_mode)。""" + filters: list = [] + _append_enum_filters(filters, params) + _append_text_filters(filters, params) + _append_create_time_filters(filters, params) + _append_date_range_filters(filters, params) + _append_number_filters(filters, params) + + owner_user_id = params.get("owner_user_id") + if owner_user_id is not None and str(owner_user_id).strip(): + filters.append(CrmCustomer.owner_user_id == str(owner_user_id).strip()) + + return filters diff --git a/backend/tests/test_crm_customer_list_filters.py b/backend/tests/test_crm_customer_list_filters.py new file mode 100644 index 000000000..61e04a6b5 --- /dev/null +++ b/backend/tests/test_crm_customer_list_filters.py @@ -0,0 +1,59 @@ +"""CRM 客户列表列头/导出共用过滤条件拼装单测。""" +from __future__ import annotations + +from sqlalchemy.dialects import mysql + +from app.services.crm.customer_list_filters import build_customer_column_filters + + +def _compile(clause) -> str: + return str( + clause.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}) + ).lower() + + +def test_level_in_adds_in_clause(): + filters = build_customer_column_filters(level_in="key,normal") + assert len(filters) == 1 + sql = _compile(filters[0]) + assert "level" in sql + assert "in (" in sql + assert "key" in sql + assert "normal" in sql + + +def test_customer_name_fuzzy(): + filters = build_customer_column_filters(customer_name="三峡") + assert len(filters) == 1 + sql = _compile(filters[0]) + assert "customer_name" in sql + assert "三峡" in sql + + +def test_deal_amount_between_min_only(): + filters = build_customer_column_filters( + deal_amount_op="between", + deal_amount_min="100", + ) + assert len(filters) == 1 + sql = _compile(filters[0]) + assert "deal_amount" in sql + assert ">=" in sql + assert "100" in sql + + +def test_begin_end_time_create_time(): + filters = build_customer_column_filters( + begin_time="2026-01-01", + end_time="2026-01-31", + ) + assert len(filters) >= 1 + joined = " ".join(_compile(f) for f in filters) + assert "create_time" in joined + assert ">=" in joined + assert "2026-01-01" in joined + + +def test_empty_params_yield_no_filters(): + filters = build_customer_column_filters() + assert filters == []