dingdanquanliucheng/backend/app/api/customers.py

286 lines
12 KiB
Python
Raw Normal View History

"""
客户管理路由模块
职责
处理客户信息的增删改查和批量导入接口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
2026-05-14 13:51:06 +08:00
from sqlalchemy.orm import Session
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
from backend.app.models.business import Customer, SalesOrder
2026-05-14 13:51:06 +08:00
from backend.app.schemas.common import success_payload
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(
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:
"""分页查询客户列表
用途根据筛选条件获取客户列表支持按名称手机号类型结算方式业务员筛选
请求参数Query 参数组合筛选 + 分页参数
返回值分页客户列表包含 totalpage_nopage_sizelist
权限要求业务员salesman经理manager管理员admin
"""
2026-05-14 13:51:06 +08:00
result = customer_service.list_customers(
session,
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,
},
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(
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:
"""创建新客户
用途新增一个客户记录自动关联当前登录的业务员
请求参数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(),
current_user=current_user,
2026-05-14 13:51:06 +08:00
)
)
@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)
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(
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:
"""更新客户信息
用途根据客户 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)
@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")
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()
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)})