dingdanquanliucheng/backend/app/api/customers.py

290 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
客户管理路由模块
职责:
处理客户信息的增删改查和批量导入接口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)})