dingdanquanliucheng/backend/app/services/reminder_service.py
taiyi 71c2ab2005 feat: 仪表盘接口加入 Redis 缓存
仪表盘聚合结果缓存 30s,订单/提醒变更时自动清除仪表盘缓存。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 11:17:56 +08:00

401 lines
17 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
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:
rows = self.repository.list_overdue_arrears(session)
created_count = 0
for arrears, customer, order in rows:
if arrears.due_date is None or arrears.due_date >= datetime.now().date():
continue
receiver_user_id = (customer.salesman_id if customer and customer.salesman_id else 0) or 0
if receiver_user_id <= 0:
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.customer_name if customer else '未知客户'}"
content = (
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
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
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)
orders = self.repository.list_logistics_timeout_candidates(session, timeout_before)
created_count = 0
for order in orders:
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
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": f"物流超时提醒 - {order.order_no}",
"reminder_content": f"订单 {order.order_no} 超过 {timeout_hours} 小时未完成物流流转,请及时跟进。",
"status": "pending",
"sent_at": datetime.now(),
},
)
created_count += 1
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()