"""AI PMO cadence engine。""" from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any, Optional from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem from ..utils.timezone import now_cn_naive def _utc_now_naive() -> datetime: return now_cn_naive() class AIPMOCadenceService: """日会 / 周会 / 风险例会 / 里程碑检查 / 项目群视角。""" SUPPORTED_CADENCE_TYPES = {"daily_standup", "weekly_review", "risk_review", "milestone_check"} def __init__(self, db: AsyncSession, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) async def generate_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> dict[str, Any]: normalized_type = str(cadence_type or "").strip().lower() if normalized_type not in self.SUPPORTED_CADENCE_TYPES: raise ValueError("unsupported cadence_type") if normalized_type == "daily_standup": payload = await self._build_daily_standup(project_id=project_id) elif normalized_type == "weekly_review": payload = await self._build_weekly_review(project_id=project_id) elif normalized_type == "risk_review": payload = await self._build_risk_review(project_id=project_id) else: payload = await self._build_milestone_check(project_id=project_id) scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" row = PortfolioSnapshot( tenant_id=self.tenant_id, scope_type=normalized_type[:20], scope_ref=scope_ref[:64], summary=payload.get("summary"), payload=payload, ) self.db.add(row) await self.db.flush() return { "id": int(row.id), "scope_type": row.scope_type, "scope_ref": row.scope_ref, "summary": row.summary, "payload": row.payload, "created_at": row.created_at.isoformat() if row.created_at else None, } async def get_latest_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> Optional[dict[str, Any]]: normalized_type = str(cadence_type or "").strip().lower() scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" row = ( await self.db.execute( select(PortfolioSnapshot).where( and_( PortfolioSnapshot.tenant_id == self.tenant_id, PortfolioSnapshot.scope_type == normalized_type, PortfolioSnapshot.scope_ref == scope_ref, ) ).order_by(PortfolioSnapshot.id.desc()).limit(1) ) ).scalar_one_or_none() if row is None: return None return { "id": int(row.id), "scope_type": row.scope_type, "scope_ref": row.scope_ref, "summary": row.summary, "payload": row.payload, "created_at": row.created_at.isoformat() if row.created_at else None, } async def build_program_view(self) -> dict[str, Any]: projects = ( await self.db.execute( select(Project, ProjectDomain) .outerjoin(ProjectDomain, and_(ProjectDomain.id == Project.domain_id, ProjectDomain.tenant_id == self.tenant_id)) .where( and_( Project.tenant_id == self.tenant_id, Project.status.in_(["active", "planning", "doing", "todo"]), ) ) .order_by(Project.id.asc()) ) ).all() domain_groups: dict[str, dict[str, Any]] = {} for project, domain in projects: key = str((domain.slug if domain is not None else "unscoped") or "unscoped") group = domain_groups.setdefault( key, { "program_key": key, "program_name": str((domain.name if domain is not None else "未分组项目") or "未分组项目"), "project_count": 0, "project_ids": [], "blocked_task_count": 0, "pending_manual_count": 0, "budget_usage_ratio_max": 0.0, }, ) group["project_count"] += 1 group["project_ids"].append(int(project.id)) if not domain_groups: return {"summary": {"program_count": 0, "active_project_count": 0}, "programs": []} project_ids = [pid for group in domain_groups.values() for pid in group["project_ids"]] task_rows = ( await self.db.execute( select(Task.project_id, func.count(Task.id)) .where( and_( Task.tenant_id == self.tenant_id, Task.project_id.in_(project_ids), Task.status == "blocked", ) ) .group_by(Task.project_id) ) ).all() task_map = {int(project_id): int(count or 0) for project_id, count in task_rows} work_rows = ( await self.db.execute( select(WorkItem.project_id, func.count(WorkItem.id)) .where( and_( WorkItem.tenant_id == self.tenant_id, WorkItem.project_id.in_(project_ids), WorkItem.state == "waiting_manual", ) ) .group_by(WorkItem.project_id) ) ).all() work_map = {int(project_id): int(count or 0) for project_id, count in work_rows} budget_rows = ( await self.db.execute( select(Budget.project_id, Budget.budget_amount_usd, Budget.current_usage_usd) .where( and_( Budget.tenant_id == self.tenant_id, Budget.project_id.in_(project_ids), ) ) ) ).all() budget_ratio_map = { int(project_id): (float(usage or 0.0) / float(amount or 1.0)) if float(amount or 0.0) > 0 else 0.0 for project_id, amount, usage in budget_rows if project_id is not None } for group in domain_groups.values(): group["blocked_task_count"] = sum(task_map.get(pid, 0) for pid in group["project_ids"]) group["pending_manual_count"] = sum(work_map.get(pid, 0) for pid in group["project_ids"]) ratios = [budget_ratio_map.get(pid, 0.0) for pid in group["project_ids"]] group["budget_usage_ratio_max"] = round(max(ratios, default=0.0), 4) programs = sorted(domain_groups.values(), key=lambda item: (item["pending_manual_count"], item["blocked_task_count"], item["project_count"]), reverse=True) return { "summary": { "program_count": len(programs), "active_project_count": len(project_ids), "pending_manual_total": sum(item["pending_manual_count"] for item in programs), "blocked_task_total": sum(item["blocked_task_count"] for item in programs), }, "programs": programs, } async def _build_daily_standup(self, *, project_id: Optional[int]) -> dict[str, Any]: now = _utc_now_naive() since = now - timedelta(days=1) work_items = await self._list_recent_work_items(since=since, project_id=project_id) blocked_tasks = await self._list_blocked_tasks(project_id=project_id) recent_signals = await self._list_recent_signals(since=since, project_id=project_id) pending_manual_count = await self._count_work_items(state="waiting_manual", project_id=project_id) return { "summary": { "cadence_type": "daily_standup", "recent_work_item_count": len(work_items), "pending_manual_count": pending_manual_count, "blocked_task_count": len(blocked_tasks), "recent_signal_count": len(recent_signals), }, "work_items": work_items[:10], "blocked_tasks": blocked_tasks[:10], "recent_signals": recent_signals[:10], "focus_actions": self._build_focus_actions(pending_manual_count=pending_manual_count, blocked_task_count=len(blocked_tasks)), } async def _build_weekly_review(self, *, project_id: Optional[int]) -> dict[str, Any]: now = _utc_now_naive() since = now - timedelta(days=7) weekly_rows = await self._list_recent_work_items(since=since, project_id=project_id) budgets = await self._list_budget_hotspots(project_id=project_id) capacity = await self._list_capacity_hotspots(project_id=project_id) program_view = await self.build_program_view() if project_id is None else None by_state: dict[str, int] = {} for item in weekly_rows: state = str(item.get("state") or "unknown") by_state[state] = by_state.get(state, 0) + 1 return { "summary": { "cadence_type": "weekly_review", "work_item_total": len(weekly_rows), "by_state": by_state, "budget_hotspot_count": len(budgets), "capacity_hotspot_count": len(capacity), }, "work_items": weekly_rows[:20], "budget_hotspots": budgets[:10], "capacity_hotspots": capacity[:10], "program_view": program_view, } async def _build_risk_review(self, *, project_id: Optional[int]) -> dict[str, Any]: blocked_tasks = await self._list_blocked_tasks(project_id=project_id) signal_sources = await self._list_pending_signal_sources(project_id=project_id) notifications = await self._list_unread_risk_notifications(project_id=project_id) return { "summary": { "cadence_type": "risk_review", "blocked_task_count": len(blocked_tasks), "pending_signal_source_count": len(signal_sources), "unread_risk_notification_count": len(notifications), }, "blocked_tasks": blocked_tasks[:20], "pending_signal_sources": signal_sources[:20], "risk_notifications": notifications[:20], } async def _build_milestone_check(self, *, project_id: Optional[int]) -> dict[str, Any]: overdue_tasks = await self._list_overdue_tasks(project_id=project_id) work_items = await self._count_work_items(state="waiting_manual", project_id=project_id) stmt = select(Project.id, Project.title, Project.status).where(Project.tenant_id == self.tenant_id) if project_id is not None: stmt = stmt.where(Project.id == int(project_id)) projects = (await self.db.execute(stmt)).all() return { "summary": { "cadence_type": "milestone_check", "project_count": len(projects), "overdue_task_count": len(overdue_tasks), "pending_manual_count": work_items, }, "projects": [ {"project_id": int(pid), "title": str(title or ""), "status": str(status or "")} for pid, title, status in projects ], "overdue_tasks": overdue_tasks[:20], } async def _list_recent_work_items(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(WorkItem).where( and_( WorkItem.tenant_id == self.tenant_id, WorkItem.created_at >= since, ) ) if project_id is not None: stmt = stmt.where(WorkItem.project_id == int(project_id)) rows = (await self.db.execute(stmt.order_by(WorkItem.created_at.desc()).limit(50))).scalars().all() return [ { "id": int(row.id), "state": row.state, "intent": row.intent, "project_id": row.project_id, "signal_id": row.signal_id, "created_at": row.created_at.isoformat() if row.created_at else None, } for row in rows ] async def _count_work_items(self, *, state: str, project_id: Optional[int]) -> int: stmt = select(func.count(WorkItem.id)).where( and_( WorkItem.tenant_id == self.tenant_id, WorkItem.state == state, ) ) if project_id is not None: stmt = stmt.where(WorkItem.project_id == int(project_id)) return int((await self.db.execute(stmt)).scalar_one() or 0) async def _list_blocked_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(Task).where( and_( Task.tenant_id == self.tenant_id, Task.status == "blocked", ) ) if project_id is not None: stmt = stmt.where(Task.project_id == int(project_id)) rows = (await self.db.execute(stmt.order_by(Task.updated_at.desc()).limit(50))).scalars().all() return [ { "id": int(row.id), "project_id": int(row.project_id), "title": str(row.title or ""), "priority": str(row.priority or ""), } for row in rows ] async def _list_overdue_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: now = _utc_now_naive() stmt = select(Task).where( and_( Task.tenant_id == self.tenant_id, Task.due_date.is_not(None), Task.due_date < now, Task.status.not_in(["done", "completed", "closed"]), ) ) if project_id is not None: stmt = stmt.where(Task.project_id == int(project_id)) rows = (await self.db.execute(stmt.order_by(Task.due_date.asc()).limit(50))).scalars().all() return [ { "id": int(row.id), "project_id": int(row.project_id), "title": str(row.title or ""), "due_date": row.due_date.isoformat() if row.due_date else None, } for row in rows ] async def _list_recent_signals(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(Signal).where( and_( Signal.tenant_id == self.tenant_id, Signal.created_at >= since, ) ) if project_id is not None: stmt = stmt.where( or_( Signal.matched_project_id == int(project_id), Signal.id.in_( select(ProjectSignalLink.signal_id).where( and_( ProjectSignalLink.tenant_id == self.tenant_id, ProjectSignalLink.project_id == int(project_id), ) ) ), ) ) rows = (await self.db.execute(stmt.order_by(Signal.created_at.desc()).limit(50))).scalars().all() return [ { "id": int(row.id), "title": str(row.title or ""), "status": str(row.status or ""), "signal_type": str(row.signal_type or ""), } for row in rows ] async def _list_budget_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) if project_id is not None: stmt = stmt.where(Budget.project_id == int(project_id)) rows = (await self.db.execute(stmt)).scalars().all() hotspots = [] for row in rows: amount = float(row.budget_amount_usd or 0.0) usage = float(row.current_usage_usd or 0.0) ratio = (usage / amount) if amount > 0 else 0.0 if ratio >= 0.8: hotspots.append( { "budget_id": int(row.id), "project_id": row.project_id, "usage_ratio": round(ratio, 4), "hard_limit_enabled": bool(row.hard_limit_enabled), } ) return sorted(hotspots, key=lambda item: item["usage_ratio"], reverse=True) async def _list_capacity_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(UserWorkload).where( and_( UserWorkload.tenant_id == self.tenant_id, UserWorkload.workload_ratio >= 0.85, ) ) if project_id is not None: stmt = stmt.where( UserWorkload.user_id.in_( select(UserProject.user_id).where( and_( UserProject.tenant_id == self.tenant_id, UserProject.project_id == int(project_id), ) ) ) ) rows = (await self.db.execute(stmt.limit(50))).scalars().all() items = [ { "user_id": int(row.user_id), "workload_ratio": float(row.workload_ratio or 0.0), "current_assigned_hours": float(row.current_assigned_hours or 0.0), } for row in rows ] return items async def _list_pending_signal_sources(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(SignalSourceRegistry).where( and_( SignalSourceRegistry.tenant_id == self.tenant_id, SignalSourceRegistry.onboarding_status == "pending_manual", ) ) if project_id is not None: stmt = stmt.where(SignalSourceRegistry.project_id == int(project_id)) rows = (await self.db.execute(stmt.order_by(SignalSourceRegistry.updated_at.desc()).limit(50))).scalars().all() return [ { "registry_id": int(row.id), "project_id": int(row.project_id), "source_kind": str(row.source_kind or ""), "source_name": str(row.source_name or ""), "status": str(row.status or ""), "onboarding_status": str(row.onboarding_status or ""), } for row in rows ] async def _list_unread_risk_notifications(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: stmt = select(Notification).where( and_( Notification.tenant_id == self.tenant_id, Notification.is_read.is_(False), Notification.kind.in_(["risk", "alert", "action_required"]), ) ) if project_id is not None: stmt = stmt.where( and_( Notification.entity_type == "project", Notification.entity_id == int(project_id), ) ) rows = (await self.db.execute(stmt.order_by(Notification.updated_at.desc()).limit(50))).scalars().all() return [ { "id": int(row.id), "title": str(row.title or ""), "status": str(row.status or ""), "priority": str(row.priority or ""), } for row in rows ] @staticmethod def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]: actions: list[str] = [] if pending_manual_count > 0: actions.append("优先清理待人工确认队列,避免自动执行链路阻塞。") if blocked_task_count > 0: actions.append("优先处理 blocked tasks,降低关键依赖压力。") if not actions: actions.append("当前节奏平稳,继续按计划推进。") return actions