PMFlow/backend/app/api/endpoints/skill.py

382 lines
12 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.

"""
PMFlow 人员能力图谱 API 端点
- 技能标签 CRUD
- 用户技能绑定
- 团队负载看板
- 用户个人负载查询
- 人员推荐
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...dependencies import get_current_user, require_roles, get_tenant_id
from ...models import User
from ...schemas.skill import (
SkillTagCreate,
SkillTagUpdate,
SkillTagResponse,
UserSkillBindingCreate,
UserSkillBindingUpdate,
UserSkillBindingBatchUpdate,
UserSkillBindingResponse,
UserWorkloadResponse,
PersonnelRecommendationResponse,
PersonnelRecommendationItem,
SkillTaskHistoryResponse,
SkillTaskHistoryItem,
)
from ...services import skill_service
from ...services import workload_service
from ...services.auth import has_any_role
router = APIRouter(prefix="/skills", tags=["人员能力图谱"])
def _skill_tag_to_response(tag) -> SkillTagResponse:
"""将 SkillTag ORM 转为响应模型"""
return SkillTagResponse(
id=str(tag.id),
tenant_id=tag.tenant_id,
name=tag.name,
slug=tag.slug,
category=tag.category,
description=tag.description,
is_system=tag.is_system,
sort_order=tag.sort_order,
created_at=tag.created_at.isoformat() if tag.created_at else None,
updated_at=tag.updated_at.isoformat() if tag.updated_at else None,
)
# ============================================================================
# 技能标签 CRUD
# ============================================================================
@router.get("/tags", response_model=List[SkillTagResponse])
async def list_skill_tags(
category: Optional[str] = Query(None, description="按分类过滤"),
include_system: bool = Query(True, description="是否包含系统标签"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
列出技能标签
支持按分类过滤,系统标签可选包含。
"""
tags = await skill_service.list_skill_tags(
db, tenant_id=tenant_id, category=category, include_system=include_system
)
return [_skill_tag_to_response(t) for t in tags]
@router.post("/tags", response_model=SkillTagResponse)
async def create_skill_tag(
body: SkillTagCreate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
创建技能标签
仅 BOSS、ADMIN 可创建。
"""
try:
tag = await skill_service.create_skill_tag(
db,
tenant_id=tenant_id,
name=body.name,
slug=body.slug,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
return _skill_tag_to_response(tag)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/tags/{tag_id}", response_model=SkillTagResponse)
async def get_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取单个技能标签"""
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return _skill_tag_to_response(tag)
@router.patch("/tags/{tag_id}", response_model=SkillTagResponse)
async def update_skill_tag(
tag_id: UUID,
body: SkillTagUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
更新技能标签
系统预设标签不可修改。
"""
try:
tag = await skill_service.update_skill_tag(
db,
tag_id=tag_id,
tenant_id=tenant_id,
name=body.name,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return _skill_tag_to_response(tag)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/tags/{tag_id}")
async def delete_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
删除技能标签
系统预设标签不可删除。
"""
try:
ok = await skill_service.delete_skill_tag(db, tag_id, tenant_id)
if not ok:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return {"message": "已删除"}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================================================
# 用户技能绑定
# ============================================================================
@router.get("/users/{user_id}/bindings", response_model=List[UserSkillBindingResponse])
async def get_user_skill_bindings(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取用户的技能绑定列表"""
bindings = await skill_service.get_user_skill_bindings(db, tenant_id, user_id)
return [
UserSkillBindingResponse(
id=str(b.id),
user_id=str(b.user_id),
skill_tag_id=str(b.skill_tag_id),
skill_name=b.skill_tag.name if b.skill_tag else "",
proficiency=b.proficiency or 50,
level=b.level or b.proficiency_level or "intermediate",
years_experience=b.years_experience,
quality_score=getattr(b, "quality_score", None),
efficiency_score=getattr(b, "efficiency_score", None),
last_used_at=b.last_used_at.isoformat() if b.last_used_at else None,
created_at=b.created_at.isoformat() if b.created_at else None,
updated_at=b.updated_at.isoformat() if b.updated_at else None,
)
for b in bindings
]
@router.put("/users/{user_id}/bindings/batch")
async def batch_update_user_skill_bindings(
user_id: int,
body: UserSkillBindingBatchUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
批量更新用户技能绑定
请求体 bindings 每项格式: { "skill_tag_id": "uuid", "proficiency": 80, "level": "advanced", "years_experience": 3 }
使用路径中的 user_id,body.user_id 可省略。
"""
bindings = await skill_service.batch_update_user_skill_bindings(
db, tenant_id, user_id, body.bindings
)
return {"updated_count": len(bindings), "message": "已更新"}
@router.get("/tags/{tag_id}/users")
async def get_skill_tag_users(
tag_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""按技能标签查询拥有该技能的用户"""
bindings = await skill_service.get_skill_tag_user_bindings(db, tenant_id, tag_id)
return [
{
"user_id": str(b.user_id),
"user_name": b.user.name if b.user else "",
"proficiency": b.proficiency,
"level": b.level,
}
for b in bindings
]
@router.get("/users/{user_id}/tasks", response_model=SkillTaskHistoryResponse)
async def get_user_skill_task_history(
user_id: int,
skill_id: int = Query(..., description="技能标签 ID"),
limit: int = Query(50, ge=1, le=200, description="返回条数上限"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
查询用户某技能下的历史任务。
权限:
- 用户可查询自己
- BOSS / ADMIN 可查询任意用户
"""
from enum import Enum
current_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅可查询自己的技能历史任务",
)
items = await skill_service.get_user_skill_task_history(
db=db,
tenant_id=tenant_id,
user_id=user_id,
skill_tag_id=skill_id,
limit=limit,
)
return SkillTaskHistoryResponse(
items=[SkillTaskHistoryItem(**item) for item in items],
total=len(items),
)
# ============================================================================
# 团队负载看板
# ============================================================================
@router.get("/workload/team")
async def get_team_workload_board(
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取团队负载看板数据
返回当前周期内所有用户的工作量汇总。
"""
data = await workload_service.get_team_workload_board(db, tenant_id)
return {"items": data, "total": len(data)}
# ============================================================================
# 用户个人负载
# ============================================================================
@router.get("/workload/users/{user_id}", response_model=dict)
async def get_user_personal_workload(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取用户个人负载详情
用户只能查询自己的负载,BOSS/ADMIN 可查询任意用户。
"""
from enum import Enum
current_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅可查询自己的负载",
)
data = await workload_service.get_user_personal_workload(db, tenant_id, user_id)
if not data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="无负载数据")
return data
# ============================================================================
# 人员推荐
# ============================================================================
@router.get("/recommend", response_model=PersonnelRecommendationResponse)
async def recommend_personnel(
skills: str = Query(..., description="所需技能,逗号分隔,如: python,react,fastapi"),
task_description: Optional[str] = Query(None),
task_id: Optional[str] = Query(None),
limit: int = Query(5, ge=1, le=20),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
根据所需技能推荐匹配人员
返回匹配度评分、推荐理由等。
"""
required_skills = [s.strip() for s in skills.split(",") if s.strip()]
result = await skill_service.recommend_personnel(
db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task_description,
task_id=task_id,
limit=limit,
)
return PersonnelRecommendationResponse(
task_id=result.get("task_id"),
task_description=result.get("task_description"),
required_skills=result.get("required_skills", []),
recommendations=[
PersonnelRecommendationItem(**r) for r in result.get("recommendations", [])
],
total_count=result.get("total_count", 0),
)