PMFlow/backend/app/services/tenant_briefing_service.py

301 lines
12 KiB
Python

"""Viewer-scoped tenant briefing service."""
from __future__ import annotations
from typing import Any, Iterable
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Project, User, UserProject, UserWorkload
from .auth.service import parse_role_values
from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict
MANAGER_ROLES = {"BOSS", "ADMIN"}
class TenantBriefingService(ExecutiveBriefingService):
"""Builds tenant payloads and filters them for the active viewer."""
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
super().__init__(db, tenant_id=tenant_id)
async def get_tenant_payload(self, *, mode: str) -> dict[str, Any]:
payload = await super().get_briefing(mode=mode)
normalized = _deepcopy_dict(payload)
normalized.pop("delivery", None)
return normalized
async def build_viewer_payload(
self,
*,
viewer_id: int,
viewer_roles: Iterable[str],
mode: str,
) -> dict[str, Any]:
tenant_payload = await self.get_tenant_payload(mode=mode)
roles = self._normalize_roles(viewer_roles)
if roles.intersection(MANAGER_ROLES):
return self._build_manager_payload(tenant_payload)
project_ids = await self._get_viewer_project_ids(viewer_id)
workload = await self._get_viewer_workload(viewer_id)
return self._build_personal_payload(
tenant_payload=tenant_payload,
viewer_id=int(viewer_id),
project_ids=project_ids,
workload=workload,
)
@staticmethod
def _normalize_roles(values: Iterable[str]) -> set[str]:
normalized: set[str] = set()
for value in values:
normalized.update(parse_role_values(str(value or "")))
return normalized
def _build_manager_payload(self, tenant_payload: dict[str, Any]) -> dict[str, Any]:
payload = _deepcopy_dict(tenant_payload)
hero = payload.setdefault("hero", {})
hero["title"] = "全租户每日简报"
payload["viewer_scope"] = "tenant_full"
payload["personal_summary"] = {
"headline": f"今日全租户共有 {hero.get('decision_count', 0)} 项需关注事项。",
"action_count": int(hero.get("decision_count", 0) or 0),
"project_count": len(
{
int(item["project_id"])
for section in payload.get("sections", [])
if isinstance(section, dict)
for item in section.get("items", [])
if isinstance(item, dict) and item.get("project_id") is not None
}
),
}
payload["personal_workload"] = None
payload["export_title"] = "全租户每日简报"
return payload
def _build_personal_payload(
self,
*,
tenant_payload: dict[str, Any],
viewer_id: int,
project_ids: set[int],
workload: dict[str, Any] | None,
) -> dict[str, Any]:
payload = _deepcopy_dict(tenant_payload)
sections = payload.get("sections") if isinstance(payload.get("sections"), list) else []
filtered_sections = []
related_project_ids: set[int] = set()
total_items = 0
for section in sections:
if not isinstance(section, dict):
continue
key = str(section.get("key") or "").strip()
if key == "external_metrics":
filtered_sections.append(section)
continue
filtered_items = []
for item in section.get("items", []) if isinstance(section.get("items"), list) else []:
if not isinstance(item, dict):
continue
if self._item_matches_viewer(item=item, viewer_id=viewer_id, project_ids=project_ids):
filtered_items.append(item)
project_id = self._to_int(item.get("project_id"))
if project_id is not None:
related_project_ids.add(project_id)
total_items += len(filtered_items)
filtered_sections.append(
{
**section,
"summary": self._build_personal_section_summary(section_key=key, count=len(filtered_items)),
"metrics": self._build_personal_section_metrics(
section_key=key,
items=filtered_items,
workload=workload,
),
"items": filtered_items,
}
)
hero = payload.setdefault("hero", {})
decision_count = len(self._find_section_items(filtered_sections, "decisions"))
anomaly_count = total_items
hero["title"] = "我的每日简报"
hero["decision_count"] = decision_count
hero["anomaly_count"] = anomaly_count
hero["key_alerts"] = [
{"label": "我的待关注", "value": decision_count, "tone": "critical" if decision_count else "positive"},
{
"label": "我的负载",
"value": f"{int((workload or {}).get('workload_ratio') or 0)}%",
"tone": str((workload or {}).get("tone") or "info"),
},
{"label": "相关项目", "value": len(related_project_ids), "tone": "info"},
]
payload["viewer_scope"] = "personal"
payload["sections"] = filtered_sections
payload["personal_summary"] = {
"headline": f"今天有 {decision_count} 项需要你优先关注,涉及 {len(related_project_ids)} 个相关项目。",
"action_count": decision_count,
"project_count": len(related_project_ids),
}
payload["personal_workload"] = workload or {
"user_id": viewer_id,
"user_name": f"成员 #{viewer_id}",
"assigned_hours": 0,
"max_weekly_hours": 0,
"workload_ratio": 0,
"tone": "info",
}
payload["export_title"] = "我的每日简报"
return payload
@staticmethod
def _find_section_items(sections: list[dict[str, Any]], key: str) -> list[dict[str, Any]]:
for section in sections:
if str(section.get("key") or "").strip() == key:
return [item for item in section.get("items", []) if isinstance(item, dict)]
return []
def _build_personal_section_summary(self, *, section_key: str, count: int) -> str:
if section_key == "decisions":
return f"今天有 {count} 项与你直接相关的关注事项。" if count else "今天暂无需要你立即处理的拍板事项。"
if section_key == "input_health":
return f"最近 24 小时有 {count} 条与你相关的输入或来源变更。" if count else "最近 24 小时没有新的相关输入变更。"
if section_key == "delivery_risk":
return f"当前有 {count} 条与你相关的交付风险或任务提醒。" if count else "当前没有与你直接相关的交付风险。"
if section_key == "team_capacity":
return f"当前有 {count} 条与你协作或负载相关的提醒。" if count else "你的协作与负载当前比较平稳。"
if section_key == "ai_governance":
return f"当前有 {count} 条与你相关的治理提醒。" if count else "当前没有与你直接相关的治理提醒。"
return f"当前有 {count} 条与你相关的更新。" if count else "当前暂无与你相关的更新。"
def _build_personal_section_metrics(
self,
*,
section_key: str,
items: list[dict[str, Any]],
workload: dict[str, Any] | None,
) -> list[dict[str, Any]]:
related_project_count = len({self._to_int(item.get("project_id")) for item in items if self._to_int(item.get("project_id")) is not None})
related_people_count = len({self._to_int(item.get("user_id")) for item in items if self._to_int(item.get("user_id")) is not None})
workload_ratio = int((workload or {}).get("workload_ratio") or 0)
workload_tone = str((workload or {}).get("tone") or "info")
if section_key == "team_capacity":
return [
{"label": "我的负载", "value": f"{workload_ratio}%", "tone": workload_tone},
{"label": "协作提醒", "value": len(items), "tone": "warning" if items else "positive"},
{"label": "相关同事", "value": related_people_count, "tone": "info"},
]
return [
{"label": "相关事项", "value": len(items), "tone": "warning" if items else "positive"},
{"label": "相关项目", "value": related_project_count, "tone": "info"},
{"label": "我的负载", "value": f"{workload_ratio}%", "tone": workload_tone},
]
def _item_matches_viewer(self, *, item: dict[str, Any], viewer_id: int, project_ids: set[int]) -> bool:
user_id = self._to_int(item.get("user_id"))
if user_id is not None and user_id == viewer_id:
return True
related_user_ids = item.get("related_user_ids")
if isinstance(related_user_ids, list):
for value in related_user_ids:
if self._to_int(value) == viewer_id:
return True
project_id = self._to_int(item.get("project_id"))
if project_id is not None and project_id in project_ids:
return True
return False
async def _get_viewer_project_ids(self, viewer_id: int) -> set[int]:
member_rows = (
await self.db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == self.tenant_id,
UserProject.user_id == int(viewer_id),
)
)
)
).scalars().all()
owner_rows = (
await self.db.execute(
select(Project.id).where(
and_(
Project.tenant_id == self.tenant_id,
Project.owner_id == int(viewer_id),
)
)
)
).scalars().all()
return {
int(value)
for value in [*member_rows, *owner_rows]
if self._to_int(value) is not None
}
async def _get_viewer_workload(self, viewer_id: int) -> dict[str, Any]:
row = (
await self.db.execute(
select(
User.id,
User.name,
UserWorkload.current_assigned_hours,
UserWorkload.max_available_hours_per_week,
UserWorkload.workload_ratio,
)
.join(UserWorkload, UserWorkload.user_id == User.id, isouter=True)
.where(
and_(
User.tenant_id == self.tenant_id,
User.id == int(viewer_id),
)
)
)
).one_or_none()
if row is None:
return {
"user_id": int(viewer_id),
"user_name": f"成员 #{viewer_id}",
"assigned_hours": 0,
"max_weekly_hours": 0,
"workload_ratio": 0,
"tone": "info",
}
user_id, user_name, assigned_hours, max_weekly_hours, workload_ratio = row
assigned = float(assigned_hours or 0)
max_hours = float(max_weekly_hours or 0)
ratio_raw = self._display_workload_ratio(
assigned_hours=assigned_hours,
max_hours=max_weekly_hours,
stored_ratio=workload_ratio,
)
ratio_percent = round(ratio_raw * 100)
tone = "critical" if ratio_percent >= 100 else "warning" if ratio_percent >= 80 else "positive"
return {
"user_id": int(user_id),
"user_name": str(user_name or f"成员 #{viewer_id}"),
"assigned_hours": round(assigned, 1),
"max_weekly_hours": round(max_hours, 1),
"workload_ratio": int(ratio_percent),
"tone": tone,
}
@staticmethod
def _to_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None