Files
crm_project/server/app/services/customer_service.py
T
hankin 815cbf9d8c v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件
- 移除误跟踪的 server/venv/、crm_data.db、.env 文件
- 新增 server/.env.example 模板
- 新增合同管理、利润核算、AI教练等功能模块
- 新增 Playwright e2e 测试套件
- 前后端多项功能升级和 bug 修复
2026-05-11 07:24:19 +00:00

434 lines
14 KiB
Python

"""
客户管理 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.crm import CrmCustomer
from app.models.sys import SysUser
from app.schemas.auth import CurrentUserPayload
from app.schemas.crm import (
CustomerCreate,
CustomerListResponse,
CustomerResponse,
CustomerUpdate,
)
# ── ORM → Response ───────────────────────────────────────
def _to_response(c: CrmCustomer) -> CustomerResponse:
return CustomerResponse(
id=c.id,
name=c.name,
level=c.level,
industry=c.industry,
contact=c.contact,
phone=c.phone,
email=c.email,
address=c.address,
ai_score=float(c.ai_score or 0),
ai_persona=c.ai_persona,
billing_info=c.billing_info,
owner_id=c.owner_id,
owner_name=c.owner.real_name if c.owner else None,
status=c.status,
is_deleted=c.is_deleted,
created_at=c.created_at,
updated_at=c.updated_at,
)
# ── 递归查询本部门 + 子部门所有用户 ID ────────────────────
async def _get_dept_and_sub_user_ids(
db: AsyncSession, dept_id: uuid.UUID
) -> list[uuid.UUID]:
"""递归获取指定部门及其所有子部门下的用户 ID 列表"""
from app.models.sys import SysDepartment, SysUser
# 收集所有目标部门 ID(递归子部门)
dept_ids: list[uuid.UUID] = [dept_id]
queue = [dept_id]
while queue:
current = queue.pop(0)
children = (await db.execute(
select(SysDepartment.id).where(
SysDepartment.parent_id == current,
SysDepartment.is_deleted.is_(False),
)
)).scalars().all()
for child_id in children:
dept_ids.append(child_id)
queue.append(child_id)
# 查询这些部门下的所有用户 ID
user_ids = (await db.execute(
select(SysUser.id).where(
SysUser.dept_id.in_(dept_ids),
SysUser.is_deleted.is_(False),
)
)).scalars().all()
return list(user_ids)
# ── 权限校验 ─────────────────────────────────────────────
def _check_access(customer: CrmCustomer, user: CurrentUserPayload, *, dept_user_ids: list[uuid.UUID] | None = None) -> None:
if user.data_scope == "all":
return
if user.data_scope == "dept_and_sub":
# 如果有预查询的部门用户列表,校验 owner 是否在列表内
if dept_user_ids is not None:
if customer.owner_id not in dept_user_ids:
raise ForbiddenException("无权访问该客户(数据权限:本部门及子部门)")
return
# data_scope == 'self'
if customer.owner_id != user.user_id:
raise ForbiddenException("无权访问该客户(数据权限:仅本人)")
# ── Service Functions ────────────────────────────────────
async def create_customer(
db: AsyncSession,
user: CurrentUserPayload,
body: CustomerCreate,
) -> CustomerResponse:
customer = CrmCustomer(
name=body.name,
level=body.level,
industry=body.industry,
contact=body.contact,
phone=body.phone,
email=body.email,
address=body.address,
billing_info=body.billing_info.model_dump() if body.billing_info else None,
status=body.status,
owner_id=user.user_id,
)
db.add(customer)
await db.commit()
await db.refresh(customer)
return _to_response(customer)
async def list_customers(
db: AsyncSession,
user: CurrentUserPayload,
page: int = 1,
size: int = 20,
keyword: str | None = None,
level: str | None = None,
include_archived: bool = False,
) -> CustomerListResponse:
if include_archived:
base_where = [] # 不过滤 is_deleted
else:
base_where = [CrmCustomer.is_deleted.is_(False)]
# ── 数据域隔离 ──
if user.data_scope == "self":
base_where.append(CrmCustomer.owner_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
if dept_user_ids:
base_where.append(CrmCustomer.owner_id.in_(dept_user_ids))
else:
# 部门无用户 → 仅显示自己的
base_where.append(CrmCustomer.owner_id == user.user_id)
if keyword:
base_where.append(CrmCustomer.name.ilike(f"%{keyword}%"))
if level:
base_where.append(CrmCustomer.level == level)
total = (
await db.execute(select(func.count()).select_from(CrmCustomer).where(*base_where))
).scalar() or 0
stmt = (
select(CrmCustomer)
.where(*base_where)
.order_by(CrmCustomer.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
customers = (await db.execute(stmt)).scalars().all()
return CustomerListResponse(
total=total,
items=[_to_response(c) for c in customers],
page=page,
size=size,
)
async def get_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> CustomerResponse:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
# dept_and_sub 需要先查询部门用户列表
dept_user_ids = None
if user.data_scope == "dept_and_sub" and user.dept_id:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
_check_access(customer, user, dept_user_ids=dept_user_ids)
return _to_response(customer)
async def update_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
body: CustomerUpdate,
) -> CustomerResponse:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
dept_user_ids = None
if user.data_scope == "dept_and_sub" and user.dept_id:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
_check_access(customer, user, dept_user_ids=dept_user_ids)
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(CrmCustomer).where(CrmCustomer.id == customer_id).values(**update_data)
)
await db.commit()
updated = (
await db.execute(select(CrmCustomer).where(CrmCustomer.id == customer_id))
).scalar_one()
return _to_response(updated)
async def delete_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> None:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
dept_user_ids = None
if user.data_scope == "dept_and_sub" and user.dept_id:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
_check_access(customer, user, dept_user_ids=dept_user_ids)
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(is_deleted=True, updated_at=datetime.utcnow())
)
await db.commit()
async def restore_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> None:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(True),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或未被归档")
dept_user_ids = None
if user.data_scope == "dept_and_sub" and user.dept_id:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
_check_access(customer, user, dept_user_ids=dept_user_ids)
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(is_deleted=False, updated_at=datetime.utcnow())
)
await db.commit()
async def transfer_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
new_owner_id: uuid.UUID,
) -> CustomerResponse:
"""将客户转移至指定人员名下(仅管理员)"""
if user.data_scope != "all":
raise ForbiddenException("仅管理员可执行客户转移操作")
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被归档")
if customer.owner_id == new_owner_id:
raise BizException(message="目标负责人与当前负责人相同,无需转移")
# 校验目标用户是否存在
from app.models.sys import SysUser
target = (await db.execute(
select(SysUser).where(SysUser.id == new_owner_id)
)).scalar_one_or_none()
if target is None:
raise NotFoundException("目标负责人不存在")
old_owner_name = customer.owner.real_name if customer.owner else "(无)"
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(owner_id=new_owner_id, updated_at=datetime.utcnow())
)
await db.commit()
await db.refresh(customer)
print(f"[客户转移] {customer.name}: {old_owner_name}{target.real_name} (操作人: {user.real_name})")
return _to_response(customer)
async def get_customer_products(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> list[dict]:
"""通过订单反查客户关联的产品 SKU(去重聚合)"""
from app.models.order import ErpOrder, ErpOrderItem
from app.models.erp import ProductSku
from sqlalchemy import desc as sa_desc
# 确认客户存在
stmt = select(CrmCustomer).where(CrmCustomer.id == customer_id)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在")
dept_user_ids = None
if user.data_scope == "dept_and_sub" and user.dept_id:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
_check_access(customer, user, dept_user_ids=dept_user_ids)
# 聚合: 该客户所有订单中的 SKU,含总数量、最近下单时间
agg_stmt = (
select(
ErpOrderItem.sku_id,
ProductSku.sku_code,
ProductSku.name.label("sku_name"),
ProductSku.spec,
func.sum(ErpOrderItem.qty).label("total_qty"),
func.max(ErpOrder.order_date).label("last_order_date"),
func.count(func.distinct(ErpOrder.id)).label("order_count"),
)
.join(ErpOrder, ErpOrderItem.order_id == ErpOrder.id)
.join(ProductSku, ErpOrderItem.sku_id == ProductSku.id)
.where(
ErpOrder.customer_id == customer_id,
ErpOrder.is_deleted.is_(False),
ErpOrderItem.is_deleted.is_(False),
)
.group_by(
ErpOrderItem.sku_id,
ProductSku.sku_code,
ProductSku.name,
ProductSku.spec,
)
.order_by(sa_desc("last_order_date"))
)
rows = (await db.execute(agg_stmt)).all()
return [
{
"sku_id": str(r.sku_id),
"sku_code": r.sku_code,
"sku_name": r.sku_name,
"spec": r.spec,
"total_qty": float(r.total_qty),
"order_count": r.order_count,
"last_order_date": r.last_order_date.isoformat() if r.last_order_date else None,
}
for r in rows
]
async def search_customers(
db: AsyncSession,
user: CurrentUserPayload,
q: str,
limit: int = 20,
) -> list[dict]:
"""模糊搜索客户,返回精简列表(供远程选择器用)"""
base_where = [CrmCustomer.is_deleted.is_(False)]
# 数据域隔离
if user.data_scope == "self":
base_where.append(CrmCustomer.owner_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
if dept_user_ids:
base_where.append(CrmCustomer.owner_id.in_(dept_user_ids))
else:
base_where.append(CrmCustomer.owner_id == user.user_id)
# 模糊搜索(名称 / 联系人 / 电话)
from sqlalchemy import or_
base_where.append(
or_(
CrmCustomer.name.ilike(f"%{q}%"),
CrmCustomer.contact.ilike(f"%{q}%"),
CrmCustomer.phone.ilike(f"%{q}%"),
)
)
stmt = (
select(CrmCustomer)
.where(*base_where)
.order_by(CrmCustomer.name)
.limit(limit)
)
customers = (await db.execute(stmt)).scalars().all()
return [
{
"id": str(c.id),
"name": c.name,
"level": c.level,
"contact": c.contact,
"phone": c.phone,
}
for c in customers
]