448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""提醒模块 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
|