"""Resolve the intern roster for an attendance cycle — from Feishu, patched by an HR-maintained overlay. The "who is an intern" question is answered by membership of the Feishu **通讯录 department** named by ``attendance.intern_department`` (default ``"PingCAP Intern"``). Helix's own org tree is HRIS-sourced (SuccessFactors) and does NOT mirror that Feishu grouping, so the roster is pulled live from the Feishu contact API and matched to Helix persons by work-email. On top of that base, HR can maintain an **overlay** (see ``AttendanceRosterOverride``) so the auto-pull never has to be perfect: effective roster = (Feishu members ∪ manual includes) − manual excludes ``include`` drags an active Helix person into the report even when they are not in the Feishu department (the 许晴 case — onboarded in Helix but never added to the Feishu group); ``exclude`` keeps a Feishu member out. Returns two lists: * ``mapped`` — live Helix persons (active / leaver_pending) either matched by Feishu email or manually included; these get full reports (leader = ``manager_person_id``). Manual excludes are removed. * ``unmapped`` — Feishu members with no matching active Helix person (interns not in HRIS / not yet onboarded in Helix). Surfaced to HR as 「待补」 rather than silently dropped. """ from __future__ import annotations from dataclasses import dataclass, field from uuid import UUID from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.attendance.feishu_attendance import FeishuAttendanceClient from app.domains.attendance.models import AttendanceRosterOverride from app.identity.person_models import IdentityPerson # Employment statuses still considered "current staff" for attendance — # active and those serving notice. Excludes ``pre_hire`` and ``left``. _ROSTER_STATUSES = ("active", "leaver_pending") MODE_INCLUDE = "include" MODE_EXCLUDE = "exclude" async def load_roster_overrides( db: AsyncSession, ) -> tuple[set[UUID], set[UUID]]: """Return ``(include_person_ids, exclude_person_ids)`` from the overlay.""" rows = ( await db.execute(select(AttendanceRosterOverride)) ).scalars().all() includes = {r.person_id for r in rows if r.mode == MODE_INCLUDE} excludes = {r.person_id for r in rows if r.mode == MODE_EXCLUDE} return includes, excludes @dataclass class InternRoster: mapped: list[IdentityPerson] = field(default_factory=list) unmapped: list[dict] = field(default_factory=list) # {name, email, open_id} department_found: bool = True # Manual-exclude person ids from the overlay — surfaced so generation # can prune already-created report rows for people HR excluded after # a previous generate (otherwise the stale rows linger forever). excluded_ids: set[UUID] = field(default_factory=set) async def resolve_intern_roster( db: AsyncSession, department: str, feishu_client: FeishuAttendanceClient, ) -> InternRoster: members, dept_found = await feishu_client.list_intern_members(department) if not dept_found: return InternRoster(department_found=False) includes, excludes = await load_roster_overrides(db) emails = sorted({m["email"] for m in members if m.get("email")}) # Feishu 号 email is often ``@pingcap.com.cn`` while Helix work_email is # ``@pingcap.com`` — a full-string match silently drops those interns to # 待补 (the documented .com/.com.cn mismatch). Match by local-part too, # exactly like the identity-sync side, so a domain difference doesn't # lose the person. Full-email match still wins when both agree. local_parts = sorted({e.split("@", 1)[0] for e in emails if "@" in e}) persons_by_email: dict[str, IdentityPerson] = {} persons_by_localpart: dict[str, IdentityPerson] = {} ambiguous_localparts: set[str] = set() if emails: rows = ( await db.execute( select(IdentityPerson).where( or_( func.lower(IdentityPerson.work_email).in_(emails), func.substring_index( func.lower(IdentityPerson.work_email), "@", 1, ).in_(local_parts), ), IdentityPerson.employment_status.in_(_ROSTER_STATUSES), IdentityPerson.deleted_at.is_(None), ) ) ).scalars().all() for p in rows: we = (p.work_email or "").lower() persons_by_email[we] = p lp = we.split("@", 1)[0] # A local part can legitimately exist under two domains. The # query has no stable ordering and choosing either person could # put attendance facts on the wrong payroll record. Exact email # still wins; an ambiguous fallback remains visible as 待补. previous = persons_by_localpart.get(lp) if previous is not None and previous.id != p.id: ambiguous_localparts.add(lp) else: persons_by_localpart[lp] = p mapped: list[IdentityPerson] = [] unmapped: list[dict] = [] seen_person: set = set() for m in members: email = m.get("email") or "" person = persons_by_email.get(email) if person is None and "@" in email and email.split("@", 1)[0] not in ambiguous_localparts: person = persons_by_localpart.get(email.split("@", 1)[0]) if person is not None: # Manual exclude keeps a Feishu member out of the report. if person.id in excludes or person.id in seen_person: continue seen_person.add(person.id) mapped.append(person) else: unmapped.append({ "name": m.get("name") or "", "email": email, "open_id": m.get("open_id") or "", }) # Manual includes: drag active Helix persons into the roster even when # they are not in the Feishu department. Excludes win over includes # (UNIQUE(person_id) means a person is never in both, but guard anyway). include_ids = [pid for pid in includes if pid not in excludes and pid not in seen_person] if include_ids: extra = ( await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_(include_ids), IdentityPerson.employment_status.in_(_ROSTER_STATUSES), IdentityPerson.deleted_at.is_(None), ) ) ).scalars().all() for p in extra: if p.id not in seen_person: seen_person.add(p.id) mapped.append(p) mapped.sort(key=lambda p: (p.display_name or p.work_email or "").lower()) return InternRoster( mapped=mapped, unmapped=unmapped, department_found=True, excluded_ids=excludes, )