643 lines
24 KiB
Python
643 lines
24 KiB
Python
"""SSTC 看板聚合服务"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Iterable
|
|
|
|
from sqlalchemy import and_, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import (
|
|
AlignmentRecord,
|
|
CognitiveConflict,
|
|
Project,
|
|
ProjectSignalLink,
|
|
Signal,
|
|
Task,
|
|
TruthNode,
|
|
User,
|
|
UserProject,
|
|
UserWorkload,
|
|
)
|
|
from ..utils.project_members import ensure_project_default_members
|
|
|
|
from ..utils.timezone import now_cn_aware
|
|
DONE_STATUSES = {"done", "completed"}
|
|
ACTIVE_STATUSES = {"in_progress", "review"}
|
|
BLOCKED_STATUSES = {"blocked"}
|
|
ALIGNED_STATUSES = {"aligned", "confirmed", "已对齐"}
|
|
ALIGNING_STATUSES = {"aligning", "alignment_in_progress", "对齐中"}
|
|
ARCHIVED_STATUSES = {"archived", "已归档"}
|
|
RELEASE_VERIFIED_STATUSES = {"verified", "已验证"}
|
|
RELEASE_LIVE_STATUSES = {"online", "deployed", "已上线"}
|
|
RELEASE_RELEASING_STATUSES = {"releasing", "发布中"}
|
|
|
|
|
|
def _to_json(value: Any, fallback: dict | None = None) -> dict:
|
|
if fallback is None:
|
|
fallback = {}
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str):
|
|
import json
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, dict) else {"text": value}
|
|
except (TypeError, ValueError):
|
|
return {"text": value}
|
|
return fallback
|
|
|
|
|
|
def _avg(values: Iterable[float]) -> float:
|
|
values = list(values)
|
|
if not values:
|
|
return 0.0
|
|
return float(sum(values) / len(values))
|
|
|
|
|
|
class SSTCService:
|
|
def __init__(self, db: AsyncSession, tenant_id: int):
|
|
self.db = db
|
|
self.tenant_id = tenant_id
|
|
|
|
async def get_project(self, project_id: int) -> Project | None:
|
|
result = await self.db.execute(
|
|
select(Project).where(
|
|
and_(Project.id == project_id, Project.tenant_id == self.tenant_id)
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_signals(self, project_id: int) -> list[Signal]:
|
|
linked_result = await self.db.execute(
|
|
select(Signal)
|
|
.join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id)
|
|
.where(
|
|
and_(
|
|
ProjectSignalLink.project_id == project_id,
|
|
Signal.tenant_id == self.tenant_id,
|
|
)
|
|
)
|
|
.order_by(Signal.created_at.desc())
|
|
)
|
|
linked_rows = list(linked_result.scalars().all())
|
|
merged: dict[int, Signal] = {
|
|
int(row.id): row
|
|
for row in linked_rows
|
|
if getattr(row, "id", None) is not None
|
|
}
|
|
|
|
fallback_result = await self.db.execute(
|
|
select(Signal)
|
|
.where(
|
|
and_(
|
|
Signal.matched_project_id == project_id,
|
|
Signal.tenant_id == self.tenant_id,
|
|
)
|
|
)
|
|
.order_by(Signal.created_at.desc())
|
|
)
|
|
for row in fallback_result.scalars().all():
|
|
if getattr(row, "id", None) is None:
|
|
continue
|
|
merged.setdefault(int(row.id), row)
|
|
|
|
return sorted(
|
|
merged.values(),
|
|
key=lambda row: row.created_at or datetime.min,
|
|
reverse=True,
|
|
)
|
|
|
|
async def get_charters(self, project_id: int) -> list[TruthNode]:
|
|
result = await self.db.execute(
|
|
select(TruthNode)
|
|
.where(
|
|
and_(
|
|
TruthNode.project_id == project_id,
|
|
TruthNode.tenant_id == self.tenant_id,
|
|
TruthNode.node_type == "charter",
|
|
)
|
|
)
|
|
.order_by(TruthNode.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
async def get_tasks(self, project_id: int) -> list[Task]:
|
|
result = await self.db.execute(
|
|
select(Task).where(
|
|
and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id)
|
|
)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
async def get_releases(self, project_id: int) -> list[TruthNode]:
|
|
result = await self.db.execute(
|
|
select(TruthNode)
|
|
.where(
|
|
and_(
|
|
TruthNode.project_id == project_id,
|
|
TruthNode.tenant_id == self.tenant_id,
|
|
TruthNode.node_type == "release",
|
|
)
|
|
)
|
|
.order_by(TruthNode.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
async def get_reviews(self, project_id: int) -> list[TruthNode]:
|
|
result = await self.db.execute(
|
|
select(TruthNode)
|
|
.where(
|
|
and_(
|
|
TruthNode.project_id == project_id,
|
|
TruthNode.tenant_id == self.tenant_id,
|
|
TruthNode.node_type == "review",
|
|
)
|
|
)
|
|
.order_by(TruthNode.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
async def get_alignment_stats(self, charter_ids: list[int]) -> dict[str, Any]:
|
|
if not charter_ids:
|
|
return {"score": 0.0, "pending_nodes": 0, "unresolved_conflicts": 0}
|
|
|
|
ar_result = await self.db.execute(
|
|
select(AlignmentRecord).where(
|
|
and_(
|
|
AlignmentRecord.truth_node_id.in_(charter_ids),
|
|
AlignmentRecord.tenant_id == self.tenant_id,
|
|
)
|
|
)
|
|
)
|
|
records = list(ar_result.scalars().all())
|
|
score = _avg([float(r.alignment_score or 0) for r in records])
|
|
|
|
node_confirmed: dict[int, set[int]] = {}
|
|
for r in records:
|
|
if r.is_confirmed:
|
|
node_confirmed.setdefault(int(r.truth_node_id), set()).add(int(r.user_id))
|
|
pending_nodes = len([nid for nid in charter_ids if not node_confirmed.get(nid)])
|
|
|
|
conflict_result = await self.db.execute(
|
|
select(func.count(CognitiveConflict.id)).where(
|
|
and_(
|
|
CognitiveConflict.truth_node_id.in_(charter_ids),
|
|
CognitiveConflict.tenant_id == self.tenant_id,
|
|
CognitiveConflict.status != "resolved",
|
|
)
|
|
)
|
|
)
|
|
unresolved_conflicts = int(conflict_result.scalar() or 0)
|
|
return {
|
|
"score": round(score, 2),
|
|
"pending_nodes": pending_nodes,
|
|
"unresolved_conflicts": unresolved_conflicts,
|
|
}
|
|
|
|
async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]:
|
|
member_profiles = await self._load_member_workload_profiles(assignee_ids)
|
|
if not member_profiles:
|
|
return {
|
|
"avg_ratio": 0.0,
|
|
"overloaded_count": 0,
|
|
"idle_count": 0,
|
|
"member_count": 0,
|
|
"members": [],
|
|
}
|
|
|
|
ratios = [float(item["workload_ratio"]) for item in member_profiles]
|
|
overloaded_count = len([x for x in ratios if x > 1.0])
|
|
idle_count = len([x for x in ratios if x < 0.3])
|
|
return {
|
|
"avg_ratio": round(_avg(ratios), 2),
|
|
"overloaded_count": overloaded_count,
|
|
"idle_count": idle_count,
|
|
"member_count": len(member_profiles),
|
|
"members": member_profiles,
|
|
}
|
|
|
|
@staticmethod
|
|
def _normalize_workload_ratio(raw_ratio: float | int | None) -> float:
|
|
ratio = float(raw_ratio or 0)
|
|
if ratio > 1.5:
|
|
ratio = ratio / 100.0
|
|
return max(0.0, ratio)
|
|
|
|
async def _load_member_workload_profiles(self, assignee_ids: list[int]) -> list[dict[str, Any]]:
|
|
if not assignee_ids:
|
|
return []
|
|
|
|
rows = await self.db.execute(
|
|
select(User.id, User.name, User.username, User.role, UserWorkload.workload_ratio)
|
|
.select_from(User)
|
|
.outerjoin(
|
|
UserWorkload,
|
|
and_(
|
|
UserWorkload.tenant_id == self.tenant_id,
|
|
UserWorkload.user_id == User.id,
|
|
),
|
|
)
|
|
.where(
|
|
and_(
|
|
User.tenant_id == self.tenant_id,
|
|
User.id.in_(assignee_ids),
|
|
User.is_active.is_(True),
|
|
)
|
|
)
|
|
.order_by(User.id.asc())
|
|
)
|
|
|
|
members: list[dict[str, Any]] = []
|
|
for user_id, name, username, role, workload_ratio in rows.all():
|
|
ratio = self._normalize_workload_ratio(workload_ratio)
|
|
if ratio > 1.0:
|
|
status = "overloaded"
|
|
elif ratio < 0.3:
|
|
status = "idle"
|
|
else:
|
|
status = "normal"
|
|
members.append(
|
|
{
|
|
"user_id": str(int(user_id)),
|
|
"name": str(name or username or f"用户{int(user_id)}"),
|
|
"role": str(role or ""),
|
|
"workload_ratio": round(ratio, 2),
|
|
"status": status,
|
|
}
|
|
)
|
|
return members
|
|
|
|
@staticmethod
|
|
def _task_metrics(tasks: list[Task]) -> dict[str, Any]:
|
|
total = len(tasks)
|
|
done = len([t for t in tasks if (t.status or "").lower() in DONE_STATUSES])
|
|
blocked = len([t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES])
|
|
in_progress = len([t for t in tasks if (t.status or "").lower() in ACTIVE_STATUSES])
|
|
today_done = len(
|
|
[
|
|
t
|
|
for t in tasks
|
|
if (t.status or "").lower() in DONE_STATUSES
|
|
and t.completed_at
|
|
and t.completed_at.date() == datetime.now().date()
|
|
]
|
|
)
|
|
overdue = len(
|
|
[
|
|
t
|
|
for t in tasks
|
|
if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES
|
|
]
|
|
)
|
|
return {
|
|
"total": total,
|
|
"done": done,
|
|
"blocked": blocked,
|
|
"in_progress": in_progress,
|
|
"today_done": today_done,
|
|
"overdue": overdue,
|
|
"progress": round((done / total) * 100, 2) if total else 0,
|
|
}
|
|
|
|
async def build_overview(self, project_id: int) -> dict[str, Any]:
|
|
project = await self.get_project(project_id)
|
|
if not project:
|
|
return {}
|
|
signals = await self.get_signals(project_id)
|
|
charters = await self.get_charters(project_id)
|
|
tasks = await self.get_tasks(project_id)
|
|
charters_ids = [int(c.id) for c in charters]
|
|
alignment = await self.get_alignment_stats(charters_ids)
|
|
metrics = self._task_metrics(tasks)
|
|
member_ids = await self.get_project_workload_member_ids(project_id)
|
|
workload = await self.get_workload_health(member_ids)
|
|
chain_id = str(charters[0].id) if charters else None
|
|
return {
|
|
"project": {
|
|
"id": str(project.id),
|
|
"title": project.title or project.name,
|
|
"description": project.description,
|
|
"status": project.status,
|
|
"owner_id": str(project.owner_id) if project.owner_id else None,
|
|
"created_at": project.created_at.isoformat() if project.created_at else None,
|
|
"chain_id": chain_id,
|
|
"signal_count": len(signals),
|
|
"charter_count": len(charters),
|
|
"task_count": len(tasks),
|
|
},
|
|
"chain_id": chain_id,
|
|
"alignment_score": alignment["score"],
|
|
"pending_alignment_nodes": alignment["pending_nodes"],
|
|
"unresolved_conflicts": alignment["unresolved_conflicts"],
|
|
"task_progress": metrics,
|
|
"workload_health": workload,
|
|
}
|
|
|
|
async def build_stages(self, project_id: int) -> list[dict[str, Any]]:
|
|
signals = await self.get_signals(project_id)
|
|
charters = await self.get_charters(project_id)
|
|
tasks = await self.get_tasks(project_id)
|
|
releases = await self.get_releases(project_id)
|
|
reviews = await self.get_reviews(project_id)
|
|
|
|
has_p0 = any((s.priority or "").lower() == "p0" for s in signals)
|
|
has_pending_signal = any(not bool(s.processed) for s in signals)
|
|
signal_status = "已同步"
|
|
if has_p0 or has_pending_signal:
|
|
signal_status = "待处理"
|
|
|
|
latest_charter = charters[0] if charters else None
|
|
charter_status_raw = (latest_charter.status if latest_charter else "").lower()
|
|
if not latest_charter:
|
|
charter_status = "草稿"
|
|
elif charter_status_raw in ALIGNING_STATUSES:
|
|
charter_status = "对齐中"
|
|
elif charter_status_raw in ARCHIVED_STATUSES:
|
|
charter_status = "已归档"
|
|
elif charter_status_raw in ALIGNED_STATUSES:
|
|
charter_status = "已对齐"
|
|
else:
|
|
charter_status = "草稿"
|
|
charter_aligned = charter_status == "已对齐"
|
|
|
|
if not charter_aligned:
|
|
task_status = "未解锁"
|
|
elif not tasks:
|
|
task_status = "待确认"
|
|
elif all((t.status or "").lower() in DONE_STATUSES for t in tasks):
|
|
task_status = "已确认"
|
|
else:
|
|
task_status = "待确认"
|
|
|
|
if not tasks:
|
|
execution_status = "未开始"
|
|
elif all((t.status or "").lower() in DONE_STATUSES for t in tasks):
|
|
execution_status = "已完成"
|
|
elif any((t.status or "").lower() in (ACTIVE_STATUSES | BLOCKED_STATUSES) for t in tasks):
|
|
execution_status = "进行中"
|
|
else:
|
|
execution_status = "未开始"
|
|
metrics = self._task_metrics(tasks)
|
|
execution_done = execution_status == "已完成"
|
|
release_pre_unlocked = charter_aligned and metrics["done"] > 0
|
|
|
|
latest_release = releases[0] if releases else None
|
|
release_raw = (latest_release.status if latest_release else "").lower()
|
|
if not release_pre_unlocked:
|
|
release_status = "未解锁"
|
|
elif not execution_done and not latest_release:
|
|
release_status = "可预发布"
|
|
elif not latest_release:
|
|
release_status = "待发布"
|
|
elif release_raw in RELEASE_VERIFIED_STATUSES:
|
|
release_status = "已验证"
|
|
elif release_raw in RELEASE_RELEASING_STATUSES:
|
|
release_status = "发布中"
|
|
elif release_raw in RELEASE_LIVE_STATUSES:
|
|
release_status = "已上线"
|
|
else:
|
|
release_status = "可预发布" if not execution_done else "待发布"
|
|
release_verified = release_status == "已验证"
|
|
review_pre_unlocked = release_status in {"已上线", "已验证", "发布中"}
|
|
|
|
latest_review = reviews[0] if reviews else None
|
|
review_raw = (latest_review.status if latest_review else "").lower()
|
|
if not review_pre_unlocked:
|
|
review_status = "未解锁"
|
|
elif not release_verified and not latest_review:
|
|
review_status = "可阶段复盘"
|
|
elif not latest_review:
|
|
review_status = "草稿"
|
|
elif review_raw in ARCHIVED_STATUSES:
|
|
review_status = "已归档"
|
|
else:
|
|
review_status = "草稿"
|
|
|
|
return [
|
|
{
|
|
"key": "signal",
|
|
"label": "信号源",
|
|
"status": signal_status,
|
|
"summary": f"{len(signals)} 条有效信号",
|
|
"locked": False,
|
|
"editable": True,
|
|
"risk_count": int(has_p0),
|
|
},
|
|
{
|
|
"key": "charter",
|
|
"label": "需求宪章",
|
|
"status": charter_status,
|
|
"summary": f"{len(charters)} 份宪章",
|
|
"locked": False,
|
|
"editable": True,
|
|
"risk_count": 1 if charter_status == "对齐中" else 0,
|
|
},
|
|
{
|
|
"key": "task",
|
|
"label": "任务拆解",
|
|
"status": task_status,
|
|
"summary": f"{len(tasks)} 个任务",
|
|
"locked": not charter_aligned,
|
|
"editable": charter_aligned,
|
|
"risk_count": metrics["blocked"],
|
|
},
|
|
{
|
|
"key": "execution",
|
|
"label": "执行落地",
|
|
"status": execution_status,
|
|
"summary": f"完成率 {metrics['progress']}%",
|
|
"locked": not charter_aligned,
|
|
"editable": charter_aligned,
|
|
"risk_count": metrics["blocked"] + metrics["overdue"],
|
|
},
|
|
{
|
|
"key": "release",
|
|
"label": "发布验证",
|
|
"status": release_status,
|
|
"summary": f"{len(releases)} 次发布",
|
|
"locked": not release_pre_unlocked,
|
|
"editable": release_pre_unlocked,
|
|
"risk_count": metrics["overdue"],
|
|
},
|
|
{
|
|
"key": "review",
|
|
"label": "复盘沉淀",
|
|
"status": review_status,
|
|
"summary": f"{len(reviews)} 份复盘",
|
|
"locked": not review_pre_unlocked,
|
|
"editable": review_pre_unlocked,
|
|
"risk_count": 0,
|
|
},
|
|
]
|
|
|
|
async def build_risks(self, project_id: int) -> dict[str, Any]:
|
|
charters = await self.get_charters(project_id)
|
|
tasks = await self.get_tasks(project_id)
|
|
charter_ids = [int(c.id) for c in charters]
|
|
conflict_rows = []
|
|
if charter_ids:
|
|
conflict_result = await self.db.execute(
|
|
select(CognitiveConflict).where(
|
|
and_(
|
|
CognitiveConflict.truth_node_id.in_(charter_ids),
|
|
CognitiveConflict.tenant_id == self.tenant_id,
|
|
CognitiveConflict.status != "resolved",
|
|
)
|
|
)
|
|
)
|
|
conflict_rows = list(conflict_result.scalars().all())
|
|
|
|
blocked_tasks = [t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES]
|
|
overdue_tasks = [
|
|
t
|
|
for t in tasks
|
|
if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES
|
|
]
|
|
risk_items = [
|
|
{
|
|
"id": f"task-blocked-{t.id}",
|
|
"type": "task_blocked",
|
|
"priority": "high",
|
|
"title": f"任务阻塞:{t.title}",
|
|
"entity_id": str(t.id),
|
|
}
|
|
for t in blocked_tasks
|
|
] + [
|
|
{
|
|
"id": f"task-overdue-{t.id}",
|
|
"type": "task_overdue",
|
|
"priority": "medium",
|
|
"title": f"任务逾期:{t.title}",
|
|
"entity_id": str(t.id),
|
|
}
|
|
for t in overdue_tasks
|
|
]
|
|
|
|
conflicts = [
|
|
{
|
|
"id": str(c.id),
|
|
"type": c.conflict_type,
|
|
"priority": "high",
|
|
"title": c.description[:80],
|
|
"entity_id": str(c.truth_node_id),
|
|
}
|
|
for c in conflict_rows
|
|
]
|
|
return {
|
|
"conflicts": conflicts,
|
|
"risks": risk_items,
|
|
"summary": {
|
|
"conflict_count": len(conflicts),
|
|
"risk_count": len(risk_items),
|
|
"blocked_task_count": len(blocked_tasks),
|
|
"overdue_task_count": len(overdue_tasks),
|
|
},
|
|
}
|
|
|
|
async def build_chain(self, project_id: int) -> dict[str, Any]:
|
|
project = await self.get_project(project_id)
|
|
if not project:
|
|
return {}
|
|
|
|
signals = await self.get_signals(project_id)
|
|
charters = await self.get_charters(project_id)
|
|
tasks = await self.get_tasks(project_id)
|
|
|
|
tasks_by_charter: dict[str, list[dict[str, Any]]] = {}
|
|
for c in charters:
|
|
tasks_by_charter[str(c.id)] = [
|
|
{
|
|
"id": str(t.id),
|
|
"title": t.title,
|
|
"status": t.status,
|
|
"priority": t.priority,
|
|
"charter_id": str(c.id),
|
|
"description": t.description,
|
|
"estimated_hours": float(t.estimated_hours) if t.estimated_hours else None,
|
|
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
|
|
}
|
|
for t in tasks
|
|
if int(t.truth_node_id) == int(c.id)
|
|
]
|
|
|
|
return {
|
|
"project": {
|
|
"id": str(project.id),
|
|
"title": project.title or project.name,
|
|
"description": project.description,
|
|
},
|
|
"signals": [
|
|
{
|
|
"id": str(s.id),
|
|
"source": s.source,
|
|
"content": (s.content or "")[:240],
|
|
"impact_score": s.impact_score,
|
|
"priority": s.priority,
|
|
"processed": s.processed,
|
|
}
|
|
for s in signals
|
|
],
|
|
"charters": [
|
|
{
|
|
"id": str(c.id),
|
|
"title": c.title,
|
|
"content": _to_json(c.content),
|
|
"signal_ids": _to_json(c.content).get("signal_ids", []),
|
|
"status": c.status,
|
|
"version": c.version,
|
|
"parent_id": str(c.parent_id) if c.parent_id else None,
|
|
}
|
|
for c in charters
|
|
],
|
|
"tasks_by_charter": tasks_by_charter,
|
|
}
|
|
|
|
async def has_member_confirmed(self, charter_id: int, user_id: int) -> bool:
|
|
result = await self.db.execute(
|
|
select(func.count(AlignmentRecord.id)).where(
|
|
and_(
|
|
AlignmentRecord.tenant_id == self.tenant_id,
|
|
AlignmentRecord.truth_node_id == charter_id,
|
|
AlignmentRecord.user_id == user_id,
|
|
AlignmentRecord.is_confirmed.is_(True),
|
|
)
|
|
)
|
|
)
|
|
return int(result.scalar() or 0) > 0
|
|
|
|
async def _load_project_member_id_set(self, project_id: int) -> set[int]:
|
|
member_result = await self.db.execute(
|
|
select(UserProject.user_id).where(
|
|
and_(
|
|
UserProject.project_id == project_id,
|
|
UserProject.tenant_id == self.tenant_id,
|
|
)
|
|
)
|
|
)
|
|
return {int(x) for x in member_result.scalars().all() if x is not None}
|
|
|
|
async def get_project_workload_member_ids(self, project_id: int) -> list[int]:
|
|
member_ids = await self._load_project_member_id_set(project_id)
|
|
project = await self.get_project(project_id)
|
|
if not member_ids and project:
|
|
member_ids = set(
|
|
await ensure_project_default_members(
|
|
self.db,
|
|
tenant_id=self.tenant_id,
|
|
project_id=int(project_id),
|
|
owner_id=int(project.owner_id) if project.owner_id else None,
|
|
assigned_role=project.assigned_role,
|
|
)
|
|
)
|
|
await self.db.commit()
|
|
return sorted(member_ids)
|
|
|
|
async def get_project_member_ids(self, project_id: int) -> list[int]:
|
|
member_ids = set(await self.get_project_workload_member_ids(project_id))
|
|
project = await self.get_project(project_id)
|
|
if project and project.owner_id:
|
|
member_ids.add(int(project.owner_id))
|
|
return sorted(member_ids)
|