dingdanquanliucheng/backend/app/services/customer_service.py

594 lines
24 KiB
Python
Raw Normal View History

"""客户管理服务层。
负责客户的增删改查批量导入权限过滤等业务逻辑
依赖 CustomerRepository 进行数据持久化依赖 audit_service 记录操作审计日志
依赖 storage_service 解析导入文件的存储路径
"""
import csv
from pathlib import Path
2026-05-14 13:51:06 +08:00
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.core.cache import cache_delete_pattern, cache_get, cache_set, make_cache_key
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
2026-05-14 13:51:06 +08:00
from backend.app.repositories.customer_repository import CustomerRepository
2026-05-26 11:36:57 +08:00
from backend.app.services.audit_service import audit_service
from backend.app.services.storage_service import storage_service
2026-05-14 13:51:06 +08:00
class CustomerService:
"""客户服务,封装客户信息的 CRUD、导入和权限控制。
依赖:
CustomerRepository - 客户数据访问
audit_service - 操作审计日志
storage_service - 文件存储路径解析
"""
2026-05-14 13:51:06 +08:00
def __init__(self) -> None:
self.repository = CustomerRepository()
def list_customers(self, session: Session | None = None, filters: dict | None = None) -> dict:
"""查询客户列表。
Args:
session: 数据库会话
filters: 查询过滤条件
Returns:
包含 totalpage_nopage_sizelist 的分页字典
被调用路由: customers.py - GET /customers
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
# 读缓存
cache_key = make_cache_key("customer:list", **(filters or {}))
cached = cache_get(cache_key)
if cached is not None:
return cached
try:
customers = self.repository.list_customers_by_filters(session, filters or {})
result = {
"total": len(customers),
"page_no": 1,
"page_size": 20,
"list": [
{
"customer_id": item.id,
"customer_name": item.customer_name,
"mobile": item.mobile,
"address": item.address,
"settlement_type": item.settlement_type,
"settlement_days": item.settlement_days,
"customer_type": item.customer_type,
"salesman_id": item.salesman_id,
"salesman_name": "",
"arrears_amount": 0,
"status": 1,
}
for item in customers
],
}
cache_set(cache_key, result, ttl=60)
return result
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
2026-05-14 13:51:06 +08:00
def create_customer(
self,
session: Session | None = None,
payload: dict | None = None,
current_user: dict | None = None,
) -> dict:
"""创建新客户。
校验客户姓名和手机号非空并通过姓名+手机号判断是否重复
自动记录审计日志
Args:
session: 数据库会话
payload: 包含 customer_namemobile 等字段的请求体
current_user: 当前登录用户信息用于确定业务员归属
Returns:
包含 customer_idcustomer_namemobile 的结果字典
被调用路由: customers.py - POST /customers
"""
if session is None or payload is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话或参数未初始化", status_code=500)
try:
if not payload["customer_name"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
if not payload["mobile"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户手机号不能为空", status_code=400)
existed = self.repository.find_by_name_and_mobile(
session,
payload["customer_name"],
payload["mobile"],
)
if existed is not None:
raise AppException(code=ErrorCode.DUPLICATE, message="客户已存在", status_code=400)
2026-05-14 13:51:06 +08:00
customer = self.repository.create_customer(
session,
{
"customer_name": payload["customer_name"],
"mobile": payload["mobile"],
"address": payload.get("address"),
"settlement_type": payload.get("settlement_type"),
"settlement_days": payload.get("settlement_days", 0),
"customer_type": payload.get("customer_type"),
"salesman_id": self._resolve_salesman_id(payload, current_user),
"credit_limit": payload.get("credit_limit", 0),
"remark": payload.get("remark"),
},
)
audit_service.write_log(
session,
{
"operate_type": "customer_create",
"biz_type": "customer",
"biz_id": customer.id,
"before_value": None,
"after_value": {
"customer_name": customer.customer_name,
"mobile": customer.mobile,
2026-05-26 11:36:57 +08:00
},
"remark": f"新增客户 {customer.customer_name}",
},
)
session.commit()
cache_delete_pattern("customer:list:*")
return {
"customer_id": customer.id,
"customer_name": customer.customer_name,
"mobile": customer.mobile,
}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
2026-05-14 13:51:06 +08:00
2026-05-28 22:59:04 +08:00
def update_customer(
self,
customer_id: int,
session: Session | None = None,
payload: dict | None = None,
current_user: dict | None = None,
) -> dict | None:
"""更新客户信息。
校验客户姓名+手机号唯一性执行权限检查
自动记录审计日志
Args:
customer_id: 客户 ID
session: 数据库会话
payload: 包含更新字段的请求体
current_user: 当前登录用户信息
Returns:
更新结果字典客户不存在时返回 None
被调用路由: customers.py - PUT /customers/{customer_id}
"""
2026-05-28 22:59:04 +08:00
if session is None or payload is None:
return None
try:
customer = self.repository.get_customer(session, customer_id)
if customer is None:
return None
self._ensure_customer_access(customer, current_user)
if not payload["customer_name"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
if not payload["mobile"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户手机号不能为空", status_code=400)
existed = self.repository.find_by_name_and_mobile(session, payload["customer_name"], payload["mobile"])
if existed is not None and existed.id != customer_id:
raise AppException(code=ErrorCode.DUPLICATE, message="客户姓名+手机号已存在", status_code=400)
self.repository.update_customer(
session,
customer,
{
"customer_name": payload["customer_name"],
"mobile": payload["mobile"],
"address": payload.get("address"),
"settlement_type": payload.get("settlement_type"),
"settlement_days": payload.get("settlement_days", 0),
"customer_type": payload.get("customer_type"),
"salesman_id": payload.get("salesman_id"),
"credit_limit": payload.get("credit_limit", 0),
"remark": payload.get("remark"),
},
)
audit_service.write_log(
session,
{
"operate_type": "customer_update",
"biz_type": "customer",
"biz_id": customer.id,
"before_value": None,
"after_value": {"customer_name": customer.customer_name, "mobile": customer.mobile},
"remark": f"更新客户 {customer.customer_name}",
},
)
session.commit()
cache_delete_pattern("customer:list:*")
2026-05-28 22:59:04 +08:00
return {"customer_id": customer.id, "customer_name": customer.customer_name, "mobile": customer.mobile}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
2026-05-28 22:59:04 +08:00
session.rollback()
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
2026-05-28 22:59:04 +08:00
def get_customer(
self,
customer_id: int,
session: Session | None = None,
current_user: dict | None = None,
) -> dict:
"""查询单个客户详情。
Args:
customer_id: 客户 ID
session: 数据库会话
current_user: 当前登录用户信息用于权限校验
Returns:
客户详情字典包含所有字段
被调用路由: customers.py - GET /customers/{customer_id}
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
customer = self.repository.get_customer(session, customer_id)
if customer is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="客户不存在", status_code=404)
self._ensure_customer_access(customer, current_user)
return {
"customer_id": customer.id,
"customer_name": customer.customer_name,
"mobile": customer.mobile,
"address": customer.address,
"settlement_type": customer.settlement_type,
"settlement_days": customer.settlement_days,
"customer_type": customer.customer_type,
"salesman_id": customer.salesman_id,
"salesman_name": "",
"arrears_amount": 0,
"status": 1,
"credit_limit": float(customer.credit_limit or 0),
"remark": customer.remark,
}
except AppException:
raise
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
2026-05-14 13:51:06 +08:00
def import_customers_from_file(self, session: Session | None = None, payload: dict | None = None) -> dict:
"""从文件批量导入客户。
支持 csvxlsxxls 格式支持跳过重复或覆盖重复两种导入模式
自动记录审计日志
Args:
session: 数据库会话
payload: 包含 file_urlimport_mode 的请求体
Returns:
包含 total_countsuccess_countduplicate_countfail_countfail_list 的导入结果
被调用路由: customers.py - POST /customers/import
"""
if payload is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入参数不能为空", status_code=400)
file_url = (payload.get("file_url") or "").strip()
import_mode = (payload.get("import_mode") or "skip_duplicate").strip()
if not file_url:
raise AppException(code=ErrorCode.PARAM_ERROR, message="文件地址不能为空", status_code=400)
if import_mode not in {"skip_duplicate", "cover_duplicate"}:
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入模式不正确", status_code=400)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
rows = self._load_import_rows(file_url)
result = self._import_customer_rows(session, rows, import_mode, file_url)
audit_service.write_log(
session,
{
"operate_type": "customer_import",
"biz_type": "customer",
"biz_id": 0,
"before_value": None,
"after_value": {
"total_count": result["total_count"],
"success_count": result["success_count"],
"duplicate_count": result["duplicate_count"],
"fail_count": result["fail_count"],
"file_url": file_url,
"import_mode": import_mode,
2026-05-26 11:36:57 +08:00
},
"remark": f"批量导入客户,成功 {result['success_count']}",
},
)
session.commit()
cache_delete_pattern("customer:list:*")
return result
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def _load_import_rows(self, file_url: str) -> list[dict]:
"""加载导入文件内容并解析为行数据列表。
支持 csvUTF-8-sig 编码 xlsx/xls 格式
Args:
file_url: 文件地址本地路径或 OSS URL
Returns:
解析后的行数据字典列表
Raises:
AppException: 文件不存在或格式不支持时抛出
"""
path = self._resolve_import_path(file_url)
if not path.exists() or not path.is_file():
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入文件不存在", status_code=400)
suffix = path.suffix.lower()
if suffix == ".csv":
with path.open("r", encoding="utf-8-sig", newline="") as file:
return list(csv.DictReader(file))
if suffix in {".xlsx", ".xls"}:
try:
from openpyxl import load_workbook
except ImportError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="缺少 Excel 解析依赖 openpyxl", status_code=500) from exc
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook.active
rows = list(sheet.iter_rows(values_only=True))
if not rows:
return []
headers = [str(item).strip() if item is not None else "" for item in rows[0]]
return [
{headers[index]: value for index, value in enumerate(row) if index < len(headers)}
for row in rows[1:]
]
raise AppException(code=ErrorCode.PARAM_ERROR, message="仅支持导入 csv、xlsx、xls 文件", status_code=400)
def _resolve_import_path(self, file_url: str) -> Path:
"""将导入文件 URL 转换为本地文件路径。
2026-06-15 13:18:43 +08:00
如果是 OSS URL OSS 下载到本地临时文件
如果是本地路径直接返回
Args:
file_url: 文件地址
Returns:
本地文件 Path 对象
"""
2026-06-15 13:18:43 +08:00
import tempfile
normalized = file_url.replace("\\", "/")
2026-06-15 13:18:43 +08:00
# 如果是 OSS URL从 OSS 下载到本地临时文件
if normalized.startswith(("http://", "https://")):
object_key = storage_service.extract_object_key(file_url)
2026-06-15 13:18:43 +08:00
suffix = Path(object_key).suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
storage_service.bucket.get_object_to_file(object_key, tmp.name)
return Path(tmp.name)
path = Path(file_url)
if not path.is_absolute():
path = Path.cwd() / path
return path
def _import_customer_rows(self, session: Session, rows: list[dict], import_mode: str, file_url: str) -> dict:
"""逐行导入客户数据,处理重复判断和错误收集。
Args:
session: 数据库会话
rows: 解析后的行数据列表
import_mode: 导入模式skip_duplicate cover_duplicate
file_url: 导入文件地址用于备注
Returns:
包含统计信息和失败明细的导入结果字典
"""
fail_list: list[dict] = []
success_count = 0
duplicate_count = 0
for index, raw_row in enumerate(rows, start=2):
try:
payload = self._normalize_import_row(raw_row, file_url)
2026-05-28 22:59:04 +08:00
existed = self.repository.find_by_name_and_mobile(session, payload["customer_name"], payload["mobile"])
if existed is not None:
duplicate_count += 1
if import_mode == "skip_duplicate":
continue
self.repository.update_customer(session, existed, payload)
success_count += 1
continue
self.repository.create_customer(session, payload)
success_count += 1
except AppException as exc:
fail_list.append({"row_no": index, "reason": exc.message})
return {
"total_count": len(rows),
"success_count": success_count,
"duplicate_count": duplicate_count,
"fail_count": len(fail_list),
"fail_list": fail_list,
}
def _normalize_import_row(self, raw_row: dict, file_url: str) -> dict:
"""将导入行的原始数据标准化为客户创建所需字段。
支持中英文混合列名匹配"客户姓名""customer_name"均可识别
Args:
raw_row: 原始行数据字典
file_url: 导入文件地址
Returns:
标准化后的客户字段字典
"""
customer_name = self._pick_import_value(raw_row, ["customer_name", "客户姓名", "姓名"])
mobile = self._pick_import_value(raw_row, ["mobile", "手机号", "手机号码"])
if not customer_name:
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
if not mobile:
raise AppException(code=ErrorCode.PARAM_ERROR, message="手机号不能为空", status_code=400)
settlement_days = self._parse_int(self._pick_import_value(raw_row, ["settlement_days", "账期天数"]), 0)
salesman_id = self._parse_int(self._pick_import_value(raw_row, ["salesman_id", "业务员ID"]), None)
credit_limit = self._parse_float(self._pick_import_value(raw_row, ["credit_limit", "信用额度"]), 0)
return {
"customer_name": customer_name,
"mobile": mobile,
"address": self._pick_import_value(raw_row, ["address", "客户地址", "地址"]) or None,
"settlement_type": self._pick_import_value(raw_row, ["settlement_type", "结算方式"]) or "monthly",
"settlement_days": settlement_days,
"customer_type": self._pick_import_value(raw_row, ["customer_type", "客户类型"]) or "channel",
"salesman_id": salesman_id,
"credit_limit": credit_limit,
"remark": self._pick_import_value(raw_row, ["remark", "备注"]) or f"导入文件:{file_url}",
}
def _pick_import_value(self, raw_row: dict, keys: list[str]) -> str:
"""从原始行数据中按优先级尝试多个列名取值。
Args:
raw_row: 原始行数据字典
keys: 候选列名列表按优先级排列
Returns:
第一个非空的值字符串全部为空时返回空字符串
"""
for key in keys:
value = raw_row.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _parse_int(self, value: str, default: int | None) -> int | None:
"""安全解析整数,空字符串返回默认值,格式错误抛出异常。
Args:
value: 待解析的字符串
default: 空值时的默认返回值
Returns:
解析后的整数或默认值
"""
if value == "":
return default
try:
return int(float(value))
except ValueError as exc:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数字字段格式不正确", status_code=400) from exc
def _parse_float(self, value: str, default: float) -> float:
"""安全解析浮点数,空字符串返回默认值,格式错误抛出异常。
Args:
value: 待解析的字符串
default: 空值时的默认返回值
Returns:
解析后的浮点数或默认值
"""
if value == "":
return default
try:
return float(value)
except ValueError as exc:
raise AppException(code=ErrorCode.PARAM_ERROR, message="金额字段格式不正确", status_code=400) from exc
def normalize_list_filters(self, filters: dict | None, current_user: dict | None = None) -> dict:
"""标准化查询过滤条件。
对业务员角色自动注入 salesman_id 过滤条件确保只能查看自己的客户
Args:
filters: 原始过滤条件
current_user: 当前登录用户信息
Returns:
标准化后的过滤条件字典
被调用路由: customers.py - GET /customers
"""
normalized = dict(filters or {})
if current_user and current_user.get("role_code") == "salesman":
normalized["salesman_id"] = current_user.get("user_id")
return normalized
def _resolve_salesman_id(self, payload: dict, current_user: dict | None) -> int | None:
"""根据当前用户角色确定业务员 ID。
业务员角色自动使用自身 user_id其他角色从 payload 中取值
Args:
payload: 请求参数
current_user: 当前登录用户信息
Returns:
业务员 ID None
"""
if current_user and current_user.get("role_code") == "salesman":
return current_user.get("user_id")
return payload.get("salesman_id")
def _ensure_customer_access(self, customer: object, current_user: dict | None) -> None:
"""校验当前用户是否有权访问指定客户。
管理员和经理可访问所有客户业务员只能访问自己负责的客户
Args:
customer: 客户 ORM 对象
current_user: 当前登录用户信息
Raises:
AppException: 无权限时抛出 FORBIDDEN
"""
if not current_user:
return
role_code = current_user.get("role_code")
if role_code in {"admin", "manager"}:
return
if role_code == "salesman":
if customer.salesman_id != current_user.get("user_id"):
raise AppException(code=ErrorCode.FORBIDDEN, message="无权限访问他人客户", status_code=403)
return
raise AppException(code=ErrorCode.FORBIDDEN, message="无权限访问", status_code=403)
2026-05-14 13:51:06 +08:00
customer_service = CustomerService()