""" 成长路径服务 """ import logging from typing import List, Optional, Dict, Any from datetime import datetime from decimal import Decimal from sqlalchemy import select, func, and_, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.course import GrowthPath, Course from app.models.growth_path import ( GrowthPathNode, UserGrowthPathProgress, UserNodeCompletion, GrowthPathStatus, NodeStatus, ) from app.models.user_course_progress import UserCourseProgress, ProgressStatus from app.models.position import Position from app.models.position_member import PositionMember from app.schemas.growth_path import ( GrowthPathCreate, GrowthPathUpdate, GrowthPathNodeCreate, TraineeGrowthPathResponse, TraineeStageResponse, TraineeNodeResponse, ) logger = logging.getLogger(__name__) class GrowthPathService: """成长路径服务""" # ===================================================== # 管理端 - CRUD # ===================================================== async def create_growth_path( self, db: AsyncSession, data: GrowthPathCreate, created_by: int ) -> GrowthPath: """创建成长路径""" # 检查名称是否重复 existing = await db.execute( select(GrowthPath).where( and_( GrowthPath.name == data.name, GrowthPath.is_deleted == False ) ) ) if existing.scalar_one_or_none(): raise ValueError(f"成长路径名称 '{data.name}' 已存在") # 处理岗位关联:优先使用 position_ids,兼容 position_id position_ids = data.position_ids position_id = data.position_id if position_ids is None and position_id is not None: position_ids = [position_id] elif position_ids and not position_id: position_id = position_ids[0] if position_ids else None # 创建成长路径 growth_path = GrowthPath( name=data.name, description=data.description, target_role=data.target_role, position_id=position_id, position_ids=position_ids, stages=[s.model_dump() for s in data.stages] if data.stages else None, estimated_duration_days=data.estimated_duration_days, is_active=data.is_active, sort_order=data.sort_order, ) db.add(growth_path) await db.flush() # 创建节点 if data.nodes: for node_data in data.nodes: node = GrowthPathNode( growth_path_id=growth_path.id, course_id=node_data.course_id, stage_name=node_data.stage_name, title=node_data.title, description=node_data.description, order_num=node_data.order_num, is_required=node_data.is_required, prerequisites=node_data.prerequisites, estimated_days=node_data.estimated_days, ) db.add(node) await db.commit() await db.refresh(growth_path) logger.info(f"创建成长路径: {growth_path.name}, ID: {growth_path.id}") return growth_path async def update_growth_path( self, db: AsyncSession, path_id: int, data: GrowthPathUpdate ) -> GrowthPath: """更新成长路径""" growth_path = await db.get(GrowthPath, path_id) if not growth_path or growth_path.is_deleted: raise ValueError("成长路径不存在") # 更新基本信息 update_data = data.model_dump(exclude_unset=True, exclude={'nodes'}) if 'stages' in update_data and update_data['stages']: update_data['stages'] = [s.model_dump() if hasattr(s, 'model_dump') else s for s in update_data['stages']] # 处理 position_ids 和 position_id 的兼容 if 'position_ids' in update_data and update_data['position_ids']: update_data['position_id'] = update_data['position_ids'][0] elif 'position_id' in update_data and update_data['position_id'] and 'position_ids' not in update_data: update_data['position_ids'] = [update_data['position_id']] for key, value in update_data.items(): setattr(growth_path, key, value) # 如果提供了节点,整体替换 if data.nodes is not None: # 删除旧节点 await db.execute( delete(GrowthPathNode).where(GrowthPathNode.growth_path_id == path_id) ) # 创建新节点 for node_data in data.nodes: node = GrowthPathNode( growth_path_id=path_id, course_id=node_data.course_id, stage_name=node_data.stage_name, title=node_data.title, description=node_data.description, order_num=node_data.order_num, is_required=node_data.is_required, prerequisites=node_data.prerequisites, estimated_days=node_data.estimated_days, ) db.add(node) await db.commit() await db.refresh(growth_path) logger.info(f"更新成长路径: {growth_path.name}, ID: {path_id}") return growth_path async def delete_growth_path(self, db: AsyncSession, path_id: int) -> bool: """删除成长路径(软删除)""" growth_path = await db.get(GrowthPath, path_id) if not growth_path or growth_path.is_deleted: raise ValueError("成长路径不存在") growth_path.is_deleted = True growth_path.deleted_at = datetime.now() await db.commit() logger.info(f"删除成长路径: {growth_path.name}, ID: {path_id}") return True async def get_growth_path( self, db: AsyncSession, path_id: int ) -> Optional[Dict[str, Any]]: """获取成长路径详情""" result = await db.execute( select(GrowthPath) .options(selectinload(GrowthPath.nodes)) .where( and_( GrowthPath.id == path_id, GrowthPath.is_deleted == False ) ) ) growth_path = result.scalar_one_or_none() if not growth_path: return None # 获取岗位名称(支持多岗位) position_ids = growth_path.position_ids or [] # 兼容旧数据 if not position_ids and growth_path.position_id: position_ids = [growth_path.position_id] position_names = [] for pid in position_ids: position = await db.get(Position, pid) if position: position_names.append(position.name) # 兼容旧版 position_name(取第一个) position_name = position_names[0] if position_names else None # 获取课程名称 nodes_data = [] for node in sorted(growth_path.nodes, key=lambda x: x.order_num): if node.is_deleted: continue course = await db.get(Course, node.course_id) nodes_data.append({ "id": node.id, "growth_path_id": node.growth_path_id, "course_id": node.course_id, "course_name": course.name if course else None, "stage_name": node.stage_name, "title": node.title, "description": node.description, "order_num": node.order_num, "is_required": node.is_required, "prerequisites": node.prerequisites, "estimated_days": node.estimated_days, "created_at": node.created_at, "updated_at": node.updated_at, }) return { "id": growth_path.id, "name": growth_path.name, "description": growth_path.description, "target_role": growth_path.target_role, "position_id": growth_path.position_id, "position_ids": position_ids, "position_name": position_name, "position_names": position_names, "stages": growth_path.stages, "estimated_duration_days": growth_path.estimated_duration_days, "is_active": growth_path.is_active, "sort_order": growth_path.sort_order, "nodes": nodes_data, "node_count": len(nodes_data), "created_at": growth_path.created_at, "updated_at": growth_path.updated_at, } async def list_growth_paths( self, db: AsyncSession, position_id: Optional[int] = None, is_active: Optional[bool] = None, page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: """获取成长路径列表""" query = select(GrowthPath).where(GrowthPath.is_deleted == False) if position_id is not None: query = query.where(GrowthPath.position_id == position_id) if is_active is not None: query = query.where(GrowthPath.is_active == is_active) # 计算总数 count_result = await db.execute( select(func.count(GrowthPath.id)).where(GrowthPath.is_deleted == False) ) total = count_result.scalar() or 0 # 分页 query = query.order_by(GrowthPath.sort_order, GrowthPath.id.desc()) query = query.offset((page - 1) * page_size).limit(page_size) result = await db.execute(query) paths = result.scalars().all() items = [] for path in paths: # 获取岗位名称(支持多岗位) position_ids = path.position_ids or [] if not position_ids and path.position_id: position_ids = [path.position_id] position_names = [] for pid in position_ids: position = await db.get(Position, pid) if position: position_names.append(position.name) position_name = position_names[0] if position_names else None # 获取节点数量 node_count_result = await db.execute( select(func.count(GrowthPathNode.id)).where( and_( GrowthPathNode.growth_path_id == path.id, GrowthPathNode.is_deleted == False ) ) ) node_count = node_count_result.scalar() or 0 items.append({ "id": path.id, "name": path.name, "description": path.description, "position_id": path.position_id, "position_ids": position_ids, "position_name": position_name, "position_names": position_names, "is_active": path.is_active, "node_count": node_count, "estimated_duration_days": path.estimated_duration_days, "created_at": path.created_at, }) return { "items": items, "total": total, "page": page, "page_size": page_size, } # ===================================================== # 学员端 - 获取成长路径 # ===================================================== async def get_trainee_growth_path( self, db: AsyncSession, user_id: int, position_id: Optional[int] = None ) -> Optional[TraineeGrowthPathResponse]: """ 获取学员的成长路径(含进度) 如果指定了岗位ID,返回该岗位的成长路径 否则根据用户岗位自动匹配 """ # 如果没有指定岗位,获取用户的岗位列表 user_position_ids = [] if not position_id: pos_result = await db.execute( select(PositionMember.position_id).where( and_( PositionMember.user_id == user_id, PositionMember.is_deleted == False ) ) ) user_position_ids = [row[0] for row in pos_result.fetchall()] logger.info(f"用户 {user_id} 的岗位列表: {user_position_ids}") # 查找成长路径 query = select(GrowthPath).where( and_( GrowthPath.is_deleted == False, GrowthPath.is_active == True ) ) # 按 sort_order 排序获取所有激活的成长路径 query = query.order_by(GrowthPath.sort_order) result = await db.execute(query) all_paths = result.scalars().all() growth_path = None if position_id: # 如果指定了岗位ID,查找匹配的路径 for path in all_paths: # 检查 position_ids (多岗位) 或 position_id (单岗位) path_position_ids = path.position_ids or [] if path.position_id: path_position_ids = list(set(path_position_ids + [path.position_id])) if position_id in path_position_ids: growth_path = path break elif user_position_ids: # 根据用户岗位匹配 for path in all_paths: path_position_ids = path.position_ids or [] if path.position_id: path_position_ids = list(set(path_position_ids + [path.position_id])) # 检查用户岗位是否与路径岗位有交集 if any(pid in path_position_ids for pid in user_position_ids): growth_path = path logger.info(f"匹配到成长路径: {path.id}, 路径岗位: {path_position_ids}") break else: # 没有岗位限制,返回第一个 growth_path = all_paths[0] if all_paths else None if not growth_path: return None # 获取岗位名称 position_name = None if growth_path.position_id: position = await db.get(Position, growth_path.position_id) if position: position_name = position.name # 获取所有节点 nodes_result = await db.execute( select(GrowthPathNode).where( and_( GrowthPathNode.growth_path_id == growth_path.id, GrowthPathNode.is_deleted == False ) ).order_by(GrowthPathNode.order_num) ) nodes = nodes_result.scalars().all() # 获取用户进度 progress_result = await db.execute( select(UserGrowthPathProgress).where( and_( UserGrowthPathProgress.user_id == user_id, UserGrowthPathProgress.growth_path_id == growth_path.id ) ) ) user_progress = progress_result.scalar_one_or_none() # 获取用户节点完成情况 completions_result = await db.execute( select(UserNodeCompletion).where( and_( UserNodeCompletion.user_id == user_id, UserNodeCompletion.growth_path_id == growth_path.id ) ) ) completions = {c.node_id: c for c in completions_result.scalars().all()} # 获取用户课程学习进度 course_ids = [n.course_id for n in nodes] course_progress_result = await db.execute( select(UserCourseProgress).where( and_( UserCourseProgress.user_id == user_id, UserCourseProgress.course_id.in_(course_ids) ) ) ) course_progress_map = {cp.course_id: cp for cp in course_progress_result.scalars().all()} # 构建节点响应 completed_node_ids = set(user_progress.completed_node_ids or []) if user_progress else set() nodes_by_stage: Dict[str, List[TraineeNodeResponse]] = {} for node in nodes: # 获取课程信息 course = await db.get(Course, node.course_id) # 计算节点状态 node_status = self._calculate_node_status( node=node, completed_node_ids=completed_node_ids, completions=completions, course_progress_map=course_progress_map ) # 获取课程进度 course_prog = course_progress_map.get(node.course_id) progress = float(course_prog.progress) if course_prog else 0 node_response = TraineeNodeResponse( id=node.id, course_id=node.course_id, title=node.title, description=node.description, stage_name=node.stage_name, is_required=node.is_required, estimated_days=node.estimated_days, order_num=node.order_num, status=node_status, progress=progress, course_name=course.name if course else None, course_cover=course.cover_image if course else None, ) stage_name = node.stage_name or "默认阶段" if stage_name not in nodes_by_stage: nodes_by_stage[stage_name] = [] nodes_by_stage[stage_name].append(node_response) # 构建阶段响应 stages = [] stage_configs = growth_path.stages or [] stage_order = {s.get('name', ''): s.get('order', i) for i, s in enumerate(stage_configs)} for stage_name, stage_nodes in sorted(nodes_by_stage.items(), key=lambda x: stage_order.get(x[0], 999)): completed_in_stage = sum(1 for n in stage_nodes if n.status == NodeStatus.COMPLETED.value) stage_desc = next((s.get('description') for s in stage_configs if s.get('name') == stage_name), None) stages.append(TraineeStageResponse( name=stage_name, description=stage_desc, completed=completed_in_stage, total=len(stage_nodes), nodes=stage_nodes, )) # 计算总体进度 total_nodes = len(nodes) completed_count = len(completed_node_ids) total_progress = (completed_count / total_nodes * 100) if total_nodes > 0 else 0 return TraineeGrowthPathResponse( id=growth_path.id, name=growth_path.name, description=growth_path.description, position_id=growth_path.position_id, position_name=position_name, total_progress=round(total_progress, 1), completed_nodes=completed_count, total_nodes=total_nodes, status=user_progress.status if user_progress else GrowthPathStatus.NOT_STARTED.value, started_at=user_progress.started_at if user_progress else None, estimated_completion_days=growth_path.estimated_duration_days, stages=stages, ) def _calculate_node_status( self, node: GrowthPathNode, completed_node_ids: set, completions: Dict[int, UserNodeCompletion], course_progress_map: Dict[int, UserCourseProgress] ) -> str: """计算节点状态""" # 已完成 if node.id in completed_node_ids: return NodeStatus.COMPLETED.value # 检查前置节点 prerequisites = node.prerequisites or [] if prerequisites: for prereq_id in prerequisites: if prereq_id not in completed_node_ids: return NodeStatus.LOCKED.value # 检查用户节点记录 completion = completions.get(node.id) if completion: if completion.status == NodeStatus.IN_PROGRESS.value: return NodeStatus.IN_PROGRESS.value if completion.status == NodeStatus.COMPLETED.value: return NodeStatus.COMPLETED.value # 检查课程进度 course_progress = course_progress_map.get(node.course_id) if course_progress: if course_progress.status == ProgressStatus.COMPLETED.value: return NodeStatus.COMPLETED.value if course_progress.progress > 0: return NodeStatus.IN_PROGRESS.value # 前置已完成,当前未开始 return NodeStatus.UNLOCKED.value # ===================================================== # 学员端 - 开始/完成 # ===================================================== async def start_growth_path( self, db: AsyncSession, user_id: int, growth_path_id: int ) -> UserGrowthPathProgress: """开始学习成长路径""" # 检查成长路径是否存在 growth_path = await db.get(GrowthPath, growth_path_id) if not growth_path or growth_path.is_deleted or not growth_path.is_active: raise ValueError("成长路径不存在或未启用") # 检查是否已开始 existing = await db.execute( select(UserGrowthPathProgress).where( and_( UserGrowthPathProgress.user_id == user_id, UserGrowthPathProgress.growth_path_id == growth_path_id ) ) ) if existing.scalar_one_or_none(): raise ValueError("已开始学习此成长路径") # 创建进度记录 progress = UserGrowthPathProgress( user_id=user_id, growth_path_id=growth_path_id, status=GrowthPathStatus.IN_PROGRESS.value, started_at=datetime.now(), last_activity_at=datetime.now(), ) db.add(progress) # 获取第一个节点(无前置依赖的) first_node_result = await db.execute( select(GrowthPathNode).where( and_( GrowthPathNode.growth_path_id == growth_path_id, GrowthPathNode.is_deleted == False ) ).order_by(GrowthPathNode.order_num).limit(1) ) first_node = first_node_result.scalar_one_or_none() if first_node: progress.current_node_id = first_node.id # 解锁第一个节点 completion = UserNodeCompletion( user_id=user_id, growth_path_id=growth_path_id, node_id=first_node.id, status=NodeStatus.UNLOCKED.value, unlocked_at=datetime.now(), ) db.add(completion) await db.commit() await db.refresh(progress) logger.info(f"用户 {user_id} 开始学习成长路径 {growth_path_id}") return progress async def complete_node( self, db: AsyncSession, user_id: int, node_id: int ) -> Dict[str, Any]: """完成节点""" # 获取节点 node = await db.get(GrowthPathNode, node_id) if not node or node.is_deleted: raise ValueError("节点不存在") # 获取用户进度 progress_result = await db.execute( select(UserGrowthPathProgress).where( and_( UserGrowthPathProgress.user_id == user_id, UserGrowthPathProgress.growth_path_id == node.growth_path_id ) ) ) progress = progress_result.scalar_one_or_none() if not progress: raise ValueError("请先开始学习此成长路径") # 检查前置节点是否完成 completed_node_ids = set(progress.completed_node_ids or []) prerequisites = node.prerequisites or [] for prereq_id in prerequisites: if prereq_id not in completed_node_ids: raise ValueError("前置节点未完成") # 更新节点完成状态 completion_result = await db.execute( select(UserNodeCompletion).where( and_( UserNodeCompletion.user_id == user_id, UserNodeCompletion.node_id == node_id ) ) ) completion = completion_result.scalar_one_or_none() if not completion: completion = UserNodeCompletion( user_id=user_id, growth_path_id=node.growth_path_id, node_id=node_id, ) db.add(completion) completion.status = NodeStatus.COMPLETED.value completion.course_progress = Decimal("100.00") completion.completed_at = datetime.now() # 更新用户进度 completed_node_ids.add(node_id) progress.completed_node_ids = list(completed_node_ids) progress.last_activity_at = datetime.now() # 计算总进度 total_nodes_result = await db.execute( select(func.count(GrowthPathNode.id)).where( and_( GrowthPathNode.growth_path_id == node.growth_path_id, GrowthPathNode.is_deleted == False ) ) ) total_nodes = total_nodes_result.scalar() or 0 progress.total_progress = Decimal(str(len(completed_node_ids) / total_nodes * 100)) if total_nodes > 0 else Decimal("0") # 检查是否全部完成 if len(completed_node_ids) >= total_nodes: progress.status = GrowthPathStatus.COMPLETED.value progress.completed_at = datetime.now() # 解锁下一个节点 next_nodes = await self._get_unlockable_nodes( db, node.growth_path_id, completed_node_ids ) await db.commit() logger.info(f"用户 {user_id} 完成节点 {node_id}") return { "completed": True, "total_progress": float(progress.total_progress), "is_path_completed": progress.status == GrowthPathStatus.COMPLETED.value, "unlocked_nodes": [n.id for n in next_nodes], } async def _get_unlockable_nodes( self, db: AsyncSession, growth_path_id: int, completed_node_ids: set ) -> List[GrowthPathNode]: """获取可解锁的节点""" nodes_result = await db.execute( select(GrowthPathNode).where( and_( GrowthPathNode.growth_path_id == growth_path_id, GrowthPathNode.is_deleted == False, GrowthPathNode.id.notin_(completed_node_ids) if completed_node_ids else True ) ) ) nodes = nodes_result.scalars().all() unlockable = [] for node in nodes: prerequisites = node.prerequisites or [] if all(p in completed_node_ids for p in prerequisites): unlockable.append(node) return unlockable # ===================================================== # 同步课程进度到节点 # ===================================================== async def sync_course_progress( self, db: AsyncSession, user_id: int, course_id: int, progress: float ): """ 同步课程学习进度到成长路径节点 当用户完成课程学习时调用 """ # 查找包含该课程的节点 nodes_result = await db.execute( select(GrowthPathNode).where( and_( GrowthPathNode.course_id == course_id, GrowthPathNode.is_deleted == False ) ) ) nodes = nodes_result.scalars().all() for node in nodes: # 获取用户在该成长路径的进度 progress_result = await db.execute( select(UserGrowthPathProgress).where( and_( UserGrowthPathProgress.user_id == user_id, UserGrowthPathProgress.growth_path_id == node.growth_path_id ) ) ) user_progress = progress_result.scalar_one_or_none() if not user_progress: continue # 更新节点完成记录 completion_result = await db.execute( select(UserNodeCompletion).where( and_( UserNodeCompletion.user_id == user_id, UserNodeCompletion.node_id == node.id ) ) ) completion = completion_result.scalar_one_or_none() if not completion: completion = UserNodeCompletion( user_id=user_id, growth_path_id=node.growth_path_id, node_id=node.id, status=NodeStatus.IN_PROGRESS.value, started_at=datetime.now(), ) db.add(completion) completion.course_progress = Decimal(str(progress)) # 如果进度达到100%,自动完成节点 if progress >= 100: await self.complete_node(db, user_id, node.id) await db.commit() # 全局实例 growth_path_service = GrowthPathService()