dingdanquanliucheng/backend/app/repositories/reminder_repository.py

448 lines
18 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.

"""提醒模块 Repository 层
负责系统提醒、客户欠款、不活跃客户检测、物流超时提醒
等相关数据的数据库查询与持久化操作。
被 ReminderService提醒服务调用。
"""
import re
from datetime import datetime
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from backend.app.models.business import Customer, CustomerArrears, LogisticsTask, SalesOrder, SystemReminder
from backend.app.models.system import User
class ReminderRepository:
"""提醒数据仓储
封装系统提醒、客户欠款、不活跃客户检测、物流超时等查询。
被 ReminderService提醒服务调用。
"""
def list_reminders(self, session: Session, filters: dict) -> list[SystemReminder]:
"""查询系统提醒列表
根据提醒类型、状态、接收人等条件过滤提醒记录,
按 id 倒序返回。
参数:
session: 数据库会话
filters: 过滤条件字典,支持 reminder_type / status / receiver_user_id
返回:
符合条件的系统提醒列表
"""
stmt = select(SystemReminder)
if filters.get("reminder_type"):
stmt = stmt.where(SystemReminder.reminder_type == filters["reminder_type"])
if filters.get("status"):
stmt = stmt.where(SystemReminder.status == filters["status"])
if filters.get("receiver_user_id") is not None:
stmt = stmt.where(SystemReminder.receiver_user_id == filters["receiver_user_id"])
stmt = stmt.order_by(SystemReminder.id.desc())
return list(session.execute(stmt).scalars())
def get_reminder(self, session: Session, reminder_id: int) -> SystemReminder | None:
"""根据 ID 查询单条系统提醒
参数:
session: 数据库会话
reminder_id: 提醒记录 ID
返回:
对应的系统提醒对象,不存在则返回 None
"""
stmt = select(SystemReminder).where(SystemReminder.id == reminder_id)
return session.execute(stmt).scalar_one_or_none()
def get_customer(self, session: Session, customer_id: int) -> Customer | None:
"""根据 ID 查询未删除的客户信息
参数:
session: 数据库会话
customer_id: 客户 ID
返回:
对应的客户对象,不存在或已删除则返回 None
"""
stmt = select(Customer).where(Customer.id == customer_id, Customer.deleted == 0)
return session.execute(stmt).scalar_one_or_none()
def create_reminder(self, session: Session, payload: dict, skip_wechat: bool = False) -> SystemReminder:
"""创建系统提醒记录
创建提醒后,自动尝试发送微信通知(除非 skip_wechat=True
优先使用服务号模板消息,降级到小程序订阅消息。
参数:
session: 数据库会话
payload: 提醒数据字典,包含 reminder_type / receiver_user_id 等字段
skip_wechat: 为 True 时跳过微信发送(由 EventBus 统一负责)
"""
reminder = SystemReminder(**payload)
session.add(reminder)
session.flush()
if not skip_wechat:
self._try_send_wechat_notification(session, reminder)
return reminder
def _try_send_wechat_notification(self, session: Session, reminder: SystemReminder) -> None:
"""尝试发送微信通知,优先服务号模板消息,降级小程序订阅消息。
参数:
session: 数据库会话
reminder: 刚创建的系统提醒对象
"""
import logging
logger = logging.getLogger(__name__)
try:
from backend.app.models.system import User
from sqlalchemy import select
receiver_id = reminder.receiver_user_id
if receiver_id <= 0:
return
user = session.execute(select(User).where(User.id == receiver_id)).scalar_one_or_none()
if user is None:
return
extra_data = self._build_extra_data(session, reminder)
# 优先服务号模板消息
if user.service_open_id:
from backend.app.services.service_account_notification_service import service_account_notification_service
sent = service_account_notification_service.send_template_message(
user.service_open_id,
reminder.reminder_type,
title=reminder.reminder_title or "",
extra_data=extra_data,
)
if sent:
logger.info("Service account reminder sent: reminder_id=%s, user_id=%s", reminder.id, receiver_id)
return
logger.warning(
"Service account reminder failed: reminder_id=%s, user_id=%s, fallback_to_mini_program=%s",
reminder.id,
receiver_id,
bool(user.open_id),
)
else:
logger.info("User %s has no service_open_id for reminder %s", receiver_id, reminder.id)
# 降级小程序订阅消息
if user.open_id:
from backend.app.services.wechat_notification_service import wechat_notification_service
sent = wechat_notification_service.send_subscribe_message(
user.open_id,
reminder.reminder_type,
extra_data=extra_data,
)
if sent:
logger.info("Mini program reminder sent: reminder_id=%s, user_id=%s", reminder.id, receiver_id)
else:
logger.warning("Mini program reminder failed: reminder_id=%s, user_id=%s", reminder.id, receiver_id)
return
logger.warning(
"Skipping WeChat reminder: reminder_id=%s, user_id=%s has no service_open_id/open_id",
reminder.id,
receiver_id,
)
except Exception as e:
logger.warning("提醒微信通知发送失败: reminder_id=%s, error=%s", reminder.id, e)
def _build_extra_data(self, session: Session, reminder: SystemReminder) -> dict:
"""根据提醒类型从 title/content 中提取结构化数据,用于填充微信模板字段。
参数:
reminder: 系统提醒对象
返回:
包含模板所需字段数据的字典
"""
title = reminder.reminder_title or ""
content = reminder.reminder_content or ""
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
order_no_match = re.search(r'[\d-]{10,}', title + content)
order_no = order_no_match.group() if order_no_match else title
base: dict[str, str] = {
"order_no": order_no,
"change_time": now_str,
"submit_time": now_str,
"assign_time": now_str,
"publish_time": now_str,
"start_time": now_str,
"update_time": now_str,
}
rtype = reminder.reminder_type
if rtype == "order_status_change":
base["status_text"] = content[:50] if content else title
base["remark"] = ""
base["goods_info"] = ""
elif rtype == "order_approval_needed":
base["submitter"] = "业务员"
base["task_name"] = title
base["remark"] = "请尽快审批"
elif rtype == "task_assigned":
task = session.get(LogisticsTask, reminder.biz_id) if reminder.biz_type == "logistics_task" else None
order = session.get(SalesOrder, task.order_id) if task else None
driver = session.execute(select(User).where(User.id == task.driver_id)).scalar_one_or_none() if task else None
base["driver_name"] = driver.real_name if driver else "司机"
base["driver_phone"] = driver.mobile if driver and driver.mobile else "-"
base["tracking_number"] = (task.tracking_number if task and task.tracking_number else "")
base["order_no"] = order.order_no if order else order_no
elif rtype == "logistics_timeout":
order = session.get(SalesOrder, reminder.biz_id) if reminder.biz_type == "sales_order" else None
base["tracking_number"] = order.tracking_number if order and order.tracking_number else order_no
base["latest_location"] = content[:100] if content else ""
base["order_no"] = order.order_no if order else order_no
base["destination"] = order.customer_address if order and order.customer_address else ""
elif rtype == "arrears":
arrears = session.get(CustomerArrears, reminder.biz_id) if reminder.biz_type == "customer_arrears" else None
customer = session.get(Customer, arrears.customer_id) if arrears else None
order = session.get(SalesOrder, arrears.order_id) if arrears else None
amount_match = re.search(r'([\d.]+)\s*元', content)
base["pending_amount"] = str(float(arrears.arrears_amount or 0)) if arrears else (amount_match.group(1) if amount_match else "0")
base["due_time"] = arrears.due_date.strftime("%Y-%m-%d 00:00") if arrears and arrears.due_date else now_str
base["debtor_name"] = customer.customer_name if customer else ""
base["order_no"] = order.order_no if order else order_no
elif rtype == "inactive_customer":
customer = session.get(Customer, reminder.biz_id) if reminder.biz_type == "customer" else None
base["customer_name"] = customer.customer_name if customer else title.replace("沉默客户提醒 - ", "")
base["visit_time"] = now_str
base["contact_phone"] = customer.mobile if customer and customer.mobile else "-"
return base
def find_active_reminder(
self,
session: Session,
reminder_type: str,
biz_type: str,
biz_id: int,
receiver_user_id: int,
) -> SystemReminder | None:
"""查找针对同一业务对象的活跃提醒
根据提醒类型、业务类型、业务ID和接收人查找状态为
pending/sent/read 的现有提醒,用于防止重复创建提醒。
参数:
session: 数据库会话
reminder_type: 提醒类型
biz_type: 业务类型(如 order / arrears 等)
biz_id: 业务对象 ID
receiver_user_id: 接收人用户 ID
返回:
匹配的活跃提醒对象,不存在则返回 None
"""
stmt = select(SystemReminder).where(
SystemReminder.reminder_type == reminder_type,
SystemReminder.biz_type == biz_type,
SystemReminder.biz_id == biz_id,
SystemReminder.receiver_user_id == receiver_user_id,
SystemReminder.status.in_(["pending", "sent", "read"]),
).limit(1)
return session.execute(stmt).scalar_one_or_none()
def list_overdue_arrears(self, session: Session) -> list[tuple[CustomerArrears, Customer | None, SalesOrder | None]]:
"""查询逾期欠款列表
联表查询状态为 pending 或 overdue 的欠款记录,
关联客户和销售订单信息。
参数:
session: 数据库会话
返回:
(欠款记录, 客户对象, 销售订单对象) 的元组列表
"""
stmt = (
select(CustomerArrears, Customer, SalesOrder)
.outerjoin(Customer, Customer.id == CustomerArrears.customer_id)
.outerjoin(SalesOrder, SalesOrder.id == CustomerArrears.order_id)
.where(CustomerArrears.status.in_(["pending", "overdue"]))
.order_by(CustomerArrears.id.asc())
)
return list(session.execute(stmt).all())
def list_overdue_arrears_by_customer(
self, session: Session
) -> list[tuple[Customer | None, int, float, list[tuple[CustomerArrears, SalesOrder | None]]]]:
"""按客户汇总逾期欠款
将逾期欠款记录按客户分组,计算每个客户的累计欠款金额。
参数:
session: 数据库会话
返回:
(客户对象, 业务员ID, 累计欠款金额, [(欠款记录, 订单对象)]) 的元组列表
"""
from collections import defaultdict
rows = self.list_overdue_arrears(session)
customer_map: dict[int, dict] = defaultdict(
lambda: {"customer": None, "salesman_id": 0, "total": 0.0, "items": []}
)
for arrears, customer, order in rows:
cid = customer.id if customer else 0
entry = customer_map[cid]
if entry["customer"] is None:
entry["customer"] = customer
entry["salesman_id"] = (customer.salesman_id if customer else 0) or 0
entry["total"] += float(arrears.arrears_amount or 0)
entry["items"].append((arrears, order))
return [
(v["customer"], v["salesman_id"], v["total"], v["items"])
for v in customer_map.values()
]
def get_arrears_by_order_id(self, session: Session, order_id: int) -> CustomerArrears | None:
"""根据订单 ID 查询欠款记录
参数:
session: 数据库会话
order_id: 关联的销售订单 ID
返回:
对应的欠款记录,不存在则返回 None
"""
stmt = select(CustomerArrears).where(CustomerArrears.order_id == order_id)
return session.execute(stmt).scalar_one_or_none()
def create_arrears(self, session: Session, payload: dict) -> CustomerArrears:
"""创建客户欠款记录
参数:
session: 数据库会话
payload: 欠款数据字典,包含 order_id / customer_id / amount 等字段
返回:
新创建的欠款记录对象
"""
arrears = CustomerArrears(**payload)
session.add(arrears)
session.flush()
return arrears
def list_inactive_customers(
self,
session: Session,
inactive_before: datetime,
) -> list[tuple[Customer, SalesOrder | None, float]]:
"""查询不活跃客户列表
遍历所有未删除客户,计算其累计订单金额并查找最近一笔订单。
若最近订单时间早于或等于指定时间阈值,则视为不活跃客户。
参数:
session: 数据库会话
inactive_before: 不活跃截止时间,最近订单晚于此时间的客户视为活跃
返回:
(客户对象, 最近订单对象, 累计订单金额) 的元组列表
"""
customers = list(
session.execute(
select(Customer)
.where(Customer.deleted == 0)
.order_by(Customer.id.asc())
).scalars()
)
results: list[tuple[Customer, SalesOrder | None, float]] = []
for customer in customers:
total_amount_stmt = (
select(func.coalesce(func.sum(SalesOrder.contract_amount), 0))
.where(
SalesOrder.deleted == 0,
SalesOrder.customer_id == customer.id,
SalesOrder.order_status != "canceled",
)
)
total_amount = float(session.execute(total_amount_stmt).scalar() or 0)
latest_order_stmt = (
select(SalesOrder)
.where(
SalesOrder.deleted == 0,
SalesOrder.customer_id == customer.id,
SalesOrder.order_status != "canceled",
)
.order_by(SalesOrder.created_at.desc(), SalesOrder.id.desc())
)
latest_order = session.execute(latest_order_stmt).scalars().first()
if latest_order is None or (latest_order.created_at and latest_order.created_at <= inactive_before):
results.append((customer, latest_order, total_amount))
return results
def list_logistics_timeout_candidates(
self,
session: Session,
timeout_before: datetime,
) -> list[tuple[SalesOrder, str]]:
"""查询物流超时待提醒的销售订单
查询两种超时场景:
- no_pickup: 任务创建超过阈值仍未揽货pending/accepted 状态)
- picked_stuck: 已揽货但超过阈值未送达picked_up 状态且 updated_at 超时)
参数:
session: 数据库会话
timeout_before: 超时截止时间
返回:
(销售订单, 超时类型) 元组列表,订单已去重
"""
# 场景1未揽货超时
stmt_no_pickup = (
select(SalesOrder)
.join(LogisticsTask, LogisticsTask.order_id == SalesOrder.id)
.where(
SalesOrder.deleted == 0,
LogisticsTask.status.in_(["pending", "accepted"]),
LogisticsTask.created_at <= timeout_before,
)
.distinct()
.order_by(SalesOrder.id.asc())
)
# 场景2已揽货但未送达超时
stmt_picked_stuck = (
select(SalesOrder)
.join(LogisticsTask, LogisticsTask.order_id == SalesOrder.id)
.where(
SalesOrder.deleted == 0,
LogisticsTask.status == "picked_up",
LogisticsTask.updated_at <= timeout_before,
)
.distinct()
.order_by(SalesOrder.id.asc())
)
results: list[tuple[SalesOrder, str]] = []
seen_ids: set[int] = set()
for order in session.execute(stmt_no_pickup).scalars():
if order.id not in seen_ids:
results.append((order, "no_pickup"))
seen_ids.add(order.id)
for order in session.execute(stmt_picked_stuck).scalars():
if order.id not in seen_ids:
results.append((order, "picked_stuck"))
seen_ids.add(order.id)
return results