301 lines
12 KiB
Python
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 = {"OWNER", "REVIEWER"}
|
|
|
|
|
|
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
|