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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-06 08:17:06 +08:00
1 parent f33b905f0d
commit 3bb905bc20
3 files changed
+283

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""CRM 模块 Service 包。"""
@@ -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
@@ -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 == []