"""Scheduled-job handler for NexusCRM seat sync. ``nexuscrm.seat_sync`` pulls every active NexusCRM ``User`` (a "seat") once a day and reconciles it with Helix's ``software_seat_allocations`` for the NexusCRM plan: * **Seat import** — every active CRM user whose email matches an ``identity_persons`` row but has no active :class:`SoftwareSeatAllocation` on the NexusCRM plan gets one created with ``reason='auto-imported from NexusCRM'``. This is "通过连接器 获取使用这个软件的人" — the people using NexusCRM become tracked seats so offboarding can find them. * **Drift cleanup** — Helix-side active seats whose person no longer appears in the CRM active-user list (deactivated on the CRM, or offboarded) are flipped to ``status=revoked`` so the dashboard count matches reality. This is a Helix-side DB state change only — it does NOT call the CRM (NexusCRM offboarding is manual_notify), so it works even while the IT Connected App is read-only. Seats younger than 2h are skipped to avoid racing with grants still in-flight. The IT Connected App's run-as user only needs read scope (``api.read``) for this sync — listing seats is a read. Salesforce platform / integration accounts (the Connected App's own run-as user, Automated Process, Watchdog test users, migration placeholders) carry org-id / ``.invalid`` / ``@salesforce.com`` emails that match no Helix person, so they surface as ``external`` holders and never become seats. Idempotent: re-running yields zero diffs in steady state. Per-user errors are collected into the summary instead of aborting the whole run. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import format_exception_for_log from app.scheduled_jobs._seat_sync_helpers import ( ExternalHolderObservation, import_dedup_filter, raise_if_total_failure, record_seat_reconciliation, safe_to_drift_revoke, write_drift_event_audit, write_sync_audit, ) from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) _JOB_KEY = "nexuscrm.seat_sync" @register_scheduled_job( key=_JOB_KEY, display_name="NexusCRM · 坐席同步", description=( "每天拉取 NexusCRM(Salesforce 兼容)的活跃 User 列表,把匹配到 " "Helix 员工的坐席镜像到 software_seat_allocations,并把 CRM 上已" "停用/离职对应的 Helix 坐席标为 revoked。平台/集成账号(org-id / " ".invalid / salesforce.com 邮箱)不匹配员工,会归到 external。" "只需 IT Connected App 的读 scope(api.read)。" ), ) async def run_nexuscrm_seat_sync( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: # Deadlock-victim restart on fresh sessions — same hardening as # slack.seat_sync. The reconcile is idempotent: import has the 25h # grace dedup filter, revoke has safe_to_drift_revoke. from app.scheduled_jobs.handlers import run_with_lock_retry return await run_with_lock_retry( db, lambda session: _run_once(session, config), label=_JOB_KEY, ) async def _run_once( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.core.coordinators.nexuscrm_seat_coordinator import ( get_nexuscrm_plan, load_nexuscrm_driver, ) from app.domains.asset.models import ( SoftwareExternalHolder, SoftwareSeatAllocation, SoftwareSeatSyncStat, ) from app.identity.email_lookup import build_person_by_email_index driver = await load_nexuscrm_driver(db) if driver is None: return { "members_listed": 0, "seats_imported": 0, "seats_revoked": 0, "errors": ["nexuscrm integration not configured or not enabled"], } plan = await get_nexuscrm_plan(db) if plan is None: return { "members_listed": 0, "seats_imported": 0, "seats_revoked": 0, "errors": [ "NexusCRM license plan not found; run migration 315 or " "create a plan whose parent catalog name contains 'NexusCRM'." ], } errors: list[str] = [] # 1. List active CRM users. A primary-list failure MUST raise so the # failure_notifier fires (returning early with errors=[...] lands # status='ok' and the admin gets no signal — the slack lesson). try: members = await driver.list_full_members() except Exception as e: # noqa: BLE001 # Use the canonical formatter (as the per-user paths below do): # a raw httpx timeout (ConnectTimeout / ReadTimeout) has an EMPTY # str(), so the naive f"...: {e}" rendered the alert as a # signal-free "list_full_members failed:" with nothing after the # colon. format_exception_for_log falls back to repr(exc) and # prepends the type name so the on-call always gets a lead. raise RuntimeError( f"nexuscrm.list_full_members failed: {format_exception_for_log(e)}" ) from e members_by_email: dict[str, dict[str, Any]] = {} members_no_email = 0 for m in members: email = str(m.get("email") or "").strip().lower() if email: members_by_email[email] = m else: members_no_email += 1 # 2. Resolve emails to Helix people (alias-aware; leavers excluded so # the daily reconcile doesn't re-import a seat for someone already # gone on the Helix side — ``leaver_pending`` inside the notice # period stays in scope, see ``leaver_notice_grace``). person_by_email = await build_person_by_email_index( db, members_by_email.keys(), exclude_employment_statuses=("left", "leaver_pending"), ) # 3. Existing seats in the (plan, user) scope (active/pending/recently # revoked — the 25h grace arm avoids racing a fresh revoke). now = datetime.now(UTC) existing_all = list((await db.execute( select(SoftwareSeatAllocation).where( SoftwareSeatAllocation.plan_id == plan.id, import_dedup_filter(SoftwareSeatAllocation, now=now), ) )).scalars().all()) existing_by_user_id = {s.user_id: s for s in existing_all} existing_seats = [s for s in existing_all if s.status == "active"] # 4. Back-fill seats for CRM users who don't have one yet. seats_imported = 0 matched_email_count = 0 external_observations: list[ExternalHolderObservation] = [] for email, sm in members_by_email.items(): person = person_by_email.get(email) if person is None: # CRM user whose email isn't a Helix person — external # collaborator, platform/integration account, or migration # placeholder. Not an error. external_observations.append(ExternalHolderObservation( email=email, vendor_account_id=sm.get("id"), account_status="active", display_name=sm.get("display_name"), )) continue matched_email_count += 1 if person.id in existing_by_user_id: continue # already mirrored try: async with db.begin_nested(): db.add(SoftwareSeatAllocation( plan_id=plan.id, user_id=person.id, status="active", allocated_at=now, reason="auto-imported from NexusCRM via nexuscrm.seat_sync", )) await db.flush() seats_imported += 1 except OperationalError: raise except Exception as e: # noqa: BLE001 errors.append( f"{email}: import failed: {format_exception_for_log(e)}" ) if len(errors) > 30: errors.append( f"...truncated (total emails={len(members_by_email)})", ) break # 5. Drift cleanup — Helix active seats whose CRM presence is gone. # Guarded by safe_to_drift_revoke (refuses mass-revoke on a truncated # listing). DB-state-only: does NOT call the CRM. seats_revoked = 0 active_user_ids: set = set() for email in members_by_email: p = person_by_email.get(email) if p is not None: active_user_ids.add(p.id) safe, reason = safe_to_drift_revoke( matched_count=len(members_by_email), existing_count=len(existing_seats), label="NexusCRM seat sync", want_count=len(active_user_ids), ) if not safe: errors.append(reason or "drift cleanup refused") else: for seat in existing_seats: if seat.user_id in active_user_ids: continue allocated = seat.allocated_at if allocated is not None: if allocated.tzinfo is None: allocated = allocated.replace(tzinfo=UTC) if (now - allocated).total_seconds() < 7200: continue # too fresh — grant may be in flight seat_id, seat_user_id, seat_plan_id = ( seat.id, seat.user_id, seat.plan_id, ) try: async with db.begin_nested(): seat.status = "revoked" seat.revoked_at = now seat.reason = ( (seat.reason or "") + "\n[auto-revoked by nexuscrm.seat_sync: " "user no longer an active CRM user]" ).strip() await write_drift_event_audit( db, job_key=_JOB_KEY, seat_id=seat_id, user_id=seat_user_id, plan_id=seat_plan_id, event="revoked", detail={"reason": "user no longer an active CRM user"}, ) await db.flush() seats_revoked += 1 except OperationalError: raise except Exception as e: # noqa: BLE001 errors.append( f"seat {seat_id}: drift revoke failed: " f"{format_exception_for_log(e)}" ) await record_seat_reconciliation( db, holder_model=SoftwareExternalHolder, stat_model=SoftwareSeatSyncStat, integration_id=uuid.UUID(driver.integration_id), software_id=plan.software_id, plan_id=plan.id, provider="nexuscrm", product_key=None, site=None, vendor_license_holders=len(members_by_email) + members_no_email, holders_matched=matched_email_count, holders_no_email=members_no_email, externals=external_observations, now=now, ) summary = { "members_listed": len(members), "active_users_with_email": len(members_by_email), "seats_imported": seats_imported, "seats_revoked": seats_revoked, "external_unmatched": len(external_observations), "errors": errors, } await write_sync_audit(db, job_key=_JOB_KEY, summary=summary) await db.commit() raise_if_total_failure( job_key=_JOB_KEY, summary=summary, progress_keys=("seats_imported", "seats_revoked"), ) return summary