"""把谁拉进群 —— 以及「这个人今天在不在」。 ## 这解决的是什么 转人工的价值上限是「快」。主负责人休假两周,那两周里每一次「转人工」都会把员工和 一个**不会回话的人**关进一个房间 —— 那比降级成只建工单更糟,因为员工会**以为** 有人在看,于是他既不会去催、也不会另找路子。 所以拉人之前先问一句「他今天在吗」: 1. 主负责人**今天没有在批准中的休假**,并且有企业邮箱 → 拉他。 2. 否则拉**备份负责人**(路由表上的那一列)。 3. 两个都不行 → 返回空,调用方降级成只建工单。**拉不到人时说拉到了,是这条 链路最坏的失败。** ## 休假的判据与对外可用性接口同源 `hr_leave_absences` + `APPROVED / PENDING / PENDING_CANCELLATION` 三种状态算「出去 了」——和 `open_leave_router` 的 `_UNAVAILABLE_STATUSES` 逐字一致(那里的注释解释了 为什么 `PENDING_CANCELLATION` 和 NULL 都保守地算「不在」)。两处不一致的后果很具体: 对外接口说某人休假中,而 Helix 照样把他拉进群等他回话。 ⚠️ 休假数据来自 HRIS 同步,可能是**空的**(同步没跑、这个人不在推送人群里)。 「查不到休假记录」因此等于「在」——不能反过来:一个同步故障不该让所有转人工都 降级到备份负责人。 """ from __future__ import annotations import logging from dataclasses import dataclass from datetime import UTC, date, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) #: 「出去了」的三种审批状态。与 ``hr.open_leave_router._UNAVAILABLE_STATUSES`` #: 逐字一致 —— 两处不一致的后果是对外接口说某人休假中,而 Helix 照样把他拉进群。 _UNAVAILABLE_STATUSES = frozenset({"APPROVED", "PENDING", "PENDING_CANCELLATION"}) @dataclass(frozen=True, slots=True) class OwnerChoice: """拉谁进群,以及**为什么不是主负责人**。 ``reason`` 会出现在群里的开场白上:员工看到「主负责人休假中,先由备份负责人 接手」比看到一个陌生的名字要好得多 —— 后者会让他以为拉错了人。 """ person: IdentityPerson | None is_backup: bool = False reason: str | None = None async def on_leave_today( db: AsyncSession, person_id: object, *, today: date | None = None, ) -> bool: """这个人今天是不是在休假。 查不到记录 = 在。**不能反过来**:休假数据来自 HRIS 同步,可能压根是空的 (同步没跑、这个人不在推送人群里),而一个同步故障不该让所有转人工都降级到 备份负责人。 """ from app.domains.hr.leave_models import HrLeaveAbsence # noqa: PLC0415 day = today or datetime.now(UTC).date() # 这个 try 里**只有一条 SELECT**,一条都不写。让整个事务失效的是**失败的 # flush**,不是失败的读 —— 而调用方(开群那条链路)紧接着还要写。把一次纯读 # 包进 savepoint 只是给每一次拉人都加一次往返,换不到任何安全性。要挡的失败 # 很具体:休假表/列在这个库里不存在(同步没建、精简部署),而那时正确的答案是 # 「当他在」,不是让转人工整条链路挂掉。 # savepoint-exempt: 只读一条 SELECT,不写库;失败的读不会让事务失效 try: rows = (await db.execute( select(HrLeaveAbsence.approval_status).where( HrLeaveAbsence.person_id == str(person_id), HrLeaveAbsence.deleted_at.is_(None), HrLeaveAbsence.start_date.is_not(None), HrLeaveAbsence.end_date.is_not(None), HrLeaveAbsence.start_date <= day, HrLeaveAbsence.end_date >= day, ) )).scalars().all() except Exception: # noqa: BLE001 — 查不动就当他在 logger.warning( "assistant: leave lookup failed for %s — treating as available", person_id, exc_info=True, ) return False for status in rows: # NULL 也算「出去了」——与对外接口同一条保守判断(那里的注释:一个被 # 剥掉 approvalStatus 字段的租户不该让可用性被高报)。 if status is None or status in _UNAVAILABLE_STATUSES: return True return False async def reachable_owner( db: AsyncSession, *, route: object, today: date | None = None, ) -> OwnerChoice: """这条路由此刻真正能拉进群的那个人。 顺序:主负责人(有邮箱、今天不休假)→ 备份负责人 → 谁都没有。 """ primary = await _load(db, getattr(route, "owner_person_id", None)) backup = await _load(db, getattr(route, "backup_owner_person_id", None)) if primary is not None and (primary.work_email or "").strip(): if not await on_leave_today(db, primary.id, today=today): return OwnerChoice(person=primary) if backup is not None and (backup.work_email or "").strip(): return OwnerChoice( person=backup, is_backup=True, reason="owner_on_leave", ) # 主负责人休假、又没有备份 —— **还是拉主负责人**。一个休假中的负责人 # 好过一个空房间:他回来会看到,而员工至少在正确的房间里。这条选择 # 刻意留在这里而不是降级,因为降级的产物是「只有工单号」。 return OwnerChoice( person=primary, reason="owner_on_leave_no_backup", ) if backup is not None and (backup.work_email or "").strip(): return OwnerChoice( person=backup, is_backup=True, reason="owner_missing" if primary is None else "owner_unreachable", ) return OwnerChoice(person=None) async def _load(db: AsyncSession, person_id: object) -> IdentityPerson | None: if not person_id: return None person = await db.get(IdentityPerson, person_id) if person is None or person.deleted_at is not None: return None # 已经离职的负责人不该被拉进任何房间 —— 而路由表上那一行会留着,直到有人 # 改派。这正是运营台顶部那条「未指派」告警要显眼的原因。 if person.employment_status == "left": return None return person __all__ = ["OwnerChoice", "on_leave_today", "reachable_owner"]