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