v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
客户管理 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
|
||||
]
|
||||
Reference in New Issue
Block a user