Переглянути джерело

feat(crm): add customer list column filter builder.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 місяць тому
батько
коміт
3bb905bc20

+ 1 - 0
backend/app/services/crm/__init__.py

@@ -0,0 +1 @@
+"""CRM 模块 Service 包。"""

+ 223 - 0
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

+ 59 - 0
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 == []