"""Scheduled-job handlers for support-ticket lifecycle bookkeeping. ``support.ticket_autoclose`` (seeded by migration 487): a resolved ticket the requester neither confirms nor reopens within ``config.resolved_days`` (default 7) closes automatically — ends the "parked in resolved forever" state and bounds the CSAT rating window. Bounded per run (500), idempotent, no notification fan-out. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.locale import Lang, resolve_lang from app.domains.support.models import ( OPEN_STATUSES, STATUS_RESOLVED, STATUS_WAITING_REQUESTER, SupportTicket, ) from app.domains.support.service import SupportService from app.identity.person_models import IdentityPerson from app.notifications import fanout from app.notifications.service import NotificationEnvelope from app.scheduled_jobs.failure_notifier import list_admin_recipients from app.scheduled_jobs.registry import register_scheduled_job def _int_config(config: dict[str, Any], key: str, default: int) -> int: try: return max(1, int((config or {}).get(key) or default)) except (TypeError, ValueError): return default def _naive(value: datetime) -> datetime: return value.replace(tzinfo=None) if value.tzinfo is not None else value _REMINDER_REASONS: dict[str, dict[Lang, str]] = { "unassigned": { "zh": "工单尚未认领", "en": "The ticket has not been assigned", "ja": "チケットがまだ割り当てられていません", }, "first_response_due": { "zh": "首次响应即将或已经超时", "en": "The first-response target is near or overdue", "ja": "初回応答期限が迫っているか超過しています", }, "resolution_due": { "zh": "解决时限即将或已经超时", "en": "The resolution target is near or overdue", "ja": "解決期限が迫っているか超過しています", }, "waiting_requester": { "zh": "IT 正在等待你补充信息", "en": "IT is waiting for more information from you", "ja": "IT が追加情報をお待ちしています", }, "confirmation_due": { "zh": "工单已解决,等待你确认或重新打开", "en": "The ticket is resolved and waiting for your confirmation or reopening", "ja": "チケットは解決済みです。確認または再オープンをお願いします", }, } def _reminder_reason(code: str, person: IdentityPerson) -> str: return _REMINDER_REASONS[code][resolve_lang(person)] def _nudge_room(ticket: Any, *, reason: str, reminder_count: int) -> None: """把这一轮催办也送进工单群(如果有群)。 走 ``core.coordinators``(求助域不认识助手层),**只排一个后台任务**:一次 飞书往返绝不能待在这个还没提交的事务里,而这个任务会自己重读工单 —— 于是 催办的水位提交失败时,群里也不会出现一句关于没发生的催办的话。 绝大多数工单没有群 —— 任务里一次 SELECT 就返回。 """ from app.core.coordinators.ticket_chat_coordinator import ( # noqa: PLC0415 schedule_room_sla_nudge, ) schedule_room_sla_nudge( ticket.id, reason=reason, reminder_count=reminder_count, ) @register_scheduled_job( key="support.ticket_sla_reminder", display_name="工单 · SLA 与员工待办催办", description="按当前责任方提醒即将/已经超时的 IT,以及等待补充或确认的员工。", ) async def run_support_ticket_sla_reminder( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: """State-aware reminders with one durable outbox row per recipient.""" now = datetime.now(UTC).replace(tzinfo=None) cooldown = timedelta(hours=_int_config(config, "cooldown_hours", 8)) requester_cooldown = timedelta( hours=_int_config(config, "requester_cooldown_hours", 24), ) unassigned_wait = timedelta(hours=_int_config(config, "unassigned_after_hours", 1)) requester_wait = timedelta(hours=_int_config(config, "requester_wait_hours", 48)) confirm_wait = timedelta(hours=_int_config(config, "resolved_confirm_hours", 72)) tickets = ( ( await db.execute( select(SupportTicket) .where(SupportTicket.status.in_((*OPEN_STATUSES, STATUS_RESOLVED))) .order_by(SupportTicket.created_at.asc()) .limit(500) ) ) .scalars() .all() ) admins: list[IdentityPerson] | None = None envelopes: list[NotificationEnvelope] = [] agent_tickets = requester_tickets = 0 for ticket in tickets: agent_reason_code: str | None = None if ticket.status != STATUS_WAITING_REQUESTER and ticket.status != STATUS_RESOLVED: if ticket.assignee_id is None and _naive(ticket.created_at) <= now - unassigned_wait: agent_reason_code = "unassigned" elif ticket.first_response_at is None and ticket.first_response_due_at is not None: if _naive(ticket.first_response_due_at) <= now + timedelta(minutes=30): agent_reason_code = "first_response_due" elif ( ticket.resolution_due_at is not None and _naive(ticket.resolution_due_at) <= now + timedelta(hours=1) ): agent_reason_code = "resolution_due" if agent_reason_code and ( ticket.last_agent_reminded_at is None or _naive(ticket.last_agent_reminded_at) <= now - cooldown ): recipients: list[IdentityPerson] = [] if ticket.assignee_id: assignee = await db.get(IdentityPerson, ticket.assignee_id) if assignee is not None: recipients = [assignee] if not recipients: if admins is None: admins = await list_admin_recipients(db) recipients = admins ticket.last_agent_reminded_at = now ticket.agent_reminder_count = int(ticket.agent_reminder_count or 0) + 1 # 这张工单如果有飞书群,群里也说一句 —— 催办卡片只发给处理人一个 # 人,而求助人就在同一个房间里等着;一条只有处理人看见的催办,对 # 员工而言与「没人管」没有区别,而他会另开一张工单。 # # **复用这里的水位与冷却**(上面两行),不另立一套时间表:「多久算 # 超时」只该有一处定义,而这类数只能拉长不能收紧。 _nudge_room( ticket, reason=agent_reason_code, reminder_count=int(ticket.agent_reminder_count), ) for recipient in recipients: agent_reason = _reminder_reason(agent_reason_code, recipient) envelopes.append( NotificationEnvelope( recipient=recipient, title=f"工单催办 · {ticket.ticket_number}", body_text=f"{agent_reason}:{ticket.title}\n\n请进入 IT 支持台处理。", channels=["feishu", "email"], template_key="support.ticket_sla_reminder.to_agent", context={ "ticket_number": ticket.ticket_number, "ticket_title": ticket.title, "reason": agent_reason, }, url=f"/admin/support/tickets/{ticket.id}", related_resource_type="support_ticket", related_resource_id=ticket.id, ) ) agent_tickets += 1 requester_reason_code: str | None = None if ( ticket.status == STATUS_WAITING_REQUESTER and _naive(ticket.updated_at) <= now - requester_wait ): requester_reason_code = "waiting_requester" elif ( ticket.status == STATUS_RESOLVED and ticket.resolved_at is not None and _naive(ticket.resolved_at) <= now - confirm_wait ): requester_reason_code = "confirmation_due" if requester_reason_code and ( ticket.last_requester_reminded_at is None or _naive(ticket.last_requester_reminded_at) <= now - requester_cooldown ): requester = await db.get(IdentityPerson, ticket.requester_id) if requester is not None: requester_reason = _reminder_reason(requester_reason_code, requester) ticket.last_requester_reminded_at = now ticket.requester_reminder_count = int(ticket.requester_reminder_count or 0) + 1 envelopes.append( NotificationEnvelope( recipient=requester, title=f"你的 IT 工单需要处理 · {ticket.ticket_number}", body_text=f"{requester_reason}:{ticket.title}", channels=["feishu", "email"], template_key="support.ticket_sla_reminder.to_requester", context={ "ticket_number": ticket.ticket_number, "ticket_title": ticket.title, "reason": requester_reason, }, url=f"/me/support/{ticket.id}", related_resource_type="support_ticket", related_resource_id=ticket.id, ) ) requester_tickets += 1 if envelopes: # The first durable outbox insert commits the reminder watermark with # it. A crash before enqueue therefore leaves both uncommitted and the # next tick retries, instead of suppressing an alert that never existed. await fanout.enqueue_then_drain( db, envelopes, trace_id="", label="support.ticket_sla_reminder", ) return { "scanned": len(tickets), "agent_tickets": agent_tickets, "requester_tickets": requester_tickets, "notifications": len(envelopes), } @register_scheduled_job( key="support.ticket_autoclose", display_name="求助 · 已解决工单超时自动关闭", description=( "resolved 超过 N 天(config.resolved_days,默认 7)员工未确认也未重开" "的工单自动关闭,记 system 时间线消息 + 审计;同时为满意度回评窗口收口。" ), ) async def run_support_ticket_autoclose( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: try: days = int((config or {}).get("resolved_days") or 7) except (TypeError, ValueError): days = 7 try: deflect_days = int((config or {}).get("deflect_open_expire_days") or 3) except (TypeError, ValueError): deflect_days = 3 try: assistant_days = int((config or {}).get("assistant_abandon_days") or 7) except (TypeError, ValueError): assistant_days = 7 svc = SupportService(db) closed = await svc.auto_close_stale_resolved(days=days) # Second funnel: deflection sessions the employee never resolved. No # longer a pure bookkeeping sweep — each one is escalated to a real # ticket so the request reaches a human instead of being closed out # unseen (see ``expire_stale_deflections``). Counts are reported # separately so a run that escalates nothing is visible. deflect = await svc.expire_stale_deflections(days=deflect_days) from app.core.coordinators.assistant_support_coordinator import ( # noqa: PLC0415 expire_inactive_assistant_requests, ) assistant_abandoned = await expire_inactive_assistant_requests( db, before=datetime.now(UTC) - timedelta(days=max(1, assistant_days)), ) await db.commit() return { "closed": closed, "resolved_days": days, "deflect_expired": deflect.get("scanned", 0), "deflect_auto_escalated": deflect.get("escalated", 0), "deflect_unescalated": deflect.get("unescalated", 0), "deflect_open_expire_days": deflect_days, "assistant_abandoned": assistant_abandoned, "assistant_abandon_days": assistant_days, }