"""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)