124 lines
4.7 KiB
Python
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,
|
|
}
|