Files
wecom_it_smart_desk/backend/app/services/queue_service.py
T

483 lines
17 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 分层排队服务
# =============================================================================
# 说明:实现三段排序的排队位置计算和平台统计
#
# 排队三段排序(决策 C1/C2):
# 段1VIP):is_vip = true,不受信息梳理影响
# 段2(已梳理):is_vip = false AND info_locked = true
# 段3(待梳理):is_vip = false AND info_locked = false
#
# 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC
# 插队规则(决策 C4):queue_priority = min(答题数//3, 2),上限2
# =============================================================================
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_, select, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.quiz import EmployeePoints
logger = logging.getLogger(__name__)
# 预估每个排队者的服务时间(秒),用于计算预估等待时间
ESTIMATED_SERVICE_TIME_SEC = 300 # 5分钟/人
class QueueService:
"""分层排队服务。
提供排队位置计算、平台统计、坐席端看板数据等功能。
"""
# ======================================================================
# 段位判定
# ======================================================================
@staticmethod
def _determine_segment(conversation: Conversation) -> str:
"""判定会话属于哪个排队段位。
Args:
conversation: 会话对象
Returns:
str: "vip" / "completed" / "incomplete"
"""
if conversation.is_vip:
return "vip"
elif conversation.info_locked:
return "completed"
else:
return "incomplete"
# ======================================================================
# 排队位置计算
# ======================================================================
async def calculate_queue_position(
self, db: AsyncSession, conversation: Conversation
) -> Dict[str, Any]:
"""计算指定会话的排队位置(三段排序)。
排序逻辑:
1. VIP段排最前
2. 已梳理(info_locked=true)段排第二
3. 待梳理(info_locked=false)段排最后
4. 同段内:queue_priority DESC → urgency_score DESC → created_at ASC
Args:
db: 数据库会话
conversation: 要计算位置的会话
Returns:
Dict: {position, segment, ahead_count, estimated_wait_sec}
"""
segment = self._determine_segment(conversation)
ahead_count = 0
# ---- 计算更高段的人数 ----
if segment != "vip":
# 当前不是VIP段 → 所有VIP都排前面
vip_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == True, # noqa: E712
)
)
ahead_count += vip_count or 0
if segment == "incomplete":
# 当前是待梳理段 → 已梳理段也排前面
completed_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == True, # noqa: E712
)
)
ahead_count += completed_count or 0
# ---- 计算同段内排在前面的人数 ----
same_segment_ahead = await self._count_same_segment_ahead(db, conversation, segment)
ahead_count += same_segment_ahead
position = ahead_count + 1
estimated_wait = position * ESTIMATED_SERVICE_TIME_SEC
# 中文段位名称(前端展示用)
segment_labels = {
"vip": "VIP优先",
"completed": "已梳理",
"incomplete": "待梳理",
}
return {
"position": position,
"segment": segment,
"segment_label": segment_labels.get(segment, segment),
"ahead_count": ahead_count,
"estimated_wait_sec": estimated_wait,
"estimated_wait_text": self._format_wait_time(estimated_wait),
"queue_priority": conversation.queue_priority,
}
async def _count_same_segment_ahead(
self, db: AsyncSession, conversation: Conversation, segment: str
) -> int:
"""计算同段内排在当前会话前面的排队人数。
段内排序规则:queue_priority DESC → urgency_score DESC → created_at ASC
Args:
db: 数据库会话
conversation: 当前会话
segment: 当前段位
Returns:
int: 同段内排在前面的人数
"""
# 构建同段条件
conditions = [
Conversation.status == "queued",
Conversation.id != conversation.id, # 排除自己
]
if segment == "vip":
conditions.append(Conversation.is_vip == True) # noqa: E712
elif segment == "completed":
conditions.append(Conversation.is_vip == False) # noqa: E712
conditions.append(Conversation.info_locked == True) # noqa: E712
else: # incomplete
conditions.append(Conversation.is_vip == False) # noqa: E712
conditions.append(Conversation.info_locked == False) # noqa: E712
# 同段内排在前面的条件:
# 1. queue_priority 更高
# 2. 或 queue_priority 相同且 urgency_score 更高
# 3. 或 queue_priority 和 urgency_score 都相同且 created_at 更早
ahead_conditions = or_(
Conversation.queue_priority > conversation.queue_priority,
and_(
Conversation.queue_priority == conversation.queue_priority,
Conversation.urgency_score > conversation.urgency_score,
),
and_(
Conversation.queue_priority == conversation.queue_priority,
Conversation.urgency_score == conversation.urgency_score,
Conversation.created_at < conversation.created_at,
),
)
count = await db.scalar(
select(func.count(Conversation.id)).where(
*conditions, ahead_conditions
)
)
return count or 0
# ======================================================================
# 平台统计
# ======================================================================
async def get_platform_stats(self, db: AsyncSession) -> Dict[str, int]:
"""获取平台统计数据(决策 C5)。
total_active = ai_handling + queued + serving
queued = 排队中人数
serving = 服务中人数
Args:
db: 数据库会话
Returns:
Dict: {total_active, queued, serving, ai_handling}
"""
# 总活跃 = ai_handling + queued + serving
total_active = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status.in_(["ai_handling", "queued", "serving"])
)
)
queued = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued"
)
)
serving = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "serving"
)
)
ai_handling = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "ai_handling"
)
)
return {
"total_active": total_active or 0,
"queued": queued or 0,
"serving": serving or 0,
"ai_handling": ai_handling or 0,
}
async def get_queue_segment_stats(self, db: AsyncSession) -> Dict[str, int]:
"""获取排队分段统计(坐席端看板用)。
Returns:
Dict: {vip_count, completed_count, incomplete_count, total_queued}
"""
# VIP段
vip_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == True, # noqa: E712
)
)
# 已梳理段
completed_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == True, # noqa: E712
)
)
# 待梳理段
incomplete_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == False, # noqa: E712
)
)
total_queued = (vip_count or 0) + (completed_count or 0) + (incomplete_count or 0)
return {
"vip_count": vip_count or 0,
"completed_count": completed_count or 0,
"incomplete_count": incomplete_count or 0,
"total_queued": total_queued,
}
# ======================================================================
# 综合排队状态(H5端 queue/status API
# ======================================================================
async def get_comprehensive_status(
self, db: AsyncSession, conversation: Conversation
) -> Dict[str, Any]:
"""获取综合排队状态:排队位置+段位+平台统计+答题状态+积分。
供 GET /api/h5/queue/status API调用。
Args:
db: 数据库会话
conversation: 当前会话
Returns:
Dict: 综合状态数据
"""
# 1. 排队位置(仅排队中时计算)
if conversation.status == "queued":
queue_info = await self.calculate_queue_position(db, conversation)
else:
queue_info = {
"position": 0,
"segment": self._determine_segment(conversation),
"segment_label": "非排队中",
"ahead_count": 0,
"estimated_wait_sec": 0,
"estimated_wait_text": "",
"queue_priority": conversation.queue_priority,
}
# 2. 平台统计
platform_stats = await self.get_platform_stats(db)
# 3. 积分信息
points_info = await self._get_employee_points(db, conversation.employee_id)
# 4. 插队信息
quiz_answered = conversation.queue_priority * 3 if conversation.queue_priority > 0 else 0
max_quiz_for_jump = 6 # 2位×3题=6题
remaining_for_next_jump = 3 - (quiz_answered % 3) if quiz_answered < max_quiz_for_jump else 0
return {
"conversation_status": conversation.status,
"queue": queue_info,
"platform": platform_stats,
"points": points_info,
"quiz": {
"answered_in_session": quiz_answered,
"queue_priority": conversation.queue_priority,
"max_priority": 2,
"remaining_for_next_jump": remaining_for_next_jump,
"can_jump_more": conversation.queue_priority < 2,
},
"info_locked": conversation.info_locked,
}
# ======================================================================
# 坐席端排队看板
# ======================================================================
async def get_agent_dashboard(self, db: AsyncSession) -> Dict[str, Any]:
"""获取坐席端排队看板数据。
Returns:
Dict: {segment_stats, platform_stats, queue_list}
"""
segment_stats = await self.get_queue_segment_stats(db)
platform_stats = await self.get_platform_stats(db)
# 获取排队列表(按三段排序)
queue_list = await self._get_sorted_queue_list(db, limit=50)
return {
"segments": segment_stats,
"platform": platform_stats,
"queue_list": queue_list,
}
async def _get_sorted_queue_list(
self, db: AsyncSession, limit: int = 50
) -> List[Dict[str, Any]]:
"""获取按三段排序的排队列表。
Returns:
List[Dict]: 排队会话列表
"""
# 查询所有排队中的会话,按段位+段内排序
# 段位排序:VIP(0) > 已梳理(1) > 待梳理(2)
segment_order = case(
(Conversation.is_vip == True, 0), # noqa: E712
(Conversation.info_locked == True, 1), # noqa: E712
else_=2,
)
stmt = (
select(Conversation)
.where(Conversation.status == "queued")
.order_by(
segment_order,
Conversation.queue_priority.desc(),
Conversation.urgency_score.desc(),
Conversation.created_at.asc(),
)
.limit(limit)
)
result = await db.execute(stmt)
conversations = result.scalars().all()
# 转为前端需要的列表格式
queue_list = []
for conv in conversations:
segment = self._determine_segment(conv)
queue_list.append({
"conversation_id": conv.id,
"employee_name": conv.employee_name,
"department": conv.department,
"employee_id": conv.employee_id,
"segment": segment,
"segment_label": {
"vip": "VIP优先",
"completed": "已梳理",
"incomplete": "待梳理",
}.get(segment, segment),
"urgency_score": conv.urgency_score,
"queue_priority": conv.queue_priority,
"info_locked": conv.info_locked,
"is_vip": conv.is_vip,
"last_message_summary": conv.last_message_summary,
"created_at": conv.created_at.isoformat() if conv.created_at else None,
"waiting_seconds": int(
(datetime.now(timezone.utc) - conv.created_at).total_seconds()
) if conv.created_at else 0,
})
return queue_list
# ======================================================================
# 辅助方法
# ======================================================================
async def _get_employee_points(
self, db: AsyncSession, employee_id: str
) -> Dict[str, Any]:
"""获取员工积分信息。
Args:
db: 数据库会话
employee_id: 员工ID
Returns:
Dict: {total_points, level, answered_count, correct_count}
"""
result = await db.execute(
select(EmployeePoints).where(EmployeePoints.employee_id == employee_id)
)
points = result.scalar_one_or_none()
if points:
return {
"total_points": points.total_points,
"level": points.level,
"answered_count": points.answered_count,
"correct_count": points.correct_count,
}
else:
return {
"total_points": 0,
"level": "IT小白",
"answered_count": 0,
"correct_count": 0,
}
@staticmethod
def _format_wait_time(seconds: int) -> str:
"""将秒数格式化为人类可读的等待时间文本。
Args:
seconds: 秒数
Returns:
str: 如"约5分钟""约1小时30分钟"
"""
if seconds <= 0:
return "即将接通"
minutes = seconds // 60
if minutes < 1:
return f"{seconds}"
elif minutes < 60:
return f"{minutes}分钟"
else:
hours = minutes // 60
remaining_minutes = minutes % 60
if remaining_minutes == 0:
return f"{hours}小时"
return f"{hours}小时{remaining_minutes}分钟"
# 单例
_queue_service: Optional[QueueService] = None
def get_queue_service() -> QueueService:
"""获取 QueueService 单例。"""
global _queue_service
if _queue_service is None:
_queue_service = QueueService()
return _queue_service