feat(crm): add customer list column filter builder.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f33b905f0d
commit
3bb905bc20
3 files changed
+283
No files matched your search
@@ -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 == []
|
||||
Reference in new issue
Block a user