"""CRM 客户 CRUD(客户主表 + 转移/成交/公海池操作)""" from __future__ import annotations import math from datetime import datetime, timedelta, 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 CrmCustomer from app.models.models import SysUser from app.crud.crm.data_scope_helpers import ( apply_crm_create_by_scope, apply_crm_owner_scope, ensure_crm_owner_row_in_scope, ) from app.utils.pinyin_abbr import resolve_name_pinyin_abbr def _ensure_customer_in_scope(db: Session, customer: CrmCustomer, user_info, tenant_no: str) -> None: ensure_crm_owner_row_in_scope(db, customer, 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: CrmCustomer) -> Dict[str, Any]: return { "id": row.id, "customerNo": row.customer_no or "", "customerName": row.customer_name or "", "namePinyinAbbr": getattr(row, "name_pinyin_abbr", None) or "", "shortName": row.short_name or "", "customerType": row.customer_type or "", "mobile": row.mobile or "", "telephone": row.telephone or "", "email": row.email or "", "website": row.website or "", "level": row.level or "", "source": row.source or "", "industry": row.industry or "", "parkArea": row.park_area or "", "paymentTerms": row.payment_terms or "", "scale": row.scale or "", "province": row.province or "", "city": row.city or "", "district": row.district or "", "address": row.address or "", "status": row.status or "", "dealStatus": row.deal_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, "dealTime": row.deal_time.strftime(_FMT) if row.deal_time else "", "dealAmount": float(row.deal_amount) if row.deal_amount else 0.0, "contactCount": row.contact_count if row.contact_count is not None else 0, "opportunityCount": row.opportunity_count if row.opportunity_count is not None else 0, "contractCount": row.contract_count if row.contract_count is not None else 0, "intoPoolTime": row.into_pool_time.strftime(_FMT) if row.into_pool_time else "", "intoPoolBy": row.into_pool_by or "", "intoPoolReason": row.into_pool_reason or "", "claimTime": row.claim_time.strftime(_FMT) if row.claim_time else "", "assignTime": row.assign_time.strftime(_FMT) if row.assign_time else "", "assignBy": row.assign_by or "", "protectTime": row.protect_time.strftime(_FMT) if row.protect_time else "", "businessLicenseImg": row.business_license_img or "", "businessLicenseImages": row.business_license_img or "", "businessPermitImages": row.business_permit_images or "", "otherCertImages": row.other_cert_images or "", "clueId": row.clue_id 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_customer_options( db: Session, tenant_no: str, *, keyword: str = None, customer_name: str = None, limit: int = 50, ) -> List[Dict[str, Any]]: """客户下拉选项:租户内只读搜索,不做 owner 数据权限(供地磅/合同等主数据选用)。""" q = db.query(CrmCustomer).filter( CrmCustomer.tenant_no == tenant_no, CrmCustomer.del_flag == 0, ) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q = q.filter( or_( CrmCustomer.customer_name.like(like), CrmCustomer.customer_no.like(like), CrmCustomer.short_name.like(like), func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( f"%{kw.lower()}%" ), ) ) name = (customer_name or "").strip() if name: q = q.filter( or_( CrmCustomer.customer_name.like(f"%{name}%"), func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( f"%{name.lower()}%" ), ) ) limit = min(max(1, int(limit or 50)), 200) rows = q.order_by(CrmCustomer.create_time.desc()).limit(limit).all() return [ { "id": r.id, "customerName": r.customer_name, "customerNo": r.customer_no, "label": r.customer_name, "value": r.id, } for r in rows ] def list_customers( db: Session, tenant_no: str, user_id: str = None, *, page: int = 1, page_size: int = 10, keyword: str = None, view_mode: str = None, customer_name: str = None, customer_no: str = None, status: str = None, deal_status: str = None, level: str = None, source: str = None, customer_type: 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, skip_data_scope: bool = False, max_page_size: Optional[int] = None, ) -> Tuple[List[Dict[str, Any]], int]: q = db.query(CrmCustomer).filter( CrmCustomer.tenant_no == tenant_no, CrmCustomer.del_flag == 0, ) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q = q.filter( or_( CrmCustomer.customer_name.like(like), CrmCustomer.customer_no.like(like), CrmCustomer.mobile.like(like), CrmCustomer.short_name.like(like), func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( f"%{kw.lower()}%" ), ) ) if view_mode == "mine" and user_id: q = q.filter(CrmCustomer.owner_user_id == user_id) q = q.filter(CrmCustomer.status == "normal") elif view_mode == "toFollow": q = q.filter(CrmCustomer.status == "normal") q = q.filter(CrmCustomer.next_time.isnot(None)) q = q.filter(CrmCustomer.next_time <= datetime.now()) elif view_mode == "key": q = q.filter(CrmCustomer.level == "key") elif view_mode == "pool": q = q.filter(CrmCustomer.status == "pool") elif view_mode == "dealed": q = q.filter(CrmCustomer.deal_status == "dealed") elif view_mode == "weekNew": week_ago = datetime.now() - timedelta(days=7) q = q.filter(CrmCustomer.create_time >= week_ago) elif view_mode == "longTimeNoFollow": days_30_ago = datetime.now() - timedelta(days=30) q = q.filter( or_( CrmCustomer.last_time.is_(None), CrmCustomer.last_time <= days_30_ago, ) ) if customer_name: cn = customer_name.strip() if cn: q = q.filter( or_( CrmCustomer.customer_name.like(f"%{cn}%"), func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like( f"%{cn.lower()}%" ), ) ) if customer_no: q = q.filter(CrmCustomer.customer_no.like(f"%{customer_no}%")) if status: q = q.filter(CrmCustomer.status == status) if deal_status: q = q.filter(CrmCustomer.deal_status == deal_status) if level: q = q.filter(CrmCustomer.level == level) if source: q = q.filter(CrmCustomer.source == source) if customer_type: q = q.filter(CrmCustomer.customer_type == customer_type) if industry: q = q.filter(CrmCustomer.industry == industry) if park_area: q = q.filter(CrmCustomer.park_area == park_area) if owner_user_id: q = q.filter(CrmCustomer.owner_user_id == owner_user_id) if create_time_start: q = q.filter(CrmCustomer.create_time >= create_time_start) if create_time_end: q = q.filter(CrmCustomer.create_time <= create_time_end) pool_scope_exempt = skip_data_scope or view_mode == "pool" or status == "pool" if user_info and not pool_scope_exempt: q = apply_crm_owner_scope(q, CrmCustomer, user_info, db, tenant_no) total = q.order_by(None).with_entities(func.count(CrmCustomer.id)).scalar() or 0 page = max(1, page) cap = max_page_size if max_page_size is not None else 500 page_size = min(max(1, page_size), cap) order_by = CrmCustomer.create_time.desc() if view_mode == "toFollow": order_by = CrmCustomer.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 list_pool_customers( db: Session, tenant_no: str, *, page: int = 1, page_size: int = 10, keyword: str = None, level: str = None, industry: str = None, park_area: str = None, user_info=None, max_page_size: Optional[int] = None, ) -> Tuple[List[Dict[str, Any]], int]: return list_customers( db, tenant_no, page=page, page_size=page_size, keyword=keyword, status="pool", level=level, industry=industry, park_area=park_area, user_info=user_info, skip_data_scope=True, max_page_size=max_page_size, ) def get_customer(db: Session, customer_id: str) -> Optional[CrmCustomer]: return ( db.query(CrmCustomer) .filter( CrmCustomer.id == customer_id, CrmCustomer.del_flag == 0, ) .first() ) def get_customer_by_no(db: Session, customer_no: str, tenant_no: str) -> Optional[CrmCustomer]: return ( db.query(CrmCustomer) .filter( CrmCustomer.customer_no == customer_no, CrmCustomer.tenant_no == tenant_no, CrmCustomer.del_flag == 0, ) .first() ) def generate_customer_no(db: Session, tenant_no: str) -> str: today_str = datetime.now().strftime("%Y%m%d") prefix = f"C{today_str}" max_no = ( db.query(CrmCustomer.customer_no) .filter( CrmCustomer.customer_no.like(f"{prefix}%"), CrmCustomer.tenant_no == tenant_no, ) .order_by(CrmCustomer.customer_no.desc()) .first() ) if max_no and max_no[0]: seq_str = max_no[0][len(prefix):] try: seq = int(seq_str) + 1 except ValueError: seq = 1 else: seq = 1 return f"{prefix}{seq:04d}" def customer_name_exists( db: Session, tenant_no: str, name: str, exclude_id: str = None ) -> bool: normalized = (name or "").strip() if not normalized: return False q = db.query(CrmCustomer).filter( CrmCustomer.customer_name == normalized, CrmCustomer.tenant_no == tenant_no, CrmCustomer.del_flag == 0, ) if exclude_id: q = q.filter(CrmCustomer.id != exclude_id) return q.first() is not None def check_duplicate_customer( db: Session, tenant_no: str, mobile: str = None, customer_name: str = None, exclude_id: str = None, ) -> list: conditions = [CrmCustomer.tenant_no == tenant_no, CrmCustomer.del_flag == 0] if exclude_id: conditions.append(CrmCustomer.id != exclude_id) or_clauses = [] if mobile: or_clauses.append(CrmCustomer.mobile == mobile) if customer_name: normalized_name = customer_name.strip() if normalized_name: or_clauses.append(CrmCustomer.customer_name == normalized_name) if not or_clauses: return [] rows = db.query(CrmCustomer).filter(*conditions).filter(or_(*or_clauses)).all() return [_row_to_dict(r) for r in rows] def create_customer( db: Session, *, tenant_no: str, user_id: str = None, user_name: str = None, dept_id: str = "", **kwargs, ) -> CrmCustomer: customer_name = (kwargs.get("customer_name") or "").strip() if not customer_name: raise ValueError("客户名称不能为空") if customer_name_exists(db, tenant_no, customer_name): raise ValueError("客户名称已存在") kwargs["customer_name"] = customer_name customer_no = kwargs.get("customer_no") if not customer_no: customer_no = generate_customer_no(db, tenant_no) owner_id = (kwargs.pop("owner_user_id", None) or user_id or "").strip() or None owner_name = _resolve_user_display_name(db, owner_id) or (user_name or "") # 拼音首字母:新增始终按提交值解析(空则由客户名称生成);兼容 snake / camel 键 submitted = kwargs.get("name_pinyin_abbr") if submitted is None and "namePinyinAbbr" in kwargs: submitted = kwargs.get("namePinyinAbbr") name_pinyin_abbr = resolve_name_pinyin_abbr( name=kwargs.get("customer_name"), submitted=submitted, submitted_provided=True, ) obj = CrmCustomer( id=generate_uuid(), customer_no=customer_no, customer_name=kwargs.get("customer_name"), name_pinyin_abbr=name_pinyin_abbr, short_name=kwargs.get("short_name"), customer_type=kwargs.get("customer_type", "company"), mobile=kwargs.get("mobile"), telephone=kwargs.get("telephone"), email=kwargs.get("email"), website=kwargs.get("website"), level=kwargs.get("level", "normal"), source=kwargs.get("source"), industry=kwargs.get("industry"), park_area=kwargs.get("park_area"), payment_terms=kwargs.get("payment_terms"), scale=kwargs.get("scale"), province=kwargs.get("province"), city=kwargs.get("city"), district=kwargs.get("district"), address=kwargs.get("address"), status="normal", deal_status="not_deal", owner_user_id=owner_id, owner_user_name=owner_name, follow_count=0, deal_amount=0, contact_count=0, opportunity_count=0, contract_count=0, business_license_img=kwargs.get("business_license_img"), business_permit_images=kwargs.get("business_permit_images"), other_cert_images=kwargs.get("other_cert_images"), clue_id=kwargs.get("clue_id"), next_time=_parse_optional_datetime(kwargs.get("next_time")), tenant_no=tenant_no, order_num=kwargs.get("order_num"), remark=kwargs.get("remark"), create_by=user_id, create_dept_id=dept_id, del_flag=0, ) db.add(obj) db.commit() db.refresh(obj) return obj def update_customer( db: Session, customer_id: str, *, user_id: str = None, user_info=None, tenant_no: str = None, **kwargs, ) -> CrmCustomer: obj = get_customer(db, customer_id) if not obj: raise ValueError("客户不存在") if tenant_no and obj.tenant_no != tenant_no: raise ValueError("客户不存在") _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) if "customer_name" in kwargs and kwargs.get("customer_name") is not None: normalized_name = (kwargs.get("customer_name") or "").strip() if not normalized_name: raise ValueError("客户名称不能为空") if normalized_name != (obj.customer_name or "").strip(): if customer_name_exists(db, obj.tenant_no, normalized_name, exclude_id=customer_id): raise ValueError("客户名称已存在") kwargs["customer_name"] = normalized_name # name_pinyin_abbr 单独处理,避免双重赋值 _updatable = ( "customer_name", "short_name", "customer_type", "mobile", "telephone", "email", "website", "level", "source", "industry", "park_area", "payment_terms", "scale", "province", "city", "district", "address", "business_license_img", "business_permit_images", "other_cert_images", "clue_id", "order_num", "remark", "next_time", ) _datetime_fields = {"next_time"} for field in _updatable: if field not in kwargs: continue val = kwargs[field] if field in _datetime_fields: setattr(obj, field, _parse_optional_datetime(val)) elif val is not None: setattr(obj, field, val) # 拼音首字母:提交了则用提交值(空则重生);未提交则保留已有,已有空则按名称生成 name_keys = ("name_pinyin_abbr", "namePinyinAbbr") submitted_provided = any(k in kwargs for k in name_keys) submitted = kwargs.get("name_pinyin_abbr") if submitted is None: submitted = kwargs.get("namePinyinAbbr") if "customer_name" in kwargs and kwargs.get("customer_name") is not None: new_name = kwargs.get("customer_name") else: new_name = obj.customer_name obj.name_pinyin_abbr = resolve_name_pinyin_abbr( name=new_name, submitted=submitted, existing=obj.name_pinyin_abbr, submitted_provided=submitted_provided, ) if "owner_user_id" in kwargs: owner_id = (kwargs.pop("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_customer( db: Session, customer_id: str, user_id: str = None, user_info=None, tenant_no: str = None ) -> bool: obj = get_customer(db, customer_id) if not obj: raise ValueError("客户不存在") if tenant_no and obj.tenant_no != tenant_no: raise ValueError("客户不存在") _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) 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 transfer_customer( db: Session, customer_id: str, new_owner_id: str, new_owner_name: str, user_id: str = None, user_info=None, tenant_no: str = None, ) -> CrmCustomer: obj = get_customer(db, customer_id) if not obj: raise ValueError("客户不存在") if tenant_no and obj.tenant_no != tenant_no: raise ValueError("客户不存在") _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) old_owner_id = obj.owner_user_id or "" old_owner_name = obj.owner_user_name or "" obj.owner_user_id = new_owner_id obj.owner_user_name = new_owner_name obj.update_by = user_id obj.update_time = datetime.now() db.commit() db.refresh(obj) from app.crud.crm.customer_pool import create_pool_record create_pool_record( db, tenant_no=obj.tenant_no, user_id=user_id, customer_id=obj.id, customer_name=obj.customer_name, operation_type="assign", from_user_id=old_owner_id, from_user_name=old_owner_name, to_user_id=new_owner_id, to_user_name=new_owner_name, ) return obj def deal_customer( db: Session, customer_id: str, deal_amount: float = 0, deal_time: str = None, user_id: str = None, user_info=None, tenant_no: str = None, ) -> CrmCustomer: obj = get_customer(db, customer_id) if not obj: raise ValueError("客户不存在") if tenant_no and obj.tenant_no != tenant_no: raise ValueError("客户不存在") _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) obj.deal_status = "dealed" if deal_amount: obj.deal_amount = deal_amount parsed_deal_time = _parse_optional_datetime(deal_time) obj.deal_time = parsed_deal_time or datetime.now() obj.update_by = user_id obj.update_time = datetime.now() db.commit() db.refresh(obj) return obj def into_pool( db: Session, customer_id: str, reason: str = None, user_id: str = None, user_info=None, tenant_no: str = None, ) -> CrmCustomer: obj = get_customer(db, customer_id) if not obj: raise ValueError("客户不存在") if tenant_no and obj.tenant_no != tenant_no: raise ValueError("客户不存在") _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) old_owner_id = obj.owner_user_id or "" old_owner_name = obj.owner_user_name or "" obj.status = "pool" obj.into_pool_time = datetime.now() obj.into_pool_by = user_id obj.into_pool_reason = reason obj.owner_user_id = None obj.update_by = user_id obj.update_time = datetime.now() db.commit() db.refresh(obj) from app.crud.crm.customer_pool import create_pool_record create_pool_record( db, tenant_no=obj.tenant_no, user_id=user_id, customer_id=obj.id, customer_name=obj.customer_name, operation_type="into", from_user_id=old_owner_id, from_user_name=old_owner_name, reason=reason, ) return obj def claim_customer( db: Session, customer_id: str, user_id: str, user_name: str, ) -> CrmCustomer: obj = get_customer(db, customer_id) if not obj: raise ValueError("客户不存在") if obj.status != "pool": raise ValueError("该客户不在公海池中") obj.status = "normal" obj.owner_user_id = user_id obj.owner_user_name = user_name obj.claim_time = datetime.now() obj.into_pool_time = None obj.into_pool_by = None obj.into_pool_reason = None obj.update_by = user_id obj.update_time = datetime.now() db.commit() db.refresh(obj) from app.crud.crm.customer_pool import create_pool_record create_pool_record( db, tenant_no=obj.tenant_no, user_id=user_id, customer_id=obj.id, customer_name=obj.customer_name, operation_type="claim", to_user_id=user_id, to_user_name=user_name, ) return obj def batch_claim_customers( db: Session, ids: List[str], user_id: str, user_name: str, ) -> List[CrmCustomer]: results = [] for cid in ids: obj = claim_customer(db, cid, user_id, user_name) results.append(obj) return results def assign_customer( db: Session, ids: List[str], owner_user_id: str, owner_user_name: str, user_id: str = None, user_info=None, tenant_no: str = None, ) -> List[CrmCustomer]: results = [] for cid in ids: obj = get_customer(db, cid) if not obj: continue if tenant_no and obj.tenant_no != tenant_no: continue _ensure_customer_in_scope(db, obj, user_info, obj.tenant_no) old_owner_id = obj.owner_user_id or "" old_owner_name = obj.owner_user_name or "" obj.status = "normal" obj.owner_user_id = owner_user_id obj.owner_user_name = owner_user_name obj.assign_time = datetime.now() obj.assign_by = user_id obj.into_pool_time = None obj.into_pool_by = None obj.into_pool_reason = None obj.update_by = user_id obj.update_time = datetime.now() from app.crud.crm.customer_pool import create_pool_record create_pool_record( db, tenant_no=obj.tenant_no, user_id=user_id, customer_id=obj.id, customer_name=obj.customer_name, operation_type="assign", from_user_id=old_owner_id, from_user_name=old_owner_name, to_user_id=owner_user_id, to_user_name=owner_user_name, ) results.append(obj) db.commit() return results def update_follow_stats( db: Session, customer_id: str, follow_time: datetime = None, next_time: datetime = None, ) -> None: obj = get_customer(db, customer_id) if not obj: return if follow_time: obj.last_time = follow_time if next_time: obj.next_time = next_time obj.follow_count = (obj.follow_count or 0) + 1 db.commit() def update_contact_count(db: Session, customer_id: str) -> None: from app.models.crm_models import CrmContact count = ( db.query(func.count(CrmContact.id)) .filter( CrmContact.customer_id == customer_id, CrmContact.del_flag == 0, ) .scalar() or 0 ) obj = get_customer(db, customer_id) if obj: obj.contact_count = count db.commit() def get_park_area_list(db: Session, tenant_no: str) -> list: return [ r[0] for r in db.query(CrmCustomer.park_area) .filter( CrmCustomer.tenant_no == tenant_no, CrmCustomer.del_flag == 0, CrmCustomer.park_area.isnot(None), CrmCustomer.park_area != "", ) .distinct() .order_by(CrmCustomer.park_area) .all() ]