"""Daily IT admin digest. Runs 09:00 Asia/Shanghai. Builds a morning summary for IT admins covering the next 3 days of inbound hires (see ``run``'s ``horizon``), today's leavers and their blocker status, and any newly-opened compliance incidents from the last 24h. Sent via email + Feishu. """ from __future__ import annotations import logging from datetime import UTC, date, datetime, timedelta from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.compliance.models import ( INCIDENT_OPEN, SEVERITY_CRITICAL, ComplianceIncident, ) from app.domains.offboarding.models import ( PROC_BLOCKED, PROC_READY, PROC_SCANNING, RESO_PENDING, OffboardingBlocker, OffboardingProcess, ) from app.domains.onboarding.models import ( PROC_OPEN_STATUSES as ONB_OPEN_STATUSES, ) from app.domains.onboarding.models import ( RESO_PENDING as ONB_RESO_PENDING, ) from app.domains.onboarding.models import ( SEVERITY_BLOCKER as ONB_SEVERITY_BLOCKER, ) from app.domains.onboarding.models import ( SLA_BREACH as ONB_SLA_BREACH, ) from app.domains.onboarding.models import ( SLA_URGENT as ONB_SLA_URGENT, ) from app.domains.onboarding.models import ( OnboardingBlocker, OnboardingProcess, ) from app.identity.person_models import IdentityPerson from app.notifications.service import NotificationEnvelope, NotificationService logger = logging.getLogger(__name__) class DailyDigestService: def __init__(self, db: AsyncSession) -> None: self.db = db self.notify = NotificationService(db) async def run(self, *, today: date | None = None) -> dict[str, Any]: today = today or datetime.now(UTC).date() horizon = today + timedelta(days=3) # Upcoming hires (next 3 days, including today) upcoming_hires = list(( await self.db.execute( select(IdentityPerson) .where( IdentityPerson.deleted_at.is_(None), IdentityPerson.hire_date.isnot(None), IdentityPerson.hire_date >= today, IdentityPerson.hire_date <= horizon, ) .order_by(IdentityPerson.hire_date.asc()) ) ).scalars().all()) # Today's leavers today_leavers = list(( await self.db.execute( select(IdentityPerson) .where( IdentityPerson.deleted_at.is_(None), IdentityPerson.termination_date == today, ) .order_by(IdentityPerson.display_name.asc()) ) ).scalars().all()) # Open blockers across all active offboarding processes active_blockers = ( await self.db.execute( select(func.count(OffboardingBlocker.id)) .join( OffboardingProcess, OffboardingProcess.id == OffboardingBlocker.process_id, ) .where( OffboardingBlocker.resolution == RESO_PENDING, OffboardingProcess.status.in_( (PROC_SCANNING, PROC_BLOCKED, PROC_READY) ), ) ) ).scalar() or 0 # New incidents in the last 24h cutoff = datetime.now(UTC) - timedelta(hours=24) new_incidents = ( await self.db.execute( select(func.count(ComplianceIncident.id)) .where( ComplianceIncident.detected_at >= cutoff, ComplianceIncident.status == INCIDENT_OPEN, ) ) ).scalar() or 0 total_open_incidents = ( await self.db.execute( select(func.count(ComplianceIncident.id)) .where(ComplianceIncident.status == INCIDENT_OPEN) ) ).scalar() or 0 critical_open = ( await self.db.execute( select(func.count(ComplianceIncident.id)) .where( ComplianceIncident.status == INCIDENT_OPEN, ComplianceIncident.severity == SEVERITY_CRITICAL, ) ) ).scalar() or 0 # Per-hire Day-1 readiness (onboarding-parity PR2): join the # onboarding process + pending blocker counts so the morning # digest answers "are tomorrow's hires actually ready", not # just "who is arriving". hire_ids = [p.id for p in upcoming_hires] proc_by_person: dict[Any, OnboardingProcess] = {} pending_by_proc: dict[Any, int] = {} if hire_ids: procs = list(( await self.db.execute( select(OnboardingProcess).where( OnboardingProcess.person_id.in_(hire_ids) ) ) ).scalars().all()) proc_by_person = {pr.person_id: pr for pr in procs} if procs: # Hard (BLOCKER-severity) pending only — the digest's # readiness口径 must match SLA / escalation / scorecard # (#1027 + review-r2); counting advisory dims (须知 # confirmation etc.) chronically inflated the gap line. rows = ( await self.db.execute( select( OnboardingBlocker.process_id, func.count().label("n"), ) .where( OnboardingBlocker.process_id.in_( [pr.id for pr in procs] ), OnboardingBlocker.resolution == ONB_RESO_PENDING, OnboardingBlocker.severity == ONB_SEVERITY_BLOCKER, ) .group_by(OnboardingBlocker.process_id) ) ).all() pending_by_proc = {r[0]: int(r[1]) for r in rows} # Org-wide onboarding debt: open processes still carrying # pending HARD dims, split by SLA tier (advisory excluded — # same口径 as above; backfilled rows never observed end-to-end # are excluded too). onboarding_debt_rows = list(( await self.db.execute( select( OnboardingProcess.sla_breach_level, func.count().label("n"), ) .where( OnboardingProcess.status.in_(ONB_OPEN_STATUSES), OnboardingProcess.backfilled.is_(False), # Past-due only — pre-arrival hires routinely lack # hardware/SSO during prep; counting them inflated # the headline vs the urgent/breach tiers printed # next to it (review-r3: same axis as the scorecard # debt counter + day0 escalation targets). OnboardingProcess.hire_date <= today, OnboardingProcess.id.in_( select(OnboardingBlocker.process_id).where( OnboardingBlocker.resolution == ONB_RESO_PENDING, OnboardingBlocker.severity == ONB_SEVERITY_BLOCKER, ) ), ) .group_by(OnboardingProcess.sla_breach_level) ) ).all()) onboarding_debt = {r[0]: int(r[1]) for r in onboarding_debt_rows} onboarding_open_gaps = sum(onboarding_debt.values()) onboarding_urgent = onboarding_debt.get(ONB_SLA_URGENT, 0) onboarding_breach = onboarding_debt.get(ONB_SLA_BREACH, 0) lines: list[str] = [ f"📅 今天是 {today.isoformat()}", "", "【近期入职】", ] if upcoming_hires: for p in upcoming_hires: proc = proc_by_person.get(p.id) if proc is None: readiness = "未跟踪" else: n_pending = pending_by_proc.get(proc.id, 0) readiness = ( "✅ 硬性项就绪" if n_pending == 0 else f"⚠️ 缺 {n_pending} 项硬性 ({proc.status})" ) lines.append( f" · {p.hire_date} — " f"{p.display_name or p.work_email} " f"({p.primary_department or '-'}) — {readiness}" ) else: lines.append(" · 无") lines.append("") lines.append("【入职就绪】") if onboarding_open_gaps: lines.append( f" · 已到岗仍缺硬性项: {onboarding_open_gaps} 人" f" (urgent {onboarding_urgent} / breach {onboarding_breach})" ) else: lines.append(" · 已到岗新人硬性项全部配齐") lines.append("") lines.append("【今日应离职】") if today_leavers: for p in today_leavers: lines.append( f" · {p.display_name or p.work_email} " f"[{p.employment_status}]" ) else: lines.append(" · 无") lines.extend([ "", "【运行状态】", f" · 未解 offboarding blocker: {active_blockers} 项", f" · 未解 compliance incident: {total_open_incidents} 项 (critical {critical_open})", f" · 过去 24 小时新增 incident: {new_incidents} 项", ]) lines.extend([ "", "详情入口:", " · /admin/lifecycle/onboarding — 入职管理", " · /admin/offboarding — 离职管理", " · /admin/compliance-incidents — 合规告警", " · /admin/scheduled-jobs — 定时任务", ]) body = "\n".join(lines) summary: dict[str, Any] = { "date": today.isoformat(), "upcoming_hires": len(upcoming_hires), "today_leavers": len(today_leavers), "open_blockers": int(active_blockers), "onboarding_open_gaps": int(onboarding_open_gaps), "onboarding_urgent": int(onboarding_urgent), "onboarding_breach": int(onboarding_breach), "new_incidents_24h": int(new_incidents), "open_incidents": int(total_open_incidents), "critical_incidents": int(critical_open), "notified_admins": 0, } from app.auth.models import AuthRole, AuthUserRole admin_ids = list(( await self.db.execute( select(AuthUserRole.user_id) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where(AuthRole.name.in_(("global_admin", "it_admin"))) # Dedup: an admin holding both global_admin and the legacy # it_admin alias would otherwise be notified twice. .distinct() ) ).scalars().all()) for admin_id in admin_ids: admin = await self.db.get(IdentityPerson, admin_id) if admin is None or not admin.work_email: continue # Pre-launch P1 fix (Round 45, CR-411): don't dispatch to # deactivated / left admins. Other paths (post_leaver # _alert_it_admins, advance_service notifications) filter # this way; the daily digest was missed → a former admin # kept receiving sensitive operational counts (new hires, # leavers by name, blocker counts). if ( admin.deleted_at is not None or (admin.employment_status or "").lower() != "active" ): continue try: await self.notify.dispatch( NotificationEnvelope( recipient=admin, title=f"[IT 日报] {today.isoformat()}", body_text=body, channels=["feishu", "email"], template_key="lifecycle_daily_digest", context={ "date": today.isoformat(), "body": body, "upcoming_hires": len(upcoming_hires), "today_leavers": len(today_leavers), "open_blockers": int(active_blockers), "open_incidents": int(total_open_incidents), }, ), trace_id="daily_digest", ) summary["notified_admins"] += 1 except Exception as exc: # noqa: BLE001 logger.warning( "daily_digest: failed to notify admin %s: %s", admin_id, exc, ) return summary __all__ = ["DailyDigestService"]