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