"""提醒服务模块 负责系统中各类业务提醒的查询、标记已读、以及自动生成提醒记录。 支持三类提醒场景: - 欠款逾期提醒(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()