382 lines
12 KiB
Python
382 lines
12 KiB
Python
"""
|
||
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),
|
||
)
|