PMFlow/backend/app/services/ai_pmo_cadence/mixin_program_view.py

124 lines
4.7 KiB
Python

"""Program view aggregation for AI PMO cadence service."""
from __future__ import annotations
from .shared import Any, Budget, Project, ProjectDomain, Task, WorkItem, and_, func, select
class CadenceProgramViewMixin:
"""Builds cross-project domain program metrics."""
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,
}