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