PMFlow/backend/app/services/sstc_service.py

595 lines
22 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
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]:
if not assignee_ids:
return {
"avg_ratio": 0.0,
"overloaded_count": 0,
"idle_count": 0,
"member_count": 0,
}
result = await self.db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == self.tenant_id,
UserWorkload.user_id.in_(assignee_ids),
)
)
)
workloads = list(result.scalars().all())
ratios = []
for row in workloads:
ratio = float(row.workload_ratio or 0)
if ratio > 1.5:
ratio = ratio / 100.0
ratios.append(ratio)
avg_ratio = _avg(ratios)
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_ratio, 2),
"overloaded_count": overloaded_count,
"idle_count": idle_count,
"member_count": len(assignee_ids),
}
@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 < datetime.now(timezone.utc).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_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 < datetime.now(timezone.utc).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 get_project_member_ids(self, project_id: int) -> list[int]:
member_result = await self.db.execute(
select(UserProject.user_id).where(
and_(
UserProject.project_id == project_id,
UserProject.tenant_id == self.tenant_id,
)
)
)
member_ids = {int(x) for x in member_result.scalars().all() if x is not None}
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()
if project and project.owner_id:
member_ids.add(int(project.owner_id))
return sorted(member_ids)