"""Scheduled sweep that keeps the approval-flow register current. One handler, three phases (discovery → definition sync → owner inference) — see :mod:`app.domains.approval_registry.discovery` for why they are one job: they share a roster, a Feishu client and an email→ user_id resolution that costs ten calls, and splitting them would run that three times a night to no benefit. """ from __future__ import annotations import uuid from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.domains.approval_registry import constants as const from app.scheduled_jobs.registry import register_scheduled_job @register_scheduled_job( key="approval_registry.feishu_sync", display_name="审批流程台账 · 每晚同步飞书", description=( "每晚 03:30 (Asia/Shanghai) 刷新审批流程台账:\n" "① 发现 —— 按在职员工扇出 POST /approval/v4/instances/query," "收集全公司在用的审批定义(code/名称/分组)。飞书没有「列出全部" "审批定义」的接口,按人查实例是唯一能枚举的路;分组只从这个接口取," "tasks/search 的 group 字段实测同一定义会返回多个不同分组名。\n" "② 定义同步 —— 对台账里每个 code 调 GET /approval/v4/approvals/" "{code}?with_admin_id=true 刷新名称、状态与**流程管理员**" "(改名、停用、换管理员会自动反映到公示页)。with_admin_id 这个参数" "是关键:不带它响应里根本没有 approval_admin_ids 这个字段,台账" "最初就是这么把「飞书没有负责人」写进了自己的注释,导致 49 条里 32 条" "长期公示「还没人认领」。只有一名管理员且在职时,台账认领为" "「飞书流程管理员·待确认」;多名管理员只公示名单,不硬选一个人。" "刻意不订阅事件:那是别的团队的流程,订阅等于把它们的实例事件" "全导到 Helix 的 webhook。\n" "③ 负责人推断 —— 按 tasks/search 统计谁在实际处理各流程的审批" "任务;占比过半且样本足够才写成「推断·待确认」,人工指派/确认过的" "以及②认领到的飞书流程管理员一律不覆盖(声明优先于统计)。" "达不到阈值的留「待认领」,不硬塞一个人进去。\n" "默认按 30 天增量窗口(飞书对实例时间跨度的硬上限);" "全量重扫在 /admin/approval-flows 手动触发。" ), # Fans out over the whole active roster across two Feishu search # endpoints — minutes of vendor calls per run. That is the registry's # ``heavy`` criterion; defaulting to ``light`` would let this sweep # occupy a general slot that fast critical crons need. weight="heavy", ) async def run_approval_registry_sync( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.approval_registry import discovery raw_window = config.get("window_days", 30) # ``0`` / ``null`` in the job config means "sweep everything visible" # — the manual full re-scan. Anything else is clamped to Feishu's # 30-day instance-window ceiling by the sweep itself. window_days = int(raw_window) if raw_window else None result = await discovery.run_sync( db, window_days=window_days, max_pages=int(config.get("max_pages", const.DISCOVERY_MAX_PAGES)), concurrency=int(config.get("concurrency", const.DISCOVERY_CONCURRENCY)), discover=bool(config.get("discover", True)), sync_definitions=bool(config.get("sync_definitions", True)), infer_owners=bool(config.get("infer_owners", True)), ) await db.commit() return result @register_scheduled_job( key="approval_registry.owner_confirmation_semiannual", display_name="审批流程台账 · 半年度负责人确认开盘", description=( "每日 08:30 (Asia/Shanghai) 运行,但**仅在边界后 7 天窗口内开盘**:" "6 月 1 日开 H1、12 月 1 日开 H2。开盘时给每位在册负责人发**一张**" "飞书卡片(不是每条流程一张 —— 名下九条就是九条列在一张卡上)," "卡片带免登录的「全部确认」按钮(action token,purpose 限定," "不能被挪用到别的动作)和「逐条处理/转交」深链。\n" "刻意不追溯已过的历史边界:中途部署的第一件事不应该是给全公司" "负责人补推一轮卡片(同半年度访问自查的存量风暴护栏)。" "错过的当期由管理员在台账页手动开盘。\n" "没有负责人的流程不进这一轮 —— 没有人可问,它们已经被覆盖率读面" "盯着,放进来只会压低一个衡量「有没有人回」的完成率。" ), weight="heavy", ) async def run_owner_confirmation_open( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import notify result = await conf.auto_open_semiannual( db, window_days=int(config.get("window_days", const.ROUND_WINDOW_DAYS)), due_days=int(config.get("due_days", const.ROUND_DUE_DAYS)), ) if not result.get("opened"): await db.commit() return result round_row = await conf.get_round(db, uuid.UUID(result["round_id"])) items = await conf.round_items(db, round_row.id) # Commit the round + items BEFORE the fan-out: a Feishu hiccup # halfway through must not roll back a campaign whose earlier cards # already went out, or the next run opens a second one. await db.commit() notified = await notify.notify_confirmation_round(db, round_row, items) round_row.notified_owners = notified await db.commit() return {**result, "owners_notified": notified} @register_scheduled_job( key="approval_registry.owner_confirmation_escalate", display_name="审批流程台账 · 确认逾期升级", description=( "每日 09:30 (Asia/Shanghai)。开盘 14 天后仍未回复的项,给负责人再推" "一次并**抄送其直属上级**(上级不替他回答 —— 这类事通常一句话就能" "推动,而沉默的真实原因往往是「本来就不该是他」)。按人去重:" "名下六条未回只推一次。已升级过的不再重复。\n" "同时做两件收尾:①把中途失效的项标 dropped(流程已归档 / 被问的人" "已离职 —— 追一个已归档流程的回复不是治理缺口);②到期的轮次自动" "关闭,未回复的记为 lapsed 并冻结完成率 —— 关盘时「还在等」和" "「从来没人回」的区别就是这轮的全部发现。" ), ) async def run_owner_confirmation_escalate( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from datetime import UTC, datetime from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import notify round_row = await conf.current_open_round(db) if round_row is None: return {"round": None, "reason": "no open round"} dropped = await conf.drop_stale_items(db, round_row) await db.commit() # Re-send anything whose card never went out. The opening fan-out # runs once; a Feishu outage in that minute would otherwise cost the # entire half-year round, because escalation deliberately refuses to # chase an ask nobody received. resent = 0 never_asked = await conf.unnotified_items(db, round_row) if never_asked: resent = await notify.notify_confirmation_round( db, round_row, never_asked, ) round_row.notified_owners = (round_row.notified_owners or 0) + resent await db.commit() overdue = await conf.overdue_items( db, round_row, after_days=int(config.get("after_days", const.ESCALATE_AFTER_DAYS)), ) escalated = 0 if overdue: escalated = await notify.notify_confirmation_overdue( db, round_row, overdue, ) await db.commit() closed = False due = round_row.due_at if due is not None: now = datetime.now(UTC) due_aware = due if due.tzinfo else due.replace(tzinfo=UTC) if now >= due_aware: await conf.close_round(db, round_row) await db.commit() closed = True return { "round": round_row.label, "dropped": dropped, "cards_resent": resent, "escalated_owners": escalated, "closed": closed, "summary": await conf.round_summary(db, round_row), } @register_scheduled_job( key="approval_registry.feishu_admin_drift", display_name="审批流程台账 · 飞书后台待同步提醒", description=( "每日 09:00 (Asia/Shanghai)。台账能改负责人,飞书审批后台不能 —— " "审批 v4 的定义接口只有创建 / 读取 / 订阅,**没有更新**,流程管理员" "没有写接口。所以每一次在 Helix 里换负责人(半年度确认里员工点" "「转交」、离职交接、IT 重新指派)都必然留下一处不一致:台账记新人," "后台还点着原来那位。后台那份名单才是决定谁能编辑流程、看实例数据、" "收流程管理员通知的东西 —— 新负责人于是有责任没权限,而通知还在发给" "已经交接出去的人。\n" "这个任务不修数据(修不了),它只保证这处不一致不是隐形的:给在职 " "global_admin 发**一张**汇总卡(不是每条流程一张 —— 离职交接会一次" "搬走一个人名下所有流程),列出「台账负责人 vs 飞书流程管理员」并直接" "给后台入口。\n" "漂移本身是**每次读时算出来的**(owner_person_id 是否在 " "feishu_admins[*].person_id 里),不落库标记:所以有人在后台改好之后," "每晚 03:30 的同步重新读回名单,这一条自动消失,不需要谁回来点" "「已处理」。落库的只有「上次是什么时候告知的」,用来做 14 天再提醒;" "不再漂移的行会把这个时间戳清空,于是同一条流程再次被改坏时是一条" "新发现,而不是被永久静音。\n" "从没读过管理员名单的行(feishu_admins_synced_at 为空)不算漂移 —— " "559 之前的同步根本没带 with_admin_id 参数,把「没问过」当成「对不上」" "会在上线当天点亮整个台账。" ), ) async def run_feishu_admin_drift_digest( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from datetime import UTC, datetime, timedelta from app.domains.approval_registry import notify from app.domains.approval_registry import service as svc renag_days = int(config.get("renag_days", const.DRIFT_RENAG_DAYS)) drifting = await svc.flows_with_feishu_admin_drift(db) drift_ids = {f.id for f in drifting} # ① Clear the stamp on rows that no longer drift, BEFORE deciding # what to raise. Somebody fixed the console (or the flow was # re-pointed back), and leaving the stamp behind would silently # suppress the SAME flow being broken again next month — the digest # would skip it as 「already told them」 about a finding that had # been resolved and re-created in between. healed = 0 for flow in await svc.flows_with_stale_drift_stamp(db, drift_ids): flow.feishu_admin_drift_notified_at = None healed += 1 now = datetime.now(UTC).replace(tzinfo=None) cutoff = now - timedelta(days=renag_days) due = [ f for f in drifting if f.feishu_admin_drift_notified_at is None or f.feishu_admin_drift_notified_at < cutoff ] if not due: await db.commit() return { "drifting": len(drifting), "healed": healed, "raised": 0, "operators_notified": 0, "reason": ( "nothing new to raise" if drifting else "register and Feishu console agree" ), } # Commit the healed stamps before the fan-out: a Feishu hiccup # mid-digest must not roll back bookkeeping that was already true. await db.commit() notified = await notify.notify_feishu_admin_drift(db, due) if notified: # Stamped only when somebody was actually reached. Stamping on a # failed fan-out would mean a Feishu outage in this one minute # buys the whole backlog 14 days of silence. for flow in due: flow.feishu_admin_drift_notified_at = now await db.commit() return { "drifting": len(drifting), "healed": healed, "raised": len(due), "operators_notified": notified, "flows": [f.approval_code for f in due][:20], }