615 lines
29 KiB
Python
615 lines
29 KiB
Python
"""Adaptive human capacity planning for PMFlow Team Layer and Space."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, time, timedelta
|
||
from typing import Any, Optional
|
||
|
||
from sqlalchemy import and_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..models import DevWorkspaceBinding, HumanCapacityOverride, Project, User, UserProject, UserWorkload
|
||
from ..schemas.pmflow_os import (
|
||
PMFlowCapacityFit,
|
||
PMFlowCapacityOverridePayload,
|
||
PMFlowCapacityOverrideRecord,
|
||
PMFlowCapacityOverrideSimulation,
|
||
PMFlowDailyCapacityPlan,
|
||
PMFlowHumanTouchEstimate,
|
||
PMFlowHumanWorkType,
|
||
PMFlowMemberCapacityPlan,
|
||
PMFlowWorkTypeDistribution,
|
||
PMFlowWorkTypeMinutes,
|
||
)
|
||
from ..utils.timezone import now_cn_naive
|
||
from .pmflow_os_projection_service import PMFlowOSProjectionService
|
||
|
||
|
||
WORK_TYPES: tuple[PMFlowHumanWorkType, ...] = ("focus", "review", "acceptance", "coordination")
|
||
|
||
|
||
@dataclass
|
||
class _MemberContext:
|
||
user: User
|
||
tools: list[str]
|
||
workload_ratio: float
|
||
|
||
|
||
class AdaptiveCapacityService:
|
||
"""Computes daily capacity plans from current task/review/execution facts plus overrides."""
|
||
|
||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
|
||
async def get_plan(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
current_user_id: int,
|
||
plan_date: Optional[date] = None,
|
||
extra_override: Optional[PMFlowCapacityOverridePayload] = None,
|
||
) -> PMFlowDailyCapacityPlan:
|
||
plan_date = plan_date or now_cn_naive().date()
|
||
projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id)
|
||
truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id)
|
||
execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
members = await self._load_members(project_id=project_id)
|
||
project = await self._load_project(project_id=project_id)
|
||
member_ids = [int(ctx.user.id) for ctx in members]
|
||
overrides = await self._load_active_overrides(project_id=project_id, plan_date=plan_date, member_ids=member_ids)
|
||
if extra_override is not None:
|
||
overrides[int(extra_override.userId)] = self._payload_to_record(extra_override, plan_date=plan_date, created_by=current_user_id)
|
||
|
||
touches = self._build_touches(
|
||
project_id=project_id,
|
||
owner_id=int(project.owner_id or current_user_id) if project else current_user_id,
|
||
member_ids=member_ids,
|
||
truth_state=truth_state,
|
||
execution_summary=execution_summary,
|
||
)
|
||
team_minutes = self._sum_minutes(touches)
|
||
team_mix = self._distribution(team_minutes)
|
||
plans = [
|
||
self._member_plan(
|
||
ctx=ctx,
|
||
project_id=project_id,
|
||
team_mix=team_mix,
|
||
touches=[touch for touch in touches if str(touch.assignedUserId or "") == str(ctx.user.id)],
|
||
override=overrides.get(int(ctx.user.id)),
|
||
)
|
||
for ctx in members
|
||
]
|
||
team_effective = sum(item.effectiveMinutes for item in plans)
|
||
warnings = list(execution_summary.warnings[:6])
|
||
if not members:
|
||
warnings.append("capacity_members_empty: 当前项目没有可计算人类容量的成员。")
|
||
if not touches:
|
||
warnings.append("capacity_touches_empty: 当前没有真实人工触点,容量计划保持空态。")
|
||
recommendations = self._recommendations(touches=touches, plans=plans)
|
||
return PMFlowDailyCapacityPlan(
|
||
projectId=str(project_id),
|
||
date=plan_date.isoformat(),
|
||
generatedAt=now_cn_naive().isoformat(),
|
||
teamDemandMix=team_mix,
|
||
teamEffectiveMinutes=team_effective,
|
||
teamPlannedTouchMinutes=team_minutes,
|
||
memberPlans=plans,
|
||
humanTouches=touches[:80],
|
||
warnings=warnings[:12],
|
||
recommendations=recommendations[:8],
|
||
)
|
||
|
||
async def simulate_override(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
current_user_id: int,
|
||
payload: PMFlowCapacityOverridePayload,
|
||
) -> PMFlowCapacityOverrideSimulation:
|
||
plan_date = self._parse_date(payload.date)
|
||
before = await self.get_plan(project_id=project_id, current_user_id=current_user_id, plan_date=plan_date)
|
||
after = await self.get_plan(
|
||
project_id=project_id,
|
||
current_user_id=current_user_id,
|
||
plan_date=plan_date,
|
||
extra_override=payload,
|
||
)
|
||
before_member = self._find_member_plan(before, payload.userId)
|
||
after_member = self._find_member_plan(after, payload.userId)
|
||
impact = [
|
||
f"有效容量 {before_member.effectiveMinutes}min -> {after_member.effectiveMinutes}min",
|
||
f"剩余可计划容量 {before_member.remainingMinutes}min -> {after_member.remainingMinutes}min",
|
||
f"状态 {before_member.capacityStatus} -> {after_member.capacityStatus}",
|
||
]
|
||
if payload.addedMinutes:
|
||
impact.append("加班只增加时间预算,不消除疲劳与高风险判断密度。")
|
||
return PMFlowCapacityOverrideSimulation(beforePlan=before_member, afterPlan=after_member, impactSummary=impact, warnings=after.warnings[:6])
|
||
|
||
async def apply_override(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
current_user_id: int,
|
||
payload: PMFlowCapacityOverridePayload,
|
||
) -> PMFlowCapacityOverrideRecord:
|
||
plan_date = self._parse_date(payload.date)
|
||
expires_at = self._parse_expires(payload.expiresAt, plan_date=plan_date)
|
||
record = HumanCapacityOverride(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project_id),
|
||
user_id=int(payload.userId),
|
||
date=plan_date,
|
||
override_type=payload.overrideType,
|
||
added_minutes=int(payload.addedMinutes or 0),
|
||
overtime_type=payload.overtimeType,
|
||
max_planned_utilization=payload.maxPlannedUtilization,
|
||
work_type_bias=dict(payload.workTypeBias or {}),
|
||
high_risk_limit=payload.highRiskLimit,
|
||
reason=payload.reason.strip(),
|
||
expires_at=expires_at,
|
||
created_by=int(current_user_id),
|
||
)
|
||
self.db.add(record)
|
||
await self.db.commit()
|
||
await self.db.refresh(record)
|
||
return self._override_record(record)
|
||
|
||
async def list_overrides(self, *, project_id: int, plan_date: Optional[date] = None) -> list[PMFlowCapacityOverrideRecord]:
|
||
plan_date = plan_date or now_cn_naive().date()
|
||
rows = (
|
||
await self.db.execute(
|
||
select(HumanCapacityOverride)
|
||
.where(
|
||
and_(
|
||
HumanCapacityOverride.tenant_id == self.tenant_id,
|
||
HumanCapacityOverride.project_id == int(project_id),
|
||
HumanCapacityOverride.date == plan_date,
|
||
)
|
||
)
|
||
.order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc())
|
||
)
|
||
).scalars().all()
|
||
return [self._override_record(row) for row in rows]
|
||
|
||
async def _load_project(self, *, project_id: int) -> Optional[Project]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == int(project_id)))
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_members(self, *, project_id: int) -> list[_MemberContext]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(User)
|
||
.join(UserProject, UserProject.user_id == User.id)
|
||
.where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.project_id == int(project_id),
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
.order_by(User.id.asc())
|
||
)
|
||
).scalars().all()
|
||
member_ids = [int(user.id) for user in rows]
|
||
tool_map = await self._load_tools(project_id=project_id, member_ids=member_ids)
|
||
workload_map = await self._load_workloads(member_ids=member_ids)
|
||
return [
|
||
_MemberContext(user=user, tools=tool_map.get(int(user.id), []), workload_ratio=workload_map.get(int(user.id), 0.0))
|
||
for user in rows
|
||
]
|
||
|
||
async def _load_tools(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]:
|
||
if not member_ids:
|
||
return {}
|
||
rows = (
|
||
await self.db.execute(
|
||
select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where(
|
||
and_(
|
||
DevWorkspaceBinding.tenant_id == self.tenant_id,
|
||
DevWorkspaceBinding.project_id == int(project_id),
|
||
DevWorkspaceBinding.user_id.in_(member_ids),
|
||
DevWorkspaceBinding.status == "active",
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
labels = {"cursor": "Cursor", "vscode": "VS Code", "codex": "Codex"}
|
||
tool_map: dict[int, list[str]] = {}
|
||
for user_id, editor_type in rows:
|
||
tool_map.setdefault(int(user_id), [])
|
||
label = labels.get(str(editor_type or "").lower(), str(editor_type or "Local Bridge"))
|
||
if label not in tool_map[int(user_id)]:
|
||
tool_map[int(user_id)].append(label)
|
||
return tool_map
|
||
|
||
async def _load_workloads(self, *, member_ids: list[int]) -> dict[int, float]:
|
||
if not member_ids:
|
||
return {}
|
||
rows = (
|
||
await self.db.execute(
|
||
select(UserWorkload.user_id, UserWorkload.workload_ratio).where(
|
||
and_(UserWorkload.tenant_id == self.tenant_id, UserWorkload.user_id.in_(member_ids))
|
||
)
|
||
)
|
||
).all()
|
||
return {int(user_id): float(ratio or 0.0) for user_id, ratio in rows}
|
||
|
||
async def _load_active_overrides(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
plan_date: date,
|
||
member_ids: list[int],
|
||
) -> dict[int, PMFlowCapacityOverrideRecord]:
|
||
if not member_ids:
|
||
return {}
|
||
rows = (
|
||
await self.db.execute(
|
||
select(HumanCapacityOverride)
|
||
.where(
|
||
and_(
|
||
HumanCapacityOverride.tenant_id == self.tenant_id,
|
||
HumanCapacityOverride.project_id == int(project_id),
|
||
HumanCapacityOverride.user_id.in_(member_ids),
|
||
HumanCapacityOverride.date == plan_date,
|
||
HumanCapacityOverride.expires_at >= now_cn_naive(),
|
||
)
|
||
)
|
||
.order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc())
|
||
)
|
||
).scalars().all()
|
||
overrides: dict[int, PMFlowCapacityOverrideRecord] = {}
|
||
for row in rows:
|
||
overrides.setdefault(int(row.user_id), self._override_record(row))
|
||
return overrides
|
||
|
||
def _build_touches(self, *, project_id: int, owner_id: int, member_ids: list[int], truth_state: Any, execution_summary: Any) -> list[PMFlowHumanTouchEstimate]:
|
||
touches: list[PMFlowHumanTouchEstimate] = []
|
||
fallback_user = str(owner_id if owner_id in member_ids else (member_ids[0] if member_ids else owner_id))
|
||
for item in getattr(truth_state, "changeItems", []) or []:
|
||
if self._get(item, "status") != "pending_review":
|
||
continue
|
||
risk = self._risk(self._get(item, "riskLevel") or self._get(item, "risk_level"))
|
||
minutes = 60 if risk == "high" else 35 if risk == "medium" else 20
|
||
touches.append(
|
||
PMFlowHumanTouchEstimate(
|
||
sourceType="changeset_confirmation",
|
||
sourceId=str(self._get(item, "id") or f"change-{len(touches)}"),
|
||
targetHref=f"/change-review?projectId={project_id}&changeSetId={self._get(item, 'changeSetId') or ''}",
|
||
workType="focus",
|
||
estimatedMinutes=minutes,
|
||
riskLevel=risk,
|
||
urgency="urgent" if risk == "high" else "normal",
|
||
bindingType="hard_bound",
|
||
assignedUserId=fallback_user,
|
||
roleBinding="OWNER",
|
||
contextReadiness="partial",
|
||
aiReductionPotentialMinutes=25 if risk != "low" else 10,
|
||
title=str(self._get(item, "title") or "待确认 ChangeSet"),
|
||
summary=str(self._get(item, "summary") or "需要人类确认 AI 提取结果。"),
|
||
)
|
||
)
|
||
for routing in getattr(getattr(truth_state, "reviewerRouting", None), "items", []) or []:
|
||
reviewer = getattr(routing, "suggestedReviewer", None)
|
||
assigned = str(getattr(reviewer, "userId", "") or fallback_user)
|
||
risk = self._risk(getattr(routing, "riskLevel", "low"))
|
||
touches.append(
|
||
PMFlowHumanTouchEstimate(
|
||
sourceType="review",
|
||
sourceId=str(getattr(routing, "changeSetId", "")),
|
||
targetHref=str(getattr(routing, "targetUrl", "") or f"/change-review?projectId={project_id}"),
|
||
workType="review",
|
||
estimatedMinutes=75 if risk == "high" else 45 if risk == "medium" else 30,
|
||
riskLevel=risk,
|
||
urgency=str(getattr(routing, "priority", "normal")),
|
||
bindingType="role_bound",
|
||
assignedUserId=assigned,
|
||
roleBinding=str(getattr(routing, "fallbackReviewerRole", None) or "REVIEWER"),
|
||
contextReadiness="partial",
|
||
aiReductionPotentialMinutes=30 if risk == "high" else 15,
|
||
title=str(getattr(routing, "changeSetTitle", "待审 ChangeSet")),
|
||
summary=str(getattr(routing, "reviewReason", "需要人工审查 ChangeSet。")),
|
||
)
|
||
)
|
||
for idx, event in enumerate(execution_summary.recentProgressEvents or []):
|
||
event_type = str(event.get("event_type") or "")
|
||
if event_type not in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed", "failed", "blocked"}:
|
||
continue
|
||
work_type: PMFlowHumanWorkType = "coordination" if event_type in {"failed", "blocked"} else "acceptance"
|
||
touches.append(
|
||
PMFlowHumanTouchEstimate(
|
||
sourceType="ai_failure_recovery" if work_type == "coordination" else "acceptance",
|
||
sourceId=str(event.get("id") or f"event-{idx}"),
|
||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||
workType=work_type,
|
||
estimatedMinutes=45 if work_type == "coordination" else 30,
|
||
riskLevel="medium" if work_type == "coordination" else "low",
|
||
urgency="elevated" if work_type == "coordination" else "normal",
|
||
bindingType="ai_shiftable",
|
||
assignedUserId=str(event.get("user_id") or fallback_user),
|
||
roleBinding="BUILDER",
|
||
contextReadiness="partial",
|
||
aiReductionPotentialMinutes=20,
|
||
title=str(event.get("summary") or "AI 执行结果待处理"),
|
||
summary="AI 执行结果需要验收或阻塞恢复。",
|
||
)
|
||
)
|
||
if not execution_summary.repoBindings:
|
||
touches.append(self._coordination_touch(project_id, fallback_user, "缺少 repo 绑定", "未接入 repo,AI 执行包缺少真实代码上下文。", 30))
|
||
if not execution_summary.promptPackage:
|
||
touches.append(self._coordination_touch(project_id, fallback_user, "缺少执行包", "尚未生成可交给 Cursor / VS Code / Codex 的执行包。", 20))
|
||
return touches
|
||
|
||
def _coordination_touch(self, project_id: int, assigned_user_id: str, title: str, summary: str, minutes: int) -> PMFlowHumanTouchEstimate:
|
||
return PMFlowHumanTouchEstimate(
|
||
sourceType="context_completion",
|
||
sourceId=title,
|
||
targetHref=f"/execution-bridge?projectId={project_id}",
|
||
workType="coordination",
|
||
estimatedMinutes=minutes,
|
||
riskLevel="medium",
|
||
urgency="normal",
|
||
bindingType="ai_shiftable",
|
||
assignedUserId=assigned_user_id,
|
||
roleBinding="BUILDER",
|
||
contextReadiness="blocked",
|
||
aiReductionPotentialMinutes=10,
|
||
title=title,
|
||
summary=summary,
|
||
)
|
||
|
||
def _member_plan(
|
||
self,
|
||
*,
|
||
ctx: _MemberContext,
|
||
project_id: int,
|
||
team_mix: PMFlowWorkTypeDistribution,
|
||
touches: list[PMFlowHumanTouchEstimate],
|
||
override: Optional[PMFlowCapacityOverrideRecord],
|
||
) -> PMFlowMemberCapacityPlan:
|
||
base_minutes = 480
|
||
added = int(override.addedMinutes if override else 0)
|
||
effective = base_minutes + added
|
||
max_util = float(override.maxPlannedUtilization if override and override.maxPlannedUtilization else 0.85)
|
||
planned = self._sum_minutes(touches)
|
||
current_mix = self._distribution(planned)
|
||
baseline = self._suggested_baseline(role=str(ctx.user.role or ""), team_mix=team_mix, planned=planned, override=override)
|
||
utilization = planned.total / max(effective, 1)
|
||
capacity_status = self._capacity_status(utilization=utilization, override=override, tools=ctx.tools)
|
||
remaining = round(effective * max_util - planned.total)
|
||
bottleneck = self._bottleneck(planned)
|
||
explanations = self._explanations(ctx=ctx, planned=planned, baseline=baseline, override=override, touches=touches)
|
||
return PMFlowMemberCapacityPlan(
|
||
userId=str(ctx.user.id),
|
||
displayName=str(ctx.user.name or ctx.user.username),
|
||
role=str(ctx.user.role or "MEMBER"),
|
||
baseAvailableMinutes=base_minutes,
|
||
calendarBlockedMinutes=0,
|
||
effectiveMinutes=effective,
|
||
reservedBufferMinutes=round(effective * max(0.0, 1 - max_util)),
|
||
maxPlannedUtilization=max_util,
|
||
suggestedBaseline=baseline,
|
||
currentMix=current_mix,
|
||
plannedTouchMinutes=planned,
|
||
capacityStatus=capacity_status,
|
||
remainingMinutes=remaining,
|
||
bottleneckType=bottleneck,
|
||
fatigueRisk=self._fatigue_risk(planned=planned, effective=effective),
|
||
riskDensity={
|
||
"high": len([touch for touch in touches if touch.riskLevel == "high"]),
|
||
"medium": len([touch for touch in touches if touch.riskLevel == "medium"]),
|
||
"total": len(touches),
|
||
},
|
||
canTakeMore=self._capacity_fit(project_id=project_id, planned=planned, effective=effective, max_util=max_util),
|
||
manualOverride=override,
|
||
explanations=explanations,
|
||
)
|
||
|
||
def _suggested_baseline(
|
||
self,
|
||
*,
|
||
role: str,
|
||
team_mix: PMFlowWorkTypeDistribution,
|
||
planned: PMFlowWorkTypeMinutes,
|
||
override: Optional[PMFlowCapacityOverrideRecord],
|
||
) -> PMFlowWorkTypeDistribution:
|
||
assigned_mix = self._distribution(planned) if planned.total else None
|
||
base = {key: getattr(team_mix, key) for key in WORK_TYPES}
|
||
if assigned_mix and planned.total:
|
||
base = {key: (getattr(assigned_mix, key) * 0.7) + (base[key] * 0.3) for key in WORK_TYPES}
|
||
elif not any(base.values()):
|
||
role_token = role.upper()
|
||
if "OWNER" in role_token:
|
||
base = {"focus": 0.5, "review": 0.15, "acceptance": 0.2, "coordination": 0.15}
|
||
elif "REVIEWER" in role_token:
|
||
base = {"focus": 0.2, "review": 0.5, "acceptance": 0.2, "coordination": 0.1}
|
||
elif "OPERATOR" in role_token:
|
||
base = {"focus": 0.15, "review": 0.1, "acceptance": 0.25, "coordination": 0.5}
|
||
else:
|
||
base = {"focus": 0.25, "review": 0.2, "acceptance": 0.35, "coordination": 0.2}
|
||
if override and override.workTypeBias:
|
||
for key in WORK_TYPES:
|
||
if key in override.workTypeBias:
|
||
base[key] = max(0.0, float(override.workTypeBias[key]))
|
||
return self._normalize_distribution(base)
|
||
|
||
def _capacity_fit(self, *, project_id: int, planned: PMFlowWorkTypeMinutes, effective: int, max_util: float) -> list[PMFlowCapacityFit]:
|
||
candidates: list[tuple[PMFlowHumanWorkType, str, int]] = [
|
||
("focus", "低/中风险判断", 45),
|
||
("review", "普通审查", 45),
|
||
("acceptance", "AI 结果验收", 30),
|
||
("coordination", "补上下文/工具阻塞", 25),
|
||
]
|
||
result: list[PMFlowCapacityFit] = []
|
||
for work_type, label, minutes in candidates:
|
||
next_total = planned.total + minutes
|
||
next_type = getattr(planned, work_type) + minutes
|
||
total_util = next_total / max(effective, 1)
|
||
type_share = next_type / max(effective, 1)
|
||
status = "recommended"
|
||
reason = "加入后仍处于可计划容量内。"
|
||
if total_util > max_util * 1.1:
|
||
status, reason = "not_recommended", "加入后超过今日建议计划容量。"
|
||
elif total_util > max_util or type_share > self._soft_share(work_type):
|
||
status, reason = "allowed_with_warning", "加入后接近容量或该类型疲劳风险上升。"
|
||
result.append(
|
||
PMFlowCapacityFit(
|
||
workType=work_type,
|
||
label=label,
|
||
status=status,
|
||
estimatedMinutes=minutes,
|
||
afterTotalUtilization=round(total_util, 3),
|
||
afterTypeShare=round(type_share, 3),
|
||
reason=reason,
|
||
)
|
||
)
|
||
return result
|
||
|
||
def _recommendations(self, *, touches: list[PMFlowHumanTouchEstimate], plans: list[PMFlowMemberCapacityPlan]) -> list[str]:
|
||
reduction = sum(max(0, touch.aiReductionPotentialMinutes) for touch in touches)
|
||
recommendations: list[str] = []
|
||
if reduction:
|
||
recommendations.append(f"建议先让 AI 预处理可转移触点,预计释放 {reduction} 分钟人工判断/审查容量。")
|
||
overloaded = [plan.displayName for plan in plans if plan.capacityStatus in {"near_full", "overloaded"}]
|
||
if overloaded:
|
||
recommendations.append(f"{'、'.join(overloaded[:3])} 接近或超过今日建议容量,新增任务前需要模拟。")
|
||
if not recommendations:
|
||
recommendations.append("当前容量计划未发现明显瓶颈,可继续按执行能力图谱推进。")
|
||
return recommendations
|
||
|
||
def _sum_minutes(self, touches: list[PMFlowHumanTouchEstimate]) -> PMFlowWorkTypeMinutes:
|
||
totals = {key: 0 for key in WORK_TYPES}
|
||
for touch in touches:
|
||
totals[touch.workType] += int(touch.estimatedMinutes or 0)
|
||
return PMFlowWorkTypeMinutes(**totals, total=sum(totals.values()))
|
||
|
||
def _distribution(self, minutes: PMFlowWorkTypeMinutes) -> PMFlowWorkTypeDistribution:
|
||
total = max(minutes.total, 1)
|
||
return PMFlowWorkTypeDistribution(**{key: round(getattr(minutes, key) / total, 3) for key in WORK_TYPES})
|
||
|
||
def _normalize_distribution(self, values: dict[str, float]) -> PMFlowWorkTypeDistribution:
|
||
total = sum(max(0.0, float(values.get(key, 0.0))) for key in WORK_TYPES) or 1.0
|
||
return PMFlowWorkTypeDistribution(**{key: round(max(0.0, float(values.get(key, 0.0))) / total, 3) for key in WORK_TYPES})
|
||
|
||
def _capacity_status(self, *, utilization: float, override: Optional[PMFlowCapacityOverrideRecord], tools: list[str]) -> str:
|
||
if override and override.overrideType == "lock_capacity":
|
||
return "blocked"
|
||
if utilization > 1.1:
|
||
return "overloaded"
|
||
if override and override.addedMinutes > 0:
|
||
return "manually_extended"
|
||
if utilization >= 0.9:
|
||
return "near_full"
|
||
if utilization >= 0.7:
|
||
return "attention"
|
||
return "normal"
|
||
|
||
def _fatigue_risk(self, *, planned: PMFlowWorkTypeMinutes, effective: int) -> dict[str, str]:
|
||
risks: dict[str, str] = {}
|
||
for key in WORK_TYPES:
|
||
share = getattr(planned, key) / max(effective, 1)
|
||
if share > self._hard_share(key):
|
||
risks[key] = "high"
|
||
elif share > self._soft_share(key):
|
||
risks[key] = "medium"
|
||
else:
|
||
risks[key] = "low"
|
||
return risks
|
||
|
||
def _soft_share(self, work_type: str) -> float:
|
||
return {"focus": 0.7, "review": 0.65, "acceptance": 0.75, "coordination": 0.6}.get(work_type, 0.7)
|
||
|
||
def _hard_share(self, work_type: str) -> float:
|
||
return {"focus": 0.85, "review": 0.8, "acceptance": 0.9, "coordination": 0.75}.get(work_type, 0.85)
|
||
|
||
def _bottleneck(self, minutes: PMFlowWorkTypeMinutes) -> Optional[str]:
|
||
values = {key: getattr(minutes, key) for key in WORK_TYPES}
|
||
if not any(values.values()):
|
||
return None
|
||
return max(values.items(), key=lambda item: item[1])[0]
|
||
|
||
def _explanations(
|
||
self,
|
||
*,
|
||
ctx: _MemberContext,
|
||
planned: PMFlowWorkTypeMinutes,
|
||
baseline: PMFlowWorkTypeDistribution,
|
||
override: Optional[PMFlowCapacityOverrideRecord],
|
||
touches: list[PMFlowHumanTouchEstimate],
|
||
) -> list[str]:
|
||
explanations = [
|
||
f"今日动态基线来自当前任务池:深度判断 {round(baseline.focus * 100)}%,审查 {round(baseline.review * 100)}%,验收 {round(baseline.acceptance * 100)}%,协调 {round(baseline.coordination * 100)}%。",
|
||
f"当前已计划人工触点 {planned.total} 分钟,包含 {len(touches)} 个真实触点。",
|
||
]
|
||
if override:
|
||
explanations.append(f"负责人已手工调整:{override.reason}")
|
||
if not ctx.tools:
|
||
explanations.append("未检测到活跃 Cursor / VS Code / Codex 工具连接,工具准备度会影响可承接建议。")
|
||
return explanations[:4]
|
||
|
||
def _payload_to_record(self, payload: PMFlowCapacityOverridePayload, *, plan_date: date, created_by: int) -> PMFlowCapacityOverrideRecord:
|
||
return PMFlowCapacityOverrideRecord(
|
||
id="simulated",
|
||
userId=str(payload.userId),
|
||
date=plan_date.isoformat(),
|
||
overrideType=payload.overrideType,
|
||
addedMinutes=int(payload.addedMinutes or 0),
|
||
overtimeType=payload.overtimeType,
|
||
maxPlannedUtilization=payload.maxPlannedUtilization,
|
||
workTypeBias=dict(payload.workTypeBias or {}),
|
||
highRiskLimit=payload.highRiskLimit,
|
||
reason=payload.reason,
|
||
expiresAt=self._parse_expires(payload.expiresAt, plan_date=plan_date).isoformat(),
|
||
createdBy=str(created_by),
|
||
createdAt=None,
|
||
)
|
||
|
||
def _override_record(self, row: HumanCapacityOverride) -> PMFlowCapacityOverrideRecord:
|
||
return PMFlowCapacityOverrideRecord(
|
||
id=str(row.id),
|
||
userId=str(row.user_id),
|
||
date=row.date.isoformat(),
|
||
overrideType=row.override_type,
|
||
addedMinutes=int(row.added_minutes or 0),
|
||
overtimeType=row.overtime_type,
|
||
maxPlannedUtilization=row.max_planned_utilization,
|
||
workTypeBias=dict(row.work_type_bias or {}),
|
||
highRiskLimit=row.high_risk_limit,
|
||
reason=row.reason,
|
||
expiresAt=row.expires_at.isoformat(),
|
||
createdBy=str(row.created_by),
|
||
createdAt=row.created_at.isoformat() if row.created_at else None,
|
||
)
|
||
|
||
def _find_member_plan(self, plan: PMFlowDailyCapacityPlan, user_id: str) -> PMFlowMemberCapacityPlan:
|
||
for item in plan.memberPlans:
|
||
if str(item.userId) == str(user_id):
|
||
return item
|
||
raise ValueError("capacity_member_not_found")
|
||
|
||
def _parse_date(self, value: Optional[str]) -> date:
|
||
if not value:
|
||
return now_cn_naive().date()
|
||
return date.fromisoformat(str(value)[:10])
|
||
|
||
def _parse_expires(self, value: Optional[str], *, plan_date: date) -> datetime:
|
||
if value:
|
||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
return parsed.replace(tzinfo=None)
|
||
return datetime.combine(plan_date, time(23, 59, 59))
|
||
|
||
def _risk(self, value: Any) -> str:
|
||
token = str(value or "low").lower()
|
||
return token if token in {"low", "medium", "high"} else "low"
|
||
|
||
def _get(self, obj: Any, key: str) -> Any:
|
||
if isinstance(obj, dict):
|
||
return obj.get(key)
|
||
return getattr(obj, key, None)
|