"""Scheduled job: remind interns / leaders sitting on a pending attendance report. Gentle, bounded, opt-out-able: * only reports already in ``pending_intern`` / ``pending_leader`` (never a new audience — they were sent at least once already), * only those untouched for ``remind_after_hours`` (default 48h), * at most ``max_per_run`` cards per run (storm cap), * never re-nagged more often than ``remind_after_hours`` (``last_reminded_at``), * the whole job is a no-op when ``attendance.reminder_enabled`` is false. """ from __future__ import annotations import logging from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.attendance import constants as c from app.domains.attendance.lifecycle import AttendanceLifecycleService from app.domains.attendance.models import AttendanceReport, AttendanceReportEvent from app.integrations.service import get_platform_setting from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) _PENDING = (c.STATUS_PENDING_INTERN, c.STATUS_PENDING_LEADER) @register_scheduled_job( key="attendance.pending_reminder", display_name="考勤月报 · 待确认提醒", description=( "每日提醒在「待实习生确认 / 待 leader 确认」状态停留超过阈值" "(默认 48 小时)的考勤月报相关人。仅提醒已发送过的在途报告(不新增受众)," "每轮有上限,且不会比阈值更频繁地重复提醒。可经 " "attendance.reminder_enabled 平台开关整体关闭。" ), ) async def run_attendance_pending_reminder( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: enabled = config.get("enabled") if enabled is None: enabled = await get_platform_setting( c.SETTING_REMINDER_ENABLED, True, session=db, ) if not enabled: return {"status": "skipped", "reason": "reminders disabled", "reminded": 0} max_per_run = int( config.get("max_per_run") or await get_platform_setting(c.SETTING_REMINDER_MAX, 50, session=db) or 50 ) remind_after_hours = int(config.get("remind_after_hours", 48)) now = datetime.now(UTC) cutoff = now - timedelta(hours=remind_after_hours) # Fetch pending reports not reminded within the window, oldest first. rows = ( await db.execute( select(AttendanceReport) .where( AttendanceReport.status.in_(_PENDING), or_( AttendanceReport.last_reminded_at.is_(None), AttendanceReport.last_reminded_at < cutoff, ), ) .order_by(AttendanceReport.updated_at) .limit(max_per_run * 3) # over-fetch; anchor filter trims below ) ).scalars().all() lifecycle = AttendanceLifecycleService(db) reminded = 0 skipped_recent = 0 for report in rows: if reminded >= max_per_run: break # Stage-appropriate "waiting since" anchor. anchor = ( report.last_sent_at if report.status == c.STATUS_PENDING_INTERN else report.intern_confirmed_at ) if anchor is not None and anchor.tzinfo is None: anchor = anchor.replace(tzinfo=UTC) if anchor is None or anchor >= cutoff: skipped_recent += 1 continue try: async with db.begin_nested(): # Re-read in case the report raced to a terminal/other # state since the batch was fetched — don't nag someone # who already acted. await db.refresh(report) if report.status not in _PENDING: continue if report.status == c.STATUS_PENDING_INTERN: await lifecycle._dispatch_intern_card(report) else: await lifecycle._dispatch_leader_card(report) report.last_reminded_at = now db.add(AttendanceReportEvent( report_id=report.id, event_type=c.EVENT_REMINDED, to_status=report.status, detail_json={"stage": report.status}, )) except Exception: # noqa: BLE001 — isolate one bad report logger.warning( "attendance: reminder failed for report %s", report.id, exc_info=True, ) continue reminded += 1 await db.commit() return { "status": "ok", "reminded": reminded, "candidates": len(rows), "skipped_recent": skipped_recent, }