Files
shanxia2.0/backend/app/crud/crm/clue.py
T
2026-08-26 11:10:40 +08:00

802 lines
26 KiB
Python

"""CRM 线索 CRUD(线索主表 + 转化/分配/无效/等级操作)"""
from __future__ import annotations
import math
from datetime import datetime, date
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.delete_guard import assert_row_deletable_by_creator
from app.core.utils import generate_uuid
from app.models.crm_models import CrmClue
from app.models.models import SysUser
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope
def _ensure_clue_in_scope(db: Session, clue: CrmClue, user_info, tenant_no: str) -> None:
ensure_crm_owner_row_in_scope(db, clue, user_info, tenant_no, message="无权访问该线索")
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
return math.ceil(total / page_size)
_FMT = "%Y-%m-%d %H:%M:%S"
_DATE_FMT = "%Y-%m-%d"
def _parse_optional_date(value: Any) -> Optional[date]:
if value is None:
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
text = str(value).strip()
if not text:
return None
for fmt in (_DATE_FMT, _FMT):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
raise ValueError(f"日期格式无效: {text}")
def _parse_optional_datetime(value: Any) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
return value
text = str(value).strip()
if not text:
return None
for fmt in (_FMT, _DATE_FMT):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
raise ValueError(f"日期时间格式无效: {text}")
def _resolve_user_display_name(db: Session, user_id: Optional[str]) -> str:
uid = (user_id or "").strip()
if not uid:
return ""
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
if not u:
return ""
return (u.nick_name or u.user_no or "").strip()
def _enrich_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
field_pairs = (
("ownerUserId", "ownerUserName"),
("createBy", "createByName"),
("updateBy", "updateByName"),
)
need_ids: set[str] = set()
for d in rows:
for id_field, name_field in field_pairs:
uid = str(d.get(id_field) or "").strip()
if uid and not (d.get(name_field) or "").strip():
need_ids.add(uid)
if not need_ids:
return
users = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(need_ids), SysUser.del_flag == 0)
.all()
)
name_map = {u.id: (u.nick_name or u.user_no or "").strip() for u in users}
for d in rows:
for id_field, name_field in field_pairs:
uid = str(d.get(id_field) or "").strip()
if uid and not (d.get(name_field) or "").strip():
d[name_field] = name_map.get(uid, "")
def _row_to_dict(row: CrmClue) -> Dict[str, Any]:
return {
"id": row.id,
"clueName": row.clue_name or "",
"mobile": row.mobile or "",
"company": row.company or "",
"customerType": row.customer_type or "",
"telephone": row.telephone or "",
"email": row.email or "",
"title": row.title or "",
"position": row.position or "",
"source": row.source or "",
"level": row.level or "",
"industry": row.industry or "",
"parkArea": row.park_area or "",
"province": row.province or "",
"city": row.city or "",
"district": row.district or "",
"address": row.address or "",
"website": row.website or "",
"intentProducts": row.intent_products or "",
"expectTime": row.expect_time.strftime("%Y-%m-%d") if row.expect_time else "",
"budget": row.budget or "",
"requirement": row.requirement or "",
"status": row.status or "",
"ownerUserId": row.owner_user_id or "",
"ownerUserName": row.owner_user_name or "",
"lastTime": row.last_time.strftime(_FMT) if row.last_time else "",
"nextTime": row.next_time.strftime(_FMT) if row.next_time else "",
"followCount": row.follow_count if row.follow_count is not None else 0,
"isConverted": row.is_converted if row.is_converted is not None else 0,
"convertedTime": row.converted_time.strftime(_FMT) if row.converted_time else "",
"customerId": row.customer_id or "",
"convertedBy": row.converted_by or "",
"assignTime": row.assign_time.strftime(_FMT) if row.assign_time else "",
"assignBy": row.assign_by or "",
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createBy": row.create_by or "",
"createTime": row.create_time.strftime(_FMT) if row.create_time else "",
"updateBy": row.update_by or "",
"updateTime": row.update_time.strftime(_FMT) if row.update_time else "",
}
def list_clues(
db: Session,
tenant_no: str,
user_id: str = None,
*,
page: int = 1,
page_size: int = 10,
keyword: str = None,
view_type: str = None,
view_mode: str = None,
clue_name: str = None,
company: str = None,
mobile: str = None,
source: str = None,
level: str = None,
status: 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,
) -> Tuple[List[Dict[str, Any]], int]:
q = db.query(CrmClue).filter(
CrmClue.tenant_no == tenant_no,
CrmClue.del_flag == 0,
)
# view_type takes precedence, fall back to view_mode for compatibility
effective_view = view_type or view_mode
if effective_view == "mine" and user_id:
q = q.filter(CrmClue.owner_user_id == user_id, CrmClue.is_converted == 0)
elif effective_view == "toFollow":
q = q.filter(CrmClue.next_time.isnot(None))
q = q.filter(CrmClue.next_time <= datetime.now())
q = q.filter(CrmClue.status == "follow", CrmClue.is_converted == 0)
elif effective_view == "todayNew":
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
q = q.filter(CrmClue.create_time >= today_start)
elif effective_view == "converted":
q = q.filter(CrmClue.is_converted == 1)
# "all" → no extra filter
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(
or_(
CrmClue.clue_name.like(like),
CrmClue.mobile.like(like),
CrmClue.company.like(like),
)
)
if clue_name:
q = q.filter(CrmClue.clue_name.like(f"%{clue_name}%"))
if company:
q = q.filter(CrmClue.company.like(f"%{company}%"))
if mobile:
q = q.filter(CrmClue.mobile.like(f"%{mobile}%"))
if source:
q = q.filter(CrmClue.source == source)
if level:
q = q.filter(CrmClue.level == level)
if status:
q = q.filter(CrmClue.status == status)
if industry:
q = q.filter(CrmClue.industry == industry)
if park_area:
q = q.filter(CrmClue.park_area == park_area)
if owner_user_id:
q = q.filter(CrmClue.owner_user_id == owner_user_id)
if create_time_start:
q = q.filter(CrmClue.create_time >= create_time_start)
if create_time_end:
q = q.filter(CrmClue.create_time <= create_time_end)
if user_info:
q = apply_crm_owner_scope(q, CrmClue, user_info, db, tenant_no)
total = q.order_by(None).with_entities(func.count(CrmClue.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 500)
order_by = CrmClue.create_time.desc()
if effective_view == "toFollow":
order_by = CrmClue.next_time.asc()
rows = (
q.order_by(order_by)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = [_row_to_dict(r) for r in rows]
_enrich_user_names(db, result)
return result, int(total)
def get_clue(db: Session, clue_id: str) -> Optional[CrmClue]:
return (
db.query(CrmClue)
.filter(
CrmClue.id == clue_id,
CrmClue.del_flag == 0,
)
.first()
)
def create_clue(
db: Session,
tenant_no: str,
data: dict,
user_id: str = None,
) -> CrmClue:
mobile = (data.get("mobile") or "").strip()
if mobile:
exists = (
db.query(CrmClue)
.filter(
CrmClue.mobile == mobile,
CrmClue.tenant_no == tenant_no,
CrmClue.del_flag == 0,
)
.first()
)
if exists:
raise ValueError(f"手机号 {mobile} 已存在")
owner_id = (data.get("owner_user_id") or user_id or "").strip() or None
owner_name = _resolve_user_display_name(db, owner_id) if owner_id else ""
obj = CrmClue(
id=generate_uuid(),
clue_name=data.get("clue_name"),
mobile=mobile,
company=data.get("company"),
customer_type=data.get("customer_type"),
telephone=data.get("telephone"),
email=data.get("email"),
title=data.get("title"),
position=data.get("position"),
source=data.get("source"),
level=data.get("level", "unrated"),
industry=data.get("industry"),
park_area=data.get("park_area"),
province=data.get("province"),
city=data.get("city"),
district=data.get("district"),
address=data.get("address"),
website=data.get("website"),
intent_products=data.get("intent_products"),
expect_time=_parse_optional_date(data.get("expect_time")),
budget=data.get("budget"),
requirement=data.get("requirement"),
status="new",
owner_user_id=owner_id,
owner_user_name=owner_name,
follow_count=0,
is_converted=0,
next_time=_parse_optional_datetime(data.get("next_time")),
tenant_no=tenant_no,
order_num=data.get("order_num"),
remark=data.get("remark"),
create_by=user_id,
create_dept_id=data.get("create_dept_id", ""),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_clue(
db: Session,
clue_id: str,
data: dict,
user_id: str = None,
tenant_no: str = None,
user_info=None,
) -> CrmClue:
obj = get_clue(db, clue_id)
if not obj:
raise ValueError("线索不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("线索不存在")
_ensure_clue_in_scope(db, obj, user_info, obj.tenant_no)
if obj.is_converted == 1:
raise ValueError("已转化的线索不可修改")
if obj.status == "invalid":
raise ValueError("无效线索不可修改")
new_mobile = data.get("mobile")
if new_mobile and new_mobile != obj.mobile:
exists = (
db.query(CrmClue)
.filter(
CrmClue.mobile == new_mobile,
CrmClue.tenant_no == obj.tenant_no,
CrmClue.del_flag == 0,
CrmClue.id != clue_id,
)
.first()
)
if exists:
raise ValueError(f"手机号 {new_mobile} 已存在")
_updatable = (
"clue_name", "mobile", "company", "customer_type", "telephone",
"email", "title", "position", "source", "level", "industry",
"park_area", "province", "city", "district", "address", "website",
"intent_products", "expect_time", "budget", "requirement",
"next_time", "order_num", "remark",
)
_date_fields = {"expect_time"}
_datetime_fields = {"next_time"}
for field in _updatable:
if field not in data:
continue
val = data[field]
if field in _date_fields:
setattr(obj, field, _parse_optional_date(val))
elif field in _datetime_fields:
setattr(obj, field, _parse_optional_datetime(val))
elif val is not None:
setattr(obj, field, val)
if "owner_user_id" in data:
owner_id = (data["owner_user_id"] or "").strip() or None
obj.owner_user_id = owner_id
obj.owner_user_name = _resolve_user_display_name(db, owner_id) if owner_id else None
obj.update_by = user_id
obj.update_time = datetime.now()
db.commit()
db.refresh(obj)
return obj
def soft_delete_clue(
db: Session,
clue_id: str,
user_id: str = None,
tenant_no: str = None,
user_info=None,
) -> bool:
obj = get_clue(db, clue_id)
if not obj:
raise ValueError("线索不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("线索不存在")
_ensure_clue_in_scope(db, obj, user_info, obj.tenant_no)
if obj.is_converted == 1:
raise ValueError("已转化的线索不可删除")
assert_row_deletable_by_creator(obj, user_info, user_id=user_id)
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = datetime.now()
db.commit()
return True
def assign_clues(
db: Session,
ids: List[str],
owner_user_id: str,
owner_user_name: str,
current_user_id: str = None,
remark: str = None,
tenant_no: str = None,
user_info=None,
) -> int:
owner_name = (owner_user_name or "").strip()
if not owner_name:
owner_name = _resolve_user_display_name(db, owner_user_id)
q = db.query(CrmClue).filter(CrmClue.id.in_(ids), CrmClue.del_flag == 0)
if tenant_no:
q = q.filter(CrmClue.tenant_no == tenant_no)
rows = q.all()
if len(rows) != len(ids):
raise ValueError("部分线索不存在或无权操作")
converted = [r.id for r in rows if r.is_converted == 1]
if converted:
raise ValueError("已转化的线索不允许分配")
now = datetime.now()
remark_text = (remark or "").strip()
for row in rows:
_ensure_clue_in_scope(db, row, user_info, row.tenant_no)
row.owner_user_id = owner_user_id
row.owner_user_name = owner_name
row.assign_time = now
row.assign_by = current_user_id
row.update_by = current_user_id
row.update_time = now
if remark_text:
old = (row.remark or "").strip()
row.remark = (
f"{old}\n分配说明:{remark_text}".strip() if old else f"分配说明:{remark_text}"
)
db.commit()
return len(rows)
def convert_clue(
db: Session,
clue_id: str,
customer_data: dict,
current_user_id: str,
tenant_no: str,
user_info=None,
) -> dict:
from app.crud.crm.contact import create_contact
from app.crud.crm.customer import check_duplicate_customer, customer_name_exists, generate_customer_no
from app.models.crm_models import CrmCustomer
clue = get_clue(db, clue_id)
if not clue:
raise ValueError("线索不存在")
if clue.tenant_no != tenant_no:
raise ValueError("线索不存在")
_ensure_clue_in_scope(db, clue, user_info, tenant_no)
_validate_convertible(clue)
if clue.mobile:
dup_customers = check_duplicate_customer(db, tenant_no, mobile=clue.mobile)
if dup_customers:
name = dup_customers[0].get("customerName") or dup_customers[0].get("customer_name")
raise ValueError(f"该手机号已存在客户记录【{name}】,无法转化")
customer_name = (
customer_data.get("customer_name")
or clue.company
or clue.clue_name
or ""
).strip()
if not customer_name:
raise ValueError("客户名称不能为空")
if customer_name_exists(db, tenant_no, customer_name):
raise ValueError(f"客户名称【{customer_name}】已存在,无法转化")
now = datetime.now()
customer_type = (
customer_data.get("customer_type")
or clue.customer_type
or ("company" if (clue.company or "").strip() else "person")
)
customer = CrmCustomer(
id=generate_uuid(),
customer_no=generate_customer_no(db, tenant_no),
customer_name=customer_name,
customer_type=customer_type,
mobile=clue.mobile or "",
email=clue.email or "",
website=clue.website or "",
source=clue.source or "clue_convert",
industry=clue.industry or "",
park_area=clue.park_area or "",
province=clue.province or "",
city=clue.city or "",
district=clue.district or "",
address=clue.address or "",
owner_user_id=clue.owner_user_id,
owner_user_name=clue.owner_user_name,
clue_id=clue.id,
status="normal",
deal_status="not_deal",
follow_count=0,
deal_amount=0,
contact_count=0,
opportunity_count=0,
contract_count=0,
remark=customer_data.get("remark") or "",
tenant_no=tenant_no,
create_by=current_user_id,
create_dept_id="",
del_flag=0,
)
db.add(customer)
db.flush()
create_contact(
db,
tenant_no=tenant_no,
user_id=current_user_id,
customer_id=customer.id,
customer_name=customer.customer_name,
contact_name=customer_data.get("contact_name") or clue.clue_name,
mobile=customer_data.get("contact_mobile") or clue.mobile,
email=clue.email,
position=clue.position,
is_primary=1,
is_key=1,
status="active",
)
clue.status = "converted"
clue.is_converted = 1
clue.converted_time = now
clue.customer_id = customer.id
clue.converted_by = current_user_id
clue.update_by = current_user_id
clue.update_time = now
db.commit()
db.refresh(customer)
db.refresh(clue)
return {
"clue": _row_to_dict(clue),
"customerId": customer.id,
}
def _validate_convertible(clue: CrmClue) -> None:
if clue.is_converted == 1:
raise ValueError("该线索已转化,不可重复转化")
if clue.status == "invalid":
raise ValueError("无效线索不可转化")
if (clue.follow_count or 0) < 1:
raise ValueError("请先添加至少一条跟进记录后再转化")
if not (clue.clue_name or "").strip():
raise ValueError("线索姓名不能为空")
if not (clue.mobile or "").strip():
raise ValueError("手机号不能为空")
def check_convert(db: Session, clue_id: str, tenant_no: str = None) -> dict:
clue = get_clue(db, clue_id)
if not clue:
raise ValueError("线索不存在")
if tenant_no and clue.tenant_no != tenant_no:
raise ValueError("线索不存在")
_validate_convertible(clue)
return _row_to_dict(clue)
def invalid_clue(
db: Session,
clue_id: str,
user_id: str = None,
remark: str = None,
tenant_no: str = None,
user_info=None,
) -> CrmClue:
obj = get_clue(db, clue_id)
if not obj:
raise ValueError("线索不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("线索不存在")
_ensure_clue_in_scope(db, obj, user_info, obj.tenant_no)
if obj.is_converted == 1:
raise ValueError("已转化的线索不可标记无效")
obj.status = "invalid"
if remark:
old = (obj.remark or "").strip()
obj.remark = f"{old}\n无效原因:{remark}".strip() if old else f"无效原因:{remark}"
obj.update_by = user_id
obj.update_time = datetime.now()
db.commit()
db.refresh(obj)
return obj
def check_duplicate_clue(
db: Session,
tenant_no: str,
mobile: str = None,
company: str = None,
exclude_id: str = None,
) -> List[Dict[str, Any]]:
q = db.query(CrmClue).filter(
CrmClue.tenant_no == tenant_no,
CrmClue.del_flag == 0,
)
if exclude_id:
q = q.filter(CrmClue.id != exclude_id)
clauses = []
if mobile:
clauses.append(CrmClue.mobile == mobile.strip())
if company:
clauses.append(CrmClue.company.like(f"%{company.strip()}%"))
if not clauses:
return []
rows = q.filter(or_(*clauses)).all()
result = [_row_to_dict(r) for r in rows]
_enrich_user_names(db, result)
return result
def import_clues(
db: Session,
tenant_no: str,
rows: List[Dict[str, Any]],
user_id: str,
update_support: bool = False,
) -> Dict[str, Any]:
"""批量导入线索(Excel 行字典列表)"""
success_count = 0
update_count = 0
skip_count = 0
error_messages: List[str] = []
for idx, row in enumerate(rows, start=2):
clue_name = (row.get("clue_name") or row.get("clueName") or "").strip()
mobile = (row.get("mobile") or "").strip()
source = (row.get("source") or "").strip()
try:
if not clue_name:
error_messages.append(f"第{idx}行:线索姓名不能为空")
continue
if not mobile:
error_messages.append(f"第{idx}行:手机号不能为空")
continue
if not source:
error_messages.append(f"第{idx}行:线索来源不能为空")
continue
if not mobile.startswith("1") or len(mobile) != 11:
error_messages.append(f"第{idx}行:手机号格式不正确")
continue
exist = (
db.query(CrmClue)
.filter(
CrmClue.mobile == mobile,
CrmClue.tenant_no == tenant_no,
CrmClue.del_flag == 0,
)
.first()
)
payload = {
"clue_name": clue_name,
"mobile": mobile,
"company": row.get("company") or "",
"customer_type": row.get("customer_type") or row.get("customerType") or "",
"telephone": row.get("telephone") or "",
"email": row.get("email") or "",
"title": row.get("title") or "",
"position": row.get("position") or "",
"source": source,
"level": row.get("level") or "unrated",
"industry": row.get("industry") or "",
"park_area": row.get("park_area") or row.get("parkArea") or "",
"province": row.get("province") or "",
"city": row.get("city") or "",
"district": row.get("district") or "",
"address": row.get("address") or "",
"website": row.get("website") or "",
"intent_products": row.get("intent_products") or row.get("intentProducts") or "",
"remark": row.get("remark") or "",
}
if exist:
if update_support:
update_clue(db, exist.id, payload, user_id=user_id)
update_count += 1
else:
skip_count += 1
else:
payload["owner_user_id"] = user_id
create_clue(db, tenant_no, payload, user_id=user_id)
success_count += 1
except ValueError as e:
error_messages.append(f"第{idx}行【{clue_name or mobile}】:{e}")
except Exception as e:
error_messages.append(f"第{idx}行【{clue_name or mobile}】:导入失败 - {e}")
return {
"success": success_count,
"update": update_count,
"skip": skip_count,
"fail": len(error_messages),
"errors": error_messages[:20],
"message": (
f"成功导入{success_count}条"
+ (f",更新{update_count}条" if update_count else "")
+ (f",跳过{skip_count}条" if skip_count else "")
+ (f",失败{len(error_messages)}条" if error_messages else "")
),
}
def update_clue_level(
db: Session,
ids: List[str],
level: str,
user_id: str = None,
tenant_no: str = None,
user_info=None,
) -> int:
q = db.query(CrmClue).filter(CrmClue.id.in_(ids), CrmClue.del_flag == 0)
if tenant_no:
q = q.filter(CrmClue.tenant_no == tenant_no)
rows = q.all()
if len(rows) != len(ids):
raise ValueError("部分线索不存在或无权操作")
converted = [r.id for r in rows if r.is_converted == 1]
if converted:
raise ValueError("已转化的线索不可修改等级")
now = datetime.now()
for row in rows:
_ensure_clue_in_scope(db, row, user_info, row.tenant_no)
row.level = level
row.update_by = user_id
row.update_time = now
db.commit()
return len(rows)
def update_clue_status(
db: Session,
clue_id: str,
status: str,
user_id: str = None,
tenant_no: str = None,
user_info=None,
) -> CrmClue:
obj = get_clue(db, clue_id)
if not obj:
raise ValueError("线索不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("线索不存在")
_ensure_clue_in_scope(db, obj, user_info, obj.tenant_no)
if obj.is_converted == 1:
raise ValueError("已转化的线索不可修改状态")
obj.status = status
obj.update_by = user_id
obj.update_time = datetime.now()
db.commit()
db.refresh(obj)
return obj
def get_park_area_list(db: Session, tenant_no: str) -> List[str]:
rows = (
db.query(CrmClue.park_area)
.filter(
CrmClue.tenant_no == tenant_no,
CrmClue.del_flag == 0,
CrmClue.park_area.isnot(None),
CrmClue.park_area != "",
)
.distinct()
.order_by(CrmClue.park_area)
.all()
)
return [r[0] for r in rows]