802 lines
26 KiB
Python
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]
|