290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""
|
||
客户管理路由模块
|
||
|
||
职责:
|
||
处理客户信息的增删改查和批量导入接口,URL 前缀为 /api/customers。
|
||
包括:客户列表查询、创建客户、查看客户详情、更新客户、批量导入客户。
|
||
"""
|
||
import os
|
||
import tempfile
|
||
from io import BytesIO
|
||
|
||
from fastapi import APIRouter, Body, Depends, File, Query, UploadFile
|
||
from fastapi.responses import StreamingResponse
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from backend.app.api.deps import require_roles
|
||
from backend.app.core.error_codes import ErrorCode
|
||
from backend.app.core.exceptions import AppException
|
||
from backend.app.db import get_db_session
|
||
from backend.app.models.business import Customer, SalesOrder
|
||
from backend.app.schemas.common import success_payload
|
||
from backend.app.schemas.customers import CreateCustomerRequest, UpdateCustomerRequest
|
||
from backend.app.core.cache import cache_delete_pattern
|
||
from backend.app.services.customer_service import customer_service
|
||
|
||
router = APIRouter(prefix="/api/customers", tags=["customers"])
|
||
|
||
|
||
@router.get("")
|
||
def list_customers(
|
||
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")), # 角色鉴权:业务员/经理/管理员
|
||
) -> dict:
|
||
"""分页查询客户列表
|
||
|
||
用途:根据筛选条件获取客户列表,支持按名称、手机号、类型、结算方式、业务员筛选。
|
||
请求参数:Query 参数组合筛选 + 分页参数。
|
||
返回值:分页客户列表,包含 total、page_no、page_size、list。
|
||
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
||
"""
|
||
result = customer_service.list_customers(
|
||
session,
|
||
customer_service.normalize_list_filters(
|
||
{
|
||
"customer_name": customer_name,
|
||
"mobile": mobile,
|
||
"customer_type": customer_type,
|
||
"settlement_type": settlement_type,
|
||
"salesman_id": salesman_id,
|
||
},
|
||
current_user,
|
||
),
|
||
)
|
||
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(
|
||
payload: CreateCustomerRequest, # 创建客户的请求体
|
||
session: Session = Depends(get_db_session), # 注入数据库会话
|
||
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权
|
||
) -> dict:
|
||
"""创建新客户
|
||
|
||
用途:新增一个客户记录,自动关联当前登录的业务员。
|
||
请求参数:CreateCustomerRequest(客户名称、手机号、类型、结算方式等)。
|
||
返回值:创建成功后的客户信息。
|
||
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
||
"""
|
||
return success_payload(
|
||
customer_service.create_customer(
|
||
session=session,
|
||
payload=payload.model_dump(),
|
||
current_user=current_user,
|
||
)
|
||
)
|
||
|
||
|
||
@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"},
|
||
)
|
||
|
||
|
||
@router.get("/{customer_id}")
|
||
def get_customer(
|
||
customer_id: int, # 客户 ID(路径参数)
|
||
session: Session = Depends(get_db_session), # 注入数据库会话
|
||
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权
|
||
) -> dict:
|
||
"""查询客户详情
|
||
|
||
用途:根据客户 ID 获取单个客户的详细信息。
|
||
请求参数:customer_id - 客户 ID(路径参数)。
|
||
返回值:客户详情信息。
|
||
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
||
"""
|
||
customer = customer_service.get_customer(customer_id, session, current_user)
|
||
if not customer:
|
||
raise AppException(code=ErrorCode.NOT_FOUND, message="客户不存在", status_code=404)
|
||
return success_payload(customer)
|
||
|
||
|
||
@router.put("/{customer_id}")
|
||
def update_customer(
|
||
customer_id: int, # 客户 ID(路径参数)
|
||
payload: UpdateCustomerRequest, # 更新客户的请求体
|
||
session: Session = Depends(get_db_session), # 注入数据库会话
|
||
current_user: dict = Depends(require_roles("salesman", "manager", "admin")), # 角色鉴权
|
||
) -> dict:
|
||
"""更新客户信息
|
||
|
||
用途:根据客户 ID 更新客户的基本信息。
|
||
请求参数:customer_id(路径参数)+ UpdateCustomerRequest(更新字段)。
|
||
返回值:更新后的客户信息。
|
||
权限要求:业务员(salesman)、经理(manager)、管理员(admin)。
|
||
"""
|
||
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)
|
||
|
||
|
||
@router.post("/import")
|
||
def import_customers(
|
||
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")), # 角色鉴权:仅经理/管理员
|
||
) -> dict:
|
||
"""批量导入客户(文件上传)
|
||
|
||
用途:通过上传 Excel/CSV 文件批量导入客户数据。
|
||
请求参数:file - 上传的文件,import_mode - 导入模式(Query 参数)。
|
||
返回值:导入结果(成功数量、失败详情等)。
|
||
权限要求:经理(manager)、管理员(admin)。
|
||
"""
|
||
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}
|
||
)
|
||
)
|
||
|
||
|
||
@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()
|
||
cache_delete_pattern("customer:list:*")
|
||
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()
|
||
cache_delete_pattern("customer:list:*")
|
||
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()
|
||
cache_delete_pattern("customer:list:*")
|
||
return success_payload({"deleted": len(customers)})
|