PMFlow/backend/app/services/workload_service.py

740 lines
23 KiB
Python
Raw Permalink 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 团队负载服务。"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from typing import Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompts import PROMPT_WORKLOAD_OPTIMIZATION
from ..models import AICallLog, Task, User, UserProject, UserSkillBinding, UserWorkload
from .llm import llm_service
LEVEL_HOURS_MULTIPLIER = {
"expert": 0.8,
"advanced": 0.9,
"intermediate": 1.0,
"beginner": 1.1,
}
def remaining_workdays_this_week(reference_date: Optional[date] = None) -> int:
"""计算本周剩余工作日(含当天)。"""
today = reference_date or date.today()
weekday = today.weekday() # Monday = 0
days_to_friday = max(0, 4 - weekday)
count = 0
for offset in range(days_to_friday + 1):
d = today + timedelta(days=offset)
if d.weekday() < 5:
count += 1
return max(count, 1)
def calculate_workload_ratio(
assigned_hours: float,
max_weekly_hours: float,
reference_date: Optional[date] = None,
) -> float:
"""
负载率公式(百分比):
ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week)
"""
remaining_days = remaining_workdays_this_week(reference_date)
denominator = (max_weekly_hours / 5.0) * remaining_days
if denominator <= 0:
return 0.0
return round((assigned_hours / denominator) * 100.0, 2)
async def ensure_user_workload(
db: AsyncSession,
tenant_id: int,
user_id: int,
) -> UserWorkload:
result = await db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id == user_id,
)
)
)
workload = result.scalar_one_or_none()
if workload:
return workload
workload = UserWorkload(
tenant_id=tenant_id,
user_id=user_id,
current_assigned_hours=0.0,
max_available_hours_per_week=40.0,
workload_ratio=0.0,
)
db.add(workload)
await db.flush()
return workload
async def recalculate_user_workload_ratio(
db: AsyncSession,
tenant_id: int,
user_id: int,
*,
commit: bool = True,
) -> Optional[UserWorkload]:
"""重算单用户负载率并同步 users.workload。"""
user_result = await db.execute(
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
)
user = user_result.scalar_one_or_none()
if not user:
return None
workload = await ensure_user_workload(db, tenant_id, user_id)
ratio = calculate_workload_ratio(
assigned_hours=float(workload.current_assigned_hours or 0),
max_weekly_hours=float(workload.max_available_hours_per_week or 40),
)
workload.workload_ratio = ratio
user.workload = int(max(0, min(200, ratio)))
user.updated_at = datetime.utcnow()
try:
from . import workload_alert_service
await workload_alert_service.on_user_workload_changed(
db,
tenant_id=tenant_id,
user_id=user_id,
workload_ratio=ratio,
)
except Exception:
pass
if commit:
await db.commit()
await db.refresh(workload)
return workload
async def recalculate_all_users_workload_ratio(db: AsyncSession, tenant_id: int) -> int:
"""批量重算租户内所有用户负载率。"""
result = await db.execute(select(User.id).where(User.tenant_id == tenant_id))
user_ids = [int(uid) for uid in result.scalars().all()]
for uid in user_ids:
await recalculate_user_workload_ratio(db, tenant_id, uid, commit=False)
await db.commit()
return len(user_ids)
async def list_tenant_ids(db: AsyncSession) -> list[int]:
"""获取当前系统存在用户数据的租户 ID 列表。"""
result = await db.execute(select(User.tenant_id).distinct())
return [int(tid) for tid in result.scalars().all() if tid is not None]
async def get_team_workload_board(
db: AsyncSession,
tenant_id: int = 1,
project_id: Optional[int] = None,
) -> list[dict]:
"""团队负载看板数据。"""
query = (
select(UserWorkload, User)
.join(User, UserWorkload.user_id == User.id)
.where(
and_(
UserWorkload.tenant_id == tenant_id,
User.tenant_id == tenant_id,
)
)
)
if project_id is not None:
query = query.join(
UserProject,
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == User.id,
),
).where(UserProject.project_id == project_id)
query = query.order_by(UserWorkload.workload_ratio.desc(), User.id.asc())
rows = (await db.execute(query)).all()
return [
{
"user_id": str(w.user_id),
"user_name": u.name,
"username": u.username,
"role": u.role,
"is_active": u.is_active,
"current_assigned_hours": float(w.current_assigned_hours or 0),
"max_weekly_hours": float(w.max_available_hours_per_week or 40),
"workload_ratio": float(w.workload_ratio or 0),
"remaining_hours": round(
max(
0.0,
float(w.max_available_hours_per_week or 40)
- float(w.current_assigned_hours or 0),
),
2,
),
"updated_at": w.updated_at.isoformat() if w.updated_at else None,
}
for w, u in rows
]
async def get_user_personal_workload(
db: AsyncSession,
tenant_id: int,
user_id: int,
) -> Optional[dict]:
"""个人负载详情。"""
row = (
await db.execute(
select(UserWorkload, User)
.join(User, UserWorkload.user_id == User.id)
.where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id == user_id,
User.tenant_id == tenant_id,
)
)
)
).first()
if row:
w, u = row
return {
"user_id": str(u.id),
"user_name": u.name,
"username": u.username,
"is_active": u.is_active,
"current_assigned_hours": float(w.current_assigned_hours or 0),
"max_weekly_hours": float(w.max_available_hours_per_week or 40),
"workload_ratio": float(w.workload_ratio or 0),
"remaining_hours": round(
max(
0.0,
float(w.max_available_hours_per_week or 40)
- float(w.current_assigned_hours or 0),
),
2,
),
"updated_at": w.updated_at.isoformat() if w.updated_at else None,
}
user = (
await db.execute(
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
)
).scalar_one_or_none()
if not user:
return None
workload = await ensure_user_workload(db, tenant_id, user_id)
await db.commit()
return {
"user_id": str(user.id),
"user_name": user.name,
"username": user.username,
"is_active": user.is_active,
"current_assigned_hours": float(workload.current_assigned_hours or 0),
"max_weekly_hours": float(workload.max_available_hours_per_week or 40),
"workload_ratio": float(workload.workload_ratio or 0),
"remaining_hours": round(
max(
0.0,
float(workload.max_available_hours_per_week or 40)
- float(workload.current_assigned_hours or 0),
),
2,
),
"updated_at": workload.updated_at.isoformat() if workload.updated_at else None,
}
async def update_user_max_weekly_hours(
db: AsyncSession,
tenant_id: int,
user_id: int,
max_weekly_hours: float,
) -> Optional[dict]:
"""更新每周可用工时并重算负载率。"""
user = (
await db.execute(
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
)
).scalar_one_or_none()
if not user:
return None
workload = await ensure_user_workload(db, tenant_id, user_id)
workload.max_available_hours_per_week = max(1.0, float(max_weekly_hours))
await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False)
await db.commit()
await db.refresh(workload)
return {
"user_id": str(user.id),
"max_weekly_hours": float(workload.max_available_hours_per_week),
"workload_ratio": float(workload.workload_ratio or 0),
}
async def list_user_assigned_tasks(
db: AsyncSession,
tenant_id: int,
user_id: int,
*,
status: Optional[str] = None,
priority: Optional[str] = None,
) -> list[dict]:
"""查询用户任务列表。"""
query = select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.assignee_id == user_id,
)
)
if status:
query = query.where(Task.status == status)
if priority:
query = query.where(Task.priority == priority)
rows = (await db.execute(query.order_by(Task.updated_at.desc()))).scalars().all()
return [
{
"id": str(t.id),
"title": t.title,
"project_id": str(t.project_id),
"priority": t.priority,
"status": t.status,
"estimated_hours": float(t.estimated_hours or 0),
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in rows
]
async def get_user_workload_trend(
db: AsyncSession,
tenant_id: int,
user_id: int,
days: int = 7,
) -> list[dict]:
"""
用户负载趋势。
当前无历史快照表时,返回最近 N 天的平滑序列用于前端趋势图展示。
"""
workload = (
await db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id == user_id,
)
)
)
).scalar_one_or_none()
ratio_now = float(workload.workload_ratio or 0) if workload else 0.0
days = max(1, min(days, 30))
start = date.today() - timedelta(days=days - 1)
series: list[dict] = []
for idx in range(days):
current_day = start + timedelta(days=idx)
# 缺少历史表时做线性平滑,保留可视化可用性。
ratio = round(max(0.0, ratio_now * (0.92 + 0.08 * (idx + 1) / days)), 2)
series.append(
{
"date": current_day.isoformat(),
"workload_ratio": ratio,
}
)
return series
async def refresh_user_assigned_hours_from_tasks(
db: AsyncSession,
tenant_id: int,
user_id: int,
) -> Optional[UserWorkload]:
"""从任务表汇总 assigned_hours 并重算 ratio。"""
workload = await ensure_user_workload(db, tenant_id, user_id)
task_rows = (
await db.execute(
select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.assignee_id == user_id,
func.lower(Task.status).notin_(["done", "completed", "closed"]),
)
)
)
).scalars().all()
skill_rows = (
await db.execute(
select(
UserSkillBinding.skill_tag_id,
UserSkillBinding.level,
UserSkillBinding.proficiency_level,
UserSkillBinding.proficiency,
).where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id == user_id,
)
)
)
).all()
def normalize_level(
level: Optional[str],
proficiency_level: Optional[str],
proficiency: Optional[int],
) -> str:
raw = (level or proficiency_level or "").strip().lower()
if raw in LEVEL_HOURS_MULTIPLIER:
return raw
score = int(proficiency or 0)
if score >= 85:
return "expert"
if score >= 70:
return "advanced"
if score >= 50:
return "intermediate"
return "beginner"
level_map: dict[int, str] = {
int(skill_tag_id): normalize_level(level, proficiency_level, proficiency)
for skill_tag_id, level, proficiency_level, proficiency in skill_rows
}
def effective_estimated_hours(task: Task) -> float:
base = float(task.estimated_hours or 0.0)
if base <= 0:
return 0.0
required_ids = _safe_int_list(task.required_skill_tag_ids)
if not required_ids:
return base
multipliers = [
LEVEL_HOURS_MULTIPLIER.get(level_map[sid], 1.0)
for sid in required_ids
if sid in level_map
]
if not multipliers:
return base
factor = sum(multipliers) / len(multipliers)
return round(base * factor, 2)
workload.current_assigned_hours = round(
sum(effective_estimated_hours(task) for task in task_rows),
2,
)
await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False)
await db.commit()
await db.refresh(workload)
return workload
def _skill_match_score(task_skill_ids: list[int], user_skill_ids: set[int]) -> float:
if not task_skill_ids:
return 0.0
matched = sum(1 for sid in task_skill_ids if sid in user_skill_ids)
return matched / len(task_skill_ids)
def _safe_int_list(values: object) -> list[int]:
"""兼容历史脏数据:数组中可能混入技能名称等非数字值。"""
source = values
if source is None:
return []
if isinstance(source, str):
text = source.strip()
if not text:
return []
try:
source = json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(source, list):
return []
parsed: list[int] = []
for item in source:
if item is None or isinstance(item, bool):
continue
if isinstance(item, int):
parsed.append(item)
continue
if isinstance(item, float):
if item.is_integer():
parsed.append(int(item))
continue
if isinstance(item, str):
text = item.strip()
if text and text.lstrip("-").isdigit():
parsed.append(int(text))
return parsed
def _parse_json_response(text: str) -> dict:
body = (text or "").strip()
if body.startswith("```json"):
body = body[7:].strip()
if body.startswith("```"):
body = body[3:].strip()
if body.endswith("```"):
body = body[:-3].strip()
return json.loads(body)
async def build_ai_optimize_preview(
db: AsyncSession,
tenant_id: int,
*,
project_id: Optional[int] = None,
max_moves: int = 20,
unassigned_only: bool = False,
) -> list[dict]:
"""生成任务重分配建议(预览,不落库)。"""
task_query = select(Task).where(
and_(
Task.tenant_id == tenant_id,
func.lower(Task.status).notin_(["done", "completed", "closed"]),
)
)
if unassigned_only:
task_query = task_query.where(Task.assignee_id.is_(None))
if project_id is not None:
task_query = task_query.where(Task.project_id == project_id)
task_rows = (await db.execute(task_query.order_by(Task.updated_at.desc()))).scalars().all()
if not task_rows:
return []
active_users_query = select(User).where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
users = (await db.execute(active_users_query)).scalars().all()
if not users:
return []
workload_rows = (
await db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id.in_([int(u.id) for u in users]),
)
)
)
).scalars().all()
workload_map = {int(w.user_id): float(w.workload_ratio or 0.0) for w in workload_rows}
skill_rows = (
await db.execute(
select(UserSkillBinding.user_id, UserSkillBinding.skill_tag_id).where(
UserSkillBinding.tenant_id == tenant_id
)
)
).all()
user_skill_map: dict[int, set[int]] = {}
for uid, sid in skill_rows:
user_skill_map.setdefault(int(uid), set()).add(int(sid))
users_by_project: dict[int, list[User]] = {}
for task in task_rows:
if int(task.project_id) in users_by_project:
continue
links = (
await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == int(task.project_id),
)
)
)
).scalars().all()
allowed_ids = {int(uid) for uid in links}
users_by_project[int(task.project_id)] = [u for u in users if int(u.id) in allowed_ids]
llm_suggestion_map: dict[int, dict] = {}
members_payload = []
for user in users:
uid = int(user.id)
members_payload.append(
{
"user_id": uid,
"user_name": user.name,
"workload_ratio": round(workload_map.get(uid, float(user.workload or 0)), 2),
"skill_tag_ids": sorted(list(user_skill_map.get(uid, set()))),
}
)
tasks_payload = [
{
"task_id": int(task.id),
"project_id": int(task.project_id),
"title": task.title,
"assignee_id": int(task.assignee_id) if task.assignee_id else None,
"required_skill_tag_ids": _safe_int_list(task.required_skill_tag_ids),
"estimated_hours": float(task.estimated_hours or 0),
"status": task.status,
}
for task in task_rows
]
llm_prompt = PROMPT_WORKLOAD_OPTIMIZATION.format(
members_json=json.dumps(members_payload, ensure_ascii=False),
tasks_json=json.dumps(tasks_payload, ensure_ascii=False),
)
llm_text = ""
llm_status = "success"
try:
llm_text = await llm_service.generate(
llm_prompt,
function_name="workload_optimize_prompt",
tenant_id=tenant_id,
)
parsed = _parse_json_response(llm_text)
for item in parsed.get("proposals", []) if isinstance(parsed, dict) else []:
try:
tid = int(item.get("task_id"))
llm_suggestion_map[tid] = {
"new_assignee_id": int(item.get("new_assignee_id")),
"reason": str(item.get("reason") or "llm_suggested"),
}
except (TypeError, ValueError):
continue
except Exception as exc:
llm_status = "failed"
llm_text = str(exc)
db.add(
AICallLog(
tenant_id=tenant_id,
user_id=None,
function_name="workload_optimize_prompt",
prompt=llm_prompt,
response=(llm_text or "")[:8000],
tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0),
cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000),
status=llm_status,
)
)
await db.commit()
proposals: list[dict] = []
for task in task_rows:
current_assignee = int(task.assignee_id or 0)
is_unassigned = current_assignee <= 0
candidates = users_by_project.get(int(task.project_id), [])
if not candidates:
continue
required_skill_ids = _safe_int_list(task.required_skill_tag_ids)
ranked = []
llm_target_id = llm_suggestion_map.get(int(task.id), {}).get("new_assignee_id")
llm_reason = llm_suggestion_map.get(int(task.id), {}).get("reason")
for candidate in candidates:
uid = int(candidate.id)
if not is_unassigned and uid == current_assignee:
continue
ratio = workload_map.get(uid, float(candidate.workload or 0))
skill_score = _skill_match_score(required_skill_ids, user_skill_map.get(uid, set()))
# workload 越低越优,skill_score 越高越优
total_score = (skill_score * 0.7) + ((max(0.0, 120.0 - ratio) / 120.0) * 0.3)
if llm_target_id and uid == int(llm_target_id):
total_score += 0.2
ranked.append((total_score, ratio, candidate))
ranked.sort(key=lambda x: (-x[0], x[1]))
if not ranked:
continue
best_score, _best_ratio, best_user = ranked[0]
current_ratio = workload_map.get(current_assignee, 0.0) if not is_unassigned else 0.0
if is_unassigned:
if best_score < 0.2:
continue
reason = f"initial_assignment_score={best_score:.2f}"
else:
if current_ratio <= 100 and best_score < 0.5:
continue
reason = f"skill_match+workload_score={best_score:.2f}"
if llm_target_id and int(best_user.id) == int(llm_target_id):
reason = f"{reason};llm={llm_reason or 'suggested'}"
proposals.append(
{
"task_id": int(task.id),
"task_title": task.title,
"project_id": int(task.project_id),
"old_assignee_id": current_assignee if not is_unassigned else None,
"new_assignee_id": int(best_user.id),
"old_ratio": round(current_ratio, 2) if not is_unassigned else None,
"new_ratio": round(workload_map.get(int(best_user.id), 0.0), 2),
"reason": reason,
}
)
if len(proposals) >= max_moves:
break
return proposals
async def apply_ai_optimize_plan(
db: AsyncSession,
tenant_id: int,
proposals: list[dict],
) -> dict:
"""应用优化方案并重算涉及成员负载。"""
touched_user_ids: set[int] = set()
applied = 0
for item in proposals:
task_id = int(item.get("task_id", 0))
new_assignee_id = int(item.get("new_assignee_id", 0))
if task_id <= 0 or new_assignee_id <= 0:
continue
task = (
await db.execute(
select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.id == task_id,
)
)
)
).scalar_one_or_none()
if not task:
continue
if task.assignee_id:
touched_user_ids.add(int(task.assignee_id))
task.assignee_id = new_assignee_id
touched_user_ids.add(new_assignee_id)
applied += 1
if touched_user_ids:
for uid in touched_user_ids:
await refresh_user_assigned_hours_from_tasks(db, tenant_id, uid)
else:
await db.commit()
return {"applied_count": applied, "affected_users": len(touched_user_ids)}