423baff73b
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
337 lines
9.8 KiB
Python
337 lines
9.8 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.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,
|
|
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,
|
|
)
|
|
|
|
|
|
# ── 权限校验 ─────────────────────────────────────────────
|
|
def _check_access(customer: CrmCustomer, user: CurrentUserPayload) -> None:
|
|
if user.data_scope == "all":
|
|
return
|
|
if user.data_scope == "dept_and_sub":
|
|
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,
|
|
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:
|
|
from app.models.sys import SysUser
|
|
sub = select(SysUser.id).where(
|
|
SysUser.dept_id == user.dept_id,
|
|
SysUser.is_deleted.is_(False),
|
|
)
|
|
base_where.append(CrmCustomer.owner_id.in_(sub))
|
|
|
|
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("客户不存在或已被删除")
|
|
|
|
_check_access(customer, user)
|
|
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("客户不存在或已被删除")
|
|
|
|
_check_access(customer, user)
|
|
|
|
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("客户不存在或已被删除")
|
|
|
|
_check_access(customer, user)
|
|
|
|
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("客户不存在或未被归档")
|
|
|
|
_check_access(customer, user)
|
|
|
|
await db.execute(
|
|
update(CrmCustomer)
|
|
.where(CrmCustomer.id == customer_id)
|
|
.values(is_deleted=False, updated_at=datetime.utcnow())
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
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("客户不存在")
|
|
_check_access(customer, user)
|
|
|
|
# 聚合: 该客户所有订单中的 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:
|
|
from app.models.sys import SysUser
|
|
sub = select(SysUser.id).where(
|
|
SysUser.dept_id == user.dept_id,
|
|
SysUser.is_deleted.is_(False),
|
|
)
|
|
base_where.append(CrmCustomer.owner_id.in_(sub))
|
|
|
|
# 模糊搜索(名称 / 联系人 / 电话)
|
|
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
|
|
]
|