# ============================================================================= # 企微IT智能服务台 — 分层排队服务 # ============================================================================= # 说明:实现三段排序的排队位置计算和平台统计 # # 排队三段排序(决策 C1/C2): # 段1(VIP):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