dingdanquanliucheng/backend/app/services/reminder_service.py

440 lines
19 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.

"""提醒服务模块
负责系统中各类业务提醒的查询、标记已读、以及自动生成提醒记录。
支持三类提醒场景:
- 欠款逾期提醒arrears
- 沉默客户提醒inactive_customer
- 物流超时提醒logistics_timeout
被调用方reminders 路由、salesman 路由(仪表盘提醒列表)、定时任务入口。
"""
from datetime import datetime, timedelta
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
from backend.app.repositories.config_repository import ConfigRepository
from backend.app.repositories.reminder_repository import ReminderRepository
from backend.app.services.audit_service import audit_service
from backend.app.services.event_bus import event_bus
class ReminderService:
"""提醒业务服务。
依赖:
- ReminderRepository提醒数据的增删查改
- ConfigRepository读取系统配置沉默天数阈值、物流超时小时数等
- AuditService操作审计日志
"""
def __init__(self) -> None:
self.repository = ReminderRepository()
self.config_repository = ConfigRepository()
def list_reminders(self, session: Session | None = None, filters: dict | None = None) -> dict:
"""分页查询提醒列表。
参数:
session: 数据库会话,不可为 None。
filters: 可选的筛选条件,支持 page_no、page_size、receiver_user_id 等。
返回:
包含 total、page_no、page_size、list 的分页结果字典。
被调用方reminders 路由列表接口、salesman 路由(仪表盘提醒数)。
"""
if session is None:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
filters = filters or {}
cache_key = make_cache_key("reminder:list", **filters)
cached = cache_get(cache_key)
if cached is not None:
return cached
try:
reminders = self.repository.list_reminders(session, filters)
page_no = filters.get("page_no", 1)
page_size = filters.get("page_size", 20)
start = max(page_no - 1, 0) * page_size
page_list = reminders[start : start + page_size]
result = {
"total": len(reminders),
"page_no": page_no,
"page_size": page_size,
"list": [self._map_reminder(item) for item in page_list],
}
cache_set(cache_key, result, ttl=30)
return result
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
def read_reminder(self, reminder_id: int, session: Session | None = None, current_user: dict | None = None) -> dict:
"""将指定提醒标记为已读。
参数:
reminder_id: 提醒记录 ID。
session: 数据库会话,不可为 None。
current_user: 当前登录用户信息;销售角色只能标记自己的提醒。
返回:
包含 reminder_id 和 status 的字典。
被调用方reminders 路由(标记已读接口)。
"""
if session is None:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
try:
reminder = self.repository.get_reminder(session, reminder_id)
if reminder is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="提醒不存在", status_code=404)
if current_user and current_user.get("role_code") == "salesman":
if reminder.receiver_user_id != current_user.get("user_id"):
raise AppException(code=ErrorCode.FORBIDDEN, message="无权操作他人提醒", status_code=403)
before_status = reminder.status
reminder.status = "read"
session.add(reminder)
audit_service.write_log(
session,
{
"operate_type": "reminder_read",
"biz_type": reminder.biz_type,
"biz_id": reminder.biz_id,
"before_value": {"status": before_status},
"after_value": {"status": reminder.status},
"remark": f"提醒 {reminder_id} 标记已读",
},
)
session.commit()
cache_delete_pattern("reminder:*")
cache_delete_pattern("dashboard:*")
return {"reminder_id": reminder.id, "status": reminder.status}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
def check_arrears(self, session: Session | None = None) -> dict:
"""检查逾期欠款并自动生成提醒。
按客户汇总逾期欠款总额,为每笔逾期记录创建提醒,
提醒内容包含客户累计欠款金额,便于业务员催收。
参数:
session: 数据库会话,不可为 None。
返回:
包含 checked 和 created_count 的结果字典。
被调用方reminders 路由check-arrears 接口、check_all 方法。
"""
if session is None:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
try:
customer_rows = self.repository.list_overdue_arrears_by_customer(session)
created_count = 0
for customer, receiver_user_id, total_amount, arrears_list in customer_rows:
if receiver_user_id <= 0 or total_amount <= 0:
continue
customer_name = customer.customer_name if customer else "未知客户"
for arrears, order in arrears_list:
if arrears.due_date is None or arrears.due_date >= datetime.now().date():
continue
existed = self.repository.find_active_reminder(
session,
"arrears",
"customer_arrears",
arrears.id,
receiver_user_id,
)
if existed is not None:
continue
title = f"客户欠款逾期提醒 - {customer_name}"
content = (
f"客户累计逾期欠款 {total_amount:.2f} 元,"
f"当前笔欠款 {float(arrears.arrears_amount or 0):.2f} 元,"
f"订单编号 {order.order_no if order else '-'},请及时跟进催收。"
)
self.repository.create_reminder(
session,
{
"reminder_type": "arrears",
"biz_type": "customer_arrears",
"biz_id": arrears.id,
"receiver_user_id": receiver_user_id,
"reminder_title": title,
"reminder_content": content,
"status": "pending",
"sent_at": datetime.now(),
},
)
arrears.reminded_at = datetime.now()
arrears.status = "overdue"
session.add(arrears)
created_count += 1
# 触发欠款提醒事件
event_bus.emit("arrears_reminder", {
"customer_name": customer_name,
"amount": total_amount,
"receiver_user_id": receiver_user_id,
"biz_type": "customer_arrears",
"biz_id": arrears.id,
}, session)
session.commit()
cache_delete_pattern("reminder:*")
cache_delete_pattern("dashboard:*")
return {"checked": True, "created_count": created_count}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
def check_inactive_customers(self, session: Session | None = None) -> dict:
"""检查沉默客户并自动生成提醒。
根据系统配置的天数阈值和金额阈值,查找超期未下单的客户,
为对应销售人员创建沉默客户提醒。
参数:
session: 数据库会话,不可为 None。
返回:
包含 checked 和 created_count 的结果字典。
被调用方reminders 路由check-inactive-customers 接口、check_all 方法。
"""
if session is None:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
try:
inactive_days = int(self._get_config_value(session, "inactive_customer_days", "90"))
amount_threshold = float(self._get_config_value(session, "inactive_customer_amount_threshold", "1000"))
inactive_before = datetime.now() - timedelta(days=inactive_days)
rows = self.repository.list_inactive_customers(session, inactive_before)
created_count = 0
for customer, latest_order, total_amount in rows:
if total_amount < amount_threshold:
continue
receiver_user_id = (customer.salesman_id or 0)
if receiver_user_id <= 0:
continue
existed = self.repository.find_active_reminder(
session,
"inactive_customer",
"customer",
customer.id,
receiver_user_id,
)
if existed is not None:
continue
last_order_time = (
latest_order.created_at.strftime("%Y-%m-%d %H:%M:%S")
if latest_order and latest_order.created_at
else "暂无订单"
)
self.repository.create_reminder(
session,
{
"reminder_type": "inactive_customer",
"biz_type": "customer",
"biz_id": customer.id,
"receiver_user_id": receiver_user_id,
"reminder_title": f"沉默客户提醒 - {customer.customer_name}",
"reminder_content": f"客户超过 {inactive_days} 天未下单,最近订单时间:{last_order_time}",
"status": "pending",
"sent_at": datetime.now(),
},
)
created_count += 1
# 触发沉默客户提醒事件
event_bus.emit("inactive_customer", {
"customer_name": customer.customer_name,
"days": inactive_days,
"receiver_user_id": receiver_user_id,
"biz_type": "customer",
"biz_id": customer.id,
}, session)
session.commit()
cache_delete_pattern("reminder:*")
cache_delete_pattern("dashboard:*")
return {"checked": True, "created_count": created_count}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
def check_logistics_timeout(self, session: Session | None = None) -> dict:
"""检查物流超时并自动生成提醒。
根据系统配置的超时小时数,查找超时未完成物流流转的订单,
为对应销售人员创建物流超时提醒。覆盖两种场景:
- 任务创建后长时间未揽货
- 已揽货但长时间未送达
参数:
session: 数据库会话,不可为 None。
返回:
包含 checked 和 created_count 的结果字典。
被调用方reminders 路由check-logistics-timeout 接口、check_all 方法。
"""
if session is None:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
try:
timeout_hours = int(self._get_config_value(session, "logistics_timeout_hours", "48"))
timeout_before = datetime.now() - timedelta(hours=timeout_hours)
candidates = self.repository.list_logistics_timeout_candidates(session, timeout_before)
created_count = 0
for order, timeout_type in candidates:
receiver_user_id = (order.salesman_id or 0)
if receiver_user_id <= 0:
continue
existed = self.repository.find_active_reminder(
session,
"logistics_timeout",
"sales_order",
order.id,
receiver_user_id,
)
if existed is not None:
continue
if timeout_type == "no_pickup":
title = f"物流超时提醒 - {order.order_no}"
content = f"订单 {order.order_no} 超过 {timeout_hours} 小时未完成物流流转,请及时跟进。"
else:
title = f"物流停滞提醒 - {order.order_no}"
content = f"订单 {order.order_no} 已揽货但超过 {timeout_hours} 小时未送达,请及时跟进。"
self.repository.create_reminder(
session,
{
"reminder_type": "logistics_timeout",
"biz_type": "sales_order",
"biz_id": order.id,
"receiver_user_id": receiver_user_id,
"reminder_title": title,
"reminder_content": content,
"status": "pending",
"sent_at": datetime.now(),
},
)
created_count += 1
# 触发物流超时提醒事件
event_bus.emit("logistics_timeout", {
"order_no": order.order_no,
"receiver_user_id": receiver_user_id,
"biz_type": "sales_order",
"biz_id": order.id,
}, session)
session.commit()
cache_delete_pattern("reminder:*")
cache_delete_pattern("dashboard:*")
return {"checked": True, "created_count": created_count}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
def check_all(self, session: Session | None = None) -> dict:
"""统一触发入口,一次性执行全部三类提醒检查。
便于管理端定时任务或手动补跑,依次调用欠款、沉默客户、物流超时检查。
参数:
session: 数据库会话,为 None 时返回零计数结果。
返回:
包含各类提醒创建数量和 total_created_count 的汇总字典。
被调用方reminders 路由check-all 接口)。
"""
if session is None:
return {
"checked": True,
"arrears_created_count": 0,
"inactive_customer_created_count": 0,
"logistics_timeout_created_count": 0,
"total_created_count": 0,
}
# 统一触发入口直接串起三类提醒,便于管理端定时任务或手动补跑。
arrears_result = self.check_arrears(session)
inactive_result = self.check_inactive_customers(session)
logistics_result = self.check_logistics_timeout(session)
total_created_count = (
int(arrears_result.get("created_count", 0))
+ int(inactive_result.get("created_count", 0))
+ int(logistics_result.get("created_count", 0))
)
return {
"checked": True,
"arrears_created_count": arrears_result.get("created_count", 0),
"inactive_customer_created_count": inactive_result.get("created_count", 0),
"logistics_timeout_created_count": logistics_result.get("created_count", 0),
"total_created_count": total_created_count,
}
def _get_config_value(self, session: Session, config_key: str, default: str) -> str:
"""从系统配置表读取配置值,不存在时返回默认值。
参数:
session: 数据库会话。
config_key: 配置键名。
default: 当配置不存在或为空时的默认值。
返回:
配置值字符串。
"""
config = self.config_repository.get_by_key(session, config_key)
return config.config_value if config is not None and config.config_value else default
def _map_reminder(self, reminder) -> dict:
"""将数据库提醒对象转换为 API 返回用的字典格式。
参数:
reminder: SQLAlchemy 的提醒模型对象。
返回:
包含提醒各字段的字典,日期格式化为 'YYYY-MM-DD HH:MM:SS'
"""
return {
"reminder_id": reminder.id,
"title": reminder.reminder_title,
"content": reminder.reminder_content,
"type": reminder.reminder_type,
"biz_type": reminder.biz_type,
"biz_id": reminder.biz_id,
"receiver_user_id": reminder.receiver_user_id,
"status": reminder.status,
"sent_at": reminder.sent_at.strftime("%Y-%m-%d %H:%M:%S") if reminder.sent_at else "",
"created_at": reminder.created_at.strftime("%Y-%m-%d %H:%M:%S") if reminder.created_at else "",
}
reminder_service = ReminderService()