2026-05-30 07:23:33 +08:00
|
|
|
|
"""
|
|
|
|
|
|
客户管理路由模块
|
|
|
|
|
|
|
|
|
|
|
|
职责:
|
|
|
|
|
|
处理客户信息的增删改查和批量导入接口,URL 前缀为 /api/customers。
|
|
|
|
|
|
包括:客户列表查询、创建客户、查看客户详情、更新客户、批量导入客户。
|
|
|
|
|
|
"""
|
2026-05-30 11:26:28 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
2026-06-02 20:45:00 +08:00
|
|
|
|
from fastapi import APIRouter, Body, Depends, File, Query, UploadFile
|
2026-05-30 11:26:28 +08:00
|
|
|
|
from fastapi.responses import StreamingResponse
|
2026-06-02 20:45:00 +08:00
|
|
|
|
from sqlalchemy import func, select
|
2026-05-14 13:51:06 +08:00
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-05-15 12:05:18 +08:00
|
|
|
|
from backend.app.api.deps import require_roles
|
2026-05-14 13:51:06 +08:00
|
|
|
|
from backend.app.core.error_codes import ErrorCode
|
|
|
|
|
|
from backend.app.core.exceptions import AppException
|
|
|
|
|
|
from backend.app.db import get_db_session
|
2026-06-02 20:45:00 +08:00
|
|
|
|
from backend.app.models.business import Customer, SalesOrder
|
2026-05-14 13:51:06 +08:00
|
|
|
|
from backend.app.schemas.common import success_payload
|
2026-05-30 11:26:28 +08:00
|
|
|
|
from backend.app.schemas.customers import CreateCustomerRequest, UpdateCustomerRequest
|
2026-05-14 13:51:06 +08:00
|
|
|
|
from backend.app.services.customer_service import customer_service
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/customers", tags=["customers"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("")
|
|
|
|
|
|
def list_customers(
|
2026-05-30 07:23:33 +08:00
|
|
|
|
customer_name: str | None = Query(default=None), # 客户名称模糊搜索
|
|
|
|
|
|
mobile: str | None = Query(default=None), # 手机号精确搜索
|
|
|
|
|
|
customer_type: str | None = Query(default=None), # 客户类型筛选
|
|
|
|
|
|
settlement_type: str | None = Query(default=None), # 结算方式筛选
|
|
|
|
|
|
salesman_id: int | None = Query(default=None), # 业务员 ID 筛选
|
|
|
|
|
|
page_no: int = Query(default=1), # 页码,默认第 1 页
|
|
|
|
|
|
page_size: int = Query(default=20), # 每页条数,默认 20 条
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权:业务员/经理/管理员
|
2026-05-14 13:51:06 +08:00
|
|
|
|
) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""分页查询客户列表
|
|
|
|
|
|
|
|
|
|
|
|
用途:根据筛选条件获取客户列表,支持按名称、手机号、类型、结算方式、业务员筛选。
|
|
|
|
|
|
请求参数:Query 参数组合筛选 + 分页参数。
|
|
|
|
|
|
返回值:分页客户列表,包含 total、page_no、page_size、list。
|
|
|
|
|
|
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
2026-05-14 13:51:06 +08:00
|
|
|
|
result = customer_service.list_customers(
|
|
|
|
|
|
session,
|
2026-05-15 12:05:18 +08:00
|
|
|
|
customer_service.normalize_list_filters(
|
|
|
|
|
|
{
|
2026-05-14 13:51:06 +08:00
|
|
|
|
"customer_name": customer_name,
|
|
|
|
|
|
"mobile": mobile,
|
|
|
|
|
|
"customer_type": customer_type,
|
|
|
|
|
|
"settlement_type": settlement_type,
|
|
|
|
|
|
"salesman_id": salesman_id,
|
2026-05-15 12:05:18 +08:00
|
|
|
|
},
|
|
|
|
|
|
current_user,
|
|
|
|
|
|
),
|
2026-05-14 13:51:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
start = max(page_no - 1, 0) * page_size
|
|
|
|
|
|
result["list"] = result["list"][start : start + page_size]
|
|
|
|
|
|
result["page_no"] = page_no
|
|
|
|
|
|
result["page_size"] = page_size
|
|
|
|
|
|
return success_payload(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("")
|
|
|
|
|
|
def create_customer(
|
2026-05-30 07:23:33 +08:00
|
|
|
|
payload: CreateCustomerRequest, # 创建客户的请求体
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权
|
2026-05-14 13:51:06 +08:00
|
|
|
|
) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""创建新客户
|
|
|
|
|
|
|
|
|
|
|
|
用途:新增一个客户记录,自动关联当前登录的业务员。
|
|
|
|
|
|
请求参数:CreateCustomerRequest(客户名称、手机号、类型、结算方式等)。
|
|
|
|
|
|
返回值:创建成功后的客户信息。
|
|
|
|
|
|
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
2026-05-14 13:51:06 +08:00
|
|
|
|
return success_payload(
|
|
|
|
|
|
customer_service.create_customer(
|
|
|
|
|
|
session=session,
|
|
|
|
|
|
payload=payload.model_dump(),
|
2026-05-15 12:05:18 +08:00
|
|
|
|
current_user=current_user,
|
2026-05-14 13:51:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{customer_id}")
|
2026-05-15 12:05:18 +08:00
|
|
|
|
def get_customer(
|
2026-05-30 07:23:33 +08:00
|
|
|
|
customer_id: int, # 客户 ID(路径参数)
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权
|
2026-05-15 12:05:18 +08:00
|
|
|
|
) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""查询客户详情
|
|
|
|
|
|
|
|
|
|
|
|
用途:根据客户 ID 获取单个客户的详细信息。
|
|
|
|
|
|
请求参数:customer_id - 客户 ID(路径参数)。
|
|
|
|
|
|
返回值:客户详情信息。
|
|
|
|
|
|
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
2026-05-15 12:05:18 +08:00
|
|
|
|
customer = customer_service.get_customer(customer_id, session, current_user)
|
2026-05-14 13:51:06 +08:00
|
|
|
|
if not customer:
|
|
|
|
|
|
raise AppException(code=ErrorCode.NOT_FOUND, message="客户不存在", status_code=404)
|
|
|
|
|
|
return success_payload(customer)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 22:59:04 +08:00
|
|
|
|
@router.put("/{customer_id}")
|
|
|
|
|
|
def update_customer(
|
2026-05-30 07:23:33 +08:00
|
|
|
|
customer_id: int, # 客户 ID(路径参数)
|
|
|
|
|
|
payload: UpdateCustomerRequest, # 更新客户的请求体
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权
|
2026-05-28 22:59:04 +08:00
|
|
|
|
) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""更新客户信息
|
|
|
|
|
|
|
|
|
|
|
|
用途:根据客户 ID 更新客户的基本信息。
|
|
|
|
|
|
请求参数:customer_id(路径参数)+ UpdateCustomerRequest(更新字段)。
|
|
|
|
|
|
返回值:更新后的客户信息。
|
|
|
|
|
|
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
2026-05-28 22:59:04 +08:00
|
|
|
|
result = customer_service.update_customer(customer_id, session, payload.model_dump(), current_user)
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise AppException(code=ErrorCode.NOT_FOUND, message="客户不存在", status_code=404)
|
|
|
|
|
|
return success_payload(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 11:26:28 +08:00
|
|
|
|
@router.get("/import-template")
|
|
|
|
|
|
def download_import_template(
|
|
|
|
|
|
_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
|
|
|
|
|
|
) -> StreamingResponse:
|
|
|
|
|
|
"""下载客户导入模板
|
|
|
|
|
|
|
|
|
|
|
|
用途:生成包含表头和示例行的 Excel 模板供用户下载填写。
|
|
|
|
|
|
返回值:xlsx 文件流,浏览器自动触发下载。
|
|
|
|
|
|
权限要求:经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from openpyxl import Workbook
|
|
|
|
|
|
|
|
|
|
|
|
wb = Workbook()
|
|
|
|
|
|
ws = wb.active
|
|
|
|
|
|
ws.title = "客户导入模板"
|
|
|
|
|
|
headers = ["客户姓名", "手机号", "客户地址", "客户类型", "结算方式", "账期天数", "业务员ID", "信用额度", "备注"]
|
|
|
|
|
|
ws.append(headers)
|
|
|
|
|
|
ws.append(["张三", "13800000000", "广州市天河区xxx", "渠道客户", "月结", 30, "", 0, "示例数据,请删除后填写"])
|
|
|
|
|
|
for cell in ws[1]:
|
|
|
|
|
|
cell.font = cell.font.copy(bold=True)
|
|
|
|
|
|
ws.column_dimensions["A"].width = 14
|
|
|
|
|
|
ws.column_dimensions["B"].width = 16
|
|
|
|
|
|
ws.column_dimensions["C"].width = 28
|
|
|
|
|
|
ws.column_dimensions["D"].width = 14
|
|
|
|
|
|
ws.column_dimensions["E"].width = 14
|
|
|
|
|
|
ws.column_dimensions["F"].width = 12
|
|
|
|
|
|
ws.column_dimensions["G"].width = 12
|
|
|
|
|
|
ws.column_dimensions["H"].width = 12
|
|
|
|
|
|
ws.column_dimensions["I"].width = 30
|
|
|
|
|
|
|
|
|
|
|
|
buf = BytesIO()
|
|
|
|
|
|
wb.save(buf)
|
|
|
|
|
|
buf.seek(0)
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
buf,
|
|
|
|
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
|
headers={"Content-Disposition": "attachment; filename=customer_import_template.xlsx"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 13:51:06 +08:00
|
|
|
|
@router.post("/import")
|
2026-05-15 09:00:50 +08:00
|
|
|
|
def import_customers(
|
2026-05-30 11:26:28 +08:00
|
|
|
|
file: UploadFile = File(...), # 上传的 Excel/CSV 文件
|
|
|
|
|
|
import_mode: str = Query(default="skip_duplicate"), # 导入模式:skip_duplicate/cover_duplicate
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
|
2026-05-15 09:00:50 +08:00
|
|
|
|
) -> dict:
|
2026-05-30 11:26:28 +08:00
|
|
|
|
"""批量导入客户(文件上传)
|
2026-05-30 07:23:33 +08:00
|
|
|
|
|
2026-05-30 11:26:28 +08:00
|
|
|
|
用途:通过上传 Excel/CSV 文件批量导入客户数据。
|
|
|
|
|
|
请求参数:file - 上传的文件,import_mode - 导入模式(Query 参数)。
|
2026-05-30 07:23:33 +08:00
|
|
|
|
返回值:导入结果(成功数量、失败详情等)。
|
|
|
|
|
|
权限要求:经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
2026-05-30 11:26:28 +08:00
|
|
|
|
if import_mode not in {"skip_duplicate", "cover_duplicate"}:
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入模式不正确", status_code=400)
|
|
|
|
|
|
|
|
|
|
|
|
suffix = ""
|
|
|
|
|
|
if file.filename:
|
|
|
|
|
|
suffix = os.path.splitext(file.filename)[1].lower()
|
|
|
|
|
|
if suffix not in {".csv", ".xlsx", ".xls"}:
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="仅支持 csv、xlsx、xls 文件", status_code=400)
|
|
|
|
|
|
|
|
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
|
|
|
|
tmp.write(file.file.read())
|
|
|
|
|
|
tmp_path = tmp.name
|
|
|
|
|
|
|
|
|
|
|
|
return success_payload(
|
|
|
|
|
|
customer_service.import_customers_from_file(
|
|
|
|
|
|
session, {"file_url": tmp_path, "import_mode": import_mode}
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-02 20:45:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{customer_id}")
|
|
|
|
|
|
def delete_customer(
|
|
|
|
|
|
customer_id: int, # 客户 ID(路径参数)
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
current_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""删除客户(软删除)
|
|
|
|
|
|
|
|
|
|
|
|
用途:根据客户 ID 将客户标记为已删除(设置 deleted=1),不做物理删除。
|
|
|
|
|
|
请求参数:customer_id - 客户 ID(路径参数)。
|
|
|
|
|
|
返回值:删除结果。
|
|
|
|
|
|
权限要求:经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
customer = session.execute(
|
|
|
|
|
|
select(Customer).where(Customer.id == customer_id, Customer.deleted == 0)
|
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
|
if not customer:
|
|
|
|
|
|
raise AppException(code=ErrorCode.NOT_FOUND, message="客户不存在", status_code=404)
|
|
|
|
|
|
customer.deleted = 1
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
return success_payload({"deleted": True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{customer_id}/permanent")
|
|
|
|
|
|
def permanent_delete_customer(
|
|
|
|
|
|
customer_id: int, # 客户 ID(路径参数)
|
|
|
|
|
|
session: Session = Depends(get_db_session), # 注入数据库会话
|
|
|
|
|
|
current_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""永久删除客户(硬删除)
|
|
|
|
|
|
|
|
|
|
|
|
用途:物理删除客户记录,需先检查是否有未取消的关联订单。
|
|
|
|
|
|
请求参数:customer_id - 客户 ID(路径参数)。
|
|
|
|
|
|
返回值:删除结果。
|
|
|
|
|
|
权限要求:经理(manager)、管理员(admin)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
customer = session.execute(
|
|
|
|
|
|
select(Customer).where(Customer.id == customer_id, Customer.deleted == 0)
|
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
|
if not customer:
|
|
|
|
|
|
raise AppException(code=ErrorCode.NOT_FOUND, message="客户不存在", status_code=404)
|
|
|
|
|
|
active_order_count = session.execute(
|
|
|
|
|
|
select(func.count(SalesOrder.id)).where(
|
|
|
|
|
|
SalesOrder.customer_id == customer_id,
|
|
|
|
|
|
SalesOrder.order_status != "cancelled",
|
|
|
|
|
|
)
|
|
|
|
|
|
).scalar()
|
|
|
|
|
|
if active_order_count > 0:
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="该客户还有关联订单,无法删除", status_code=400)
|
|
|
|
|
|
session.delete(customer)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
return success_payload({"deleted": True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/batch-delete")
|
|
|
|
|
|
def batch_delete_customers(
|
|
|
|
|
|
ids: list[int] = Body(..., embed=True),
|
|
|
|
|
|
permanent: bool = Body(default=False, embed=True),
|
|
|
|
|
|
session: Session = Depends(get_db_session),
|
|
|
|
|
|
current_user: dict = Depends(require_roles("manager", "admin")),
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""批量删除客户"""
|
|
|
|
|
|
customers = session.execute(
|
|
|
|
|
|
select(Customer).where(Customer.id.in_(ids), Customer.deleted == 0)
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
if not customers:
|
|
|
|
|
|
raise AppException(code=ErrorCode.NOT_FOUND, message="未找到可删除的客户", status_code=404)
|
|
|
|
|
|
if permanent:
|
|
|
|
|
|
cust_ids = [c.id for c in customers]
|
|
|
|
|
|
count = session.execute(
|
|
|
|
|
|
select(func.count(SalesOrder.id)).where(
|
|
|
|
|
|
SalesOrder.customer_id.in_(cust_ids), SalesOrder.order_status != "cancelled"
|
|
|
|
|
|
)
|
|
|
|
|
|
).scalar()
|
|
|
|
|
|
if count > 0:
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="所选客户还有关联订单,无法删除", status_code=400)
|
|
|
|
|
|
for c in customers:
|
|
|
|
|
|
session.delete(c)
|
|
|
|
|
|
else:
|
|
|
|
|
|
for c in customers:
|
|
|
|
|
|
c.deleted = 1
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
return success_payload({"deleted": len(customers)})
|