"""Onboarding domain models — symmetric to the offboarding domain. OnboardingProcess — one row per person undergoing onboarding. Created when HRIS sync confirms a hire event. Transitions: scanning — just created; service is enumerating Day-1 blockers preparing — pre-arrival, ≥1 blocker with resolution='pending' (hire_date is still in the future) ready — all blockers resolved AND hire_date is still in the future live — hire_date reached; the person is in-seat. Process stays ``live`` until the T+7 verify cron confirms case-file completed — T+7 verify passed; all 7+ dimensions still ready cancelled — admin aborted (HRIS flipped the hire back to ``rescinded``) OnboardingBlocker — one row per missing-or-pending Day-1 dimension. Asymmetric to the offboarding blocker shape: instead of pointing at a resource the leaver still HOLDS, an onboarding blocker points at a resource the hire DOESN'T-YET-HAVE (or has but not yet provisioned). OnboardingReminderLog — idempotency markers for the escalation tick. Reminder kinds are all IT-facing: ``day0_it_gaps`` / ``sla_urgent`` / ``sla_breach`` / ``t7_verify_failed``. (Manager-facing pre-arrival reminder cards were removed 2026-06-15 — HRIS already notifies managers of joins/leaves, and account/asset readiness is IT's worklist, not the manager's. Helix only pushes when there is a concrete IT action.) """ from __future__ import annotations import uuid from datetime import date as date_type from datetime import datetime from typing import Any from sqlalchemy import ( JSON, Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base from app.core.sql_types import GUID as Uuid # Process statuses PROC_SCANNING = "scanning" PROC_PREPARING = "preparing" PROC_READY = "ready" PROC_LIVE = "live" PROC_COMPLETED = "completed" PROC_CANCELLED = "cancelled" PROC_OPEN_STATUSES = (PROC_SCANNING, PROC_PREPARING, PROC_READY, PROC_LIVE) PROC_TERMINAL_STATUSES = (PROC_COMPLETED, PROC_CANCELLED) # Blocker resource types — inbound mirror of offboarding's outbound list. # Each resource type describes a Day-1 capability the hire needs in place # before they can do their job; the resource_id (if any) points at the # provisioning row (allocation, asset_item, vpn_application, …) that # satisfies it. RES_WORK_EMAIL = "work_email" RES_SSO_PROVISION = "sso_provision" RES_FEISHU_CONTACT = "feishu_contact" RES_SOFTWARE_SEAT = "software_seat" RES_HARDWARE_ALLOCATION = "hardware_allocation" RES_VPN = "vpn" # Stubbed in PR-1 — populated by PR-2/3/4 once the catalog defaults and # self-service flows ship. Kept as constants here so the scanner can # enumerate every resource type a complete onboarding tracks without a # follow-up migration: RES_EMAIL_GROUP = "email_group" RES_AI_TOOL_DEFAULT = "ai_tool_default" RES_TRAINING_ACK = "training_ack" # Resolutions RESO_PENDING = "pending" RESO_PROVISIONED = "provisioned" # Admin manual confirmation ("标记已发放") — a STICKY resolution distinct # from the scanner's auto-written ``provisioned``. The scanner re-pends a # ``provisioned`` blocker when the underlying resource still reads absent # (device handed out off-books / AssetItem not entered / VPN not via # VpnApplication); it must NOT re-pend a row an admin explicitly confirmed # out-of-band, or every 「标记已发放」 click is silently reverted on the next # 5-min tick and re-pages IT with the same escalation card. The re-pend # branch keys on ``RESO_PROVISIONED`` only, so this value survives rescans; # every "resolved vs pending" check keys on ``!= RESO_PENDING`` so it counts # as resolved everywhere (triage buckets, SLA, cockpit, analytics, t7). RESO_MANUAL_PROVISIONED = "manual_provisioned" RESO_SKIPPED = "skipped" # Resource lifecycle is owned by an Identity Sync Center target # (``feishu_contact`` / future ``google_contact``). The scanner records # this resolution to make the deferral explicit in the audit trail — # distinct from ``skipped`` because the platform is choosing to wait # for identity_sync, not abandoning the dimension. RESO_IDENTITY_SYNC_MANAGED = "identity_sync_managed" # Severities — mirrors offboarding's Round-14 split. Default ``blocker`` # so any new resource type added to the scanner without an explicit # severity entry surfaces in the red queue rather than slipping silently # into auto-handled. SEVERITY_BLOCKER = "blocker" SEVERITY_ADVISORY = "advisory" # SLA breach levels — measured from how long the process has been # ``preparing`` past ``hire_date``. # # none — hire_date hasn't passed yet, or no remaining blockers # warn — 0–24h past hire_date with hard blockers — manager DM'd # urgent — 24–72h past hire_date — IT admin DM'd # breach — > 72h past hire_date — critical compliance_incident SLA_NONE = "none" SLA_WARN = "warn" SLA_URGENT = "urgent" SLA_BREACH = "breach" class OnboardingProcess(Base): __tablename__ = "onboarding_processes" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, unique=True, ) hire_date: Mapped[date_type] = mapped_column(Date, nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False, default=PROC_SCANNING) scanned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) ready_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Stamped once when the pending BLOCKER-severity count first hits # zero (migration 280) — the hard-gap口径 shared with SLA / # escalation (#1027). ``ready_at`` requires ALL dims incl. advisory # (e.g. 入职须知 the hire can't ack pre-start), so the Day-1 # readiness scorecard reads THIS column, not ``ready_at``. hard_ready_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) live_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) current_step: Mapped[str | None] = mapped_column(String(128)) step_log: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON) # SLA tracking. ``first_ungated_at`` is set when the process first # enters ``preparing`` (i.e. blockers present); the escalation tick # advances ``sla_breach_level`` through warn → urgent → breach based # on how long the gap has persisted past ``hire_date``. first_ungated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sla_breach_level: Mapped[str] = mapped_column( String(16), nullable=False, default=SLA_NONE, server_default=SLA_NONE, ) workflow_run_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) # Per-asset hardware allocation map (populated in PR-3 once the # manager UI ships). Shape: # { "": { # "source": "office_stock" | "new_purchase" | "inherit_from_leaver", # "office_id": "", # if source=office_stock # "asset_id": "", # if source=office_stock w/ specific # "leaver_person_id": "", # if source=inherit_from_leaver # "leaver_asset_id": "", # specific asset to inherit # "note": "" # }, ... } hardware_allocation: Mapped[dict[str, Any] | None] = mapped_column(JSON) # Migration 229 marks rows seeded by the backfill block. The T+7 # verify cron treats these leniently — no compliance_incident # emission since the platform never observed the actual Day-1 # readiness end-to-end. backfilled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class OnboardingBlocker(Base): __tablename__ = "onboarding_blockers" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( # One row per dimension per process — DB-enforced (migration # 281) so concurrent rescans (ack-triggered HTTP reconcile vs # the 5-min worker cron) can't dup-create a row that then # wedges the process pending forever (review-r3). UniqueConstraint( "process_id", "resource_type", name="uq_onboarding_blockers_process_resource", ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) process_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("onboarding_processes.id", ondelete="CASCADE"), nullable=False, ) resource_type: Mapped[str] = mapped_column(String(32), nullable=False) # NULL when the blocker has no concrete resource row to point at # yet — e.g. SSO_PROVISION with no identity_external_identities # record at all, or HARDWARE_ALLOCATION pre-asset-claim. resource_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) label: Mapped[str] = mapped_column(String(255), nullable=False) severity: Mapped[str] = mapped_column( String(16), nullable=False, default=SEVERITY_BLOCKER, server_default=SEVERITY_BLOCKER, ) resolution: Mapped[str] = mapped_column( String(32), nullable=False, default=RESO_PENDING, ) resolution_ref: Mapped[str | None] = mapped_column(String(255)) provisioning_task_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("provisioning_tasks.id"), ) notes: Mapped[str | None] = mapped_column(Text) # Structured error storage (mirrors offboarding migration 148). Used # by the auto-handler retries — most recent failure summary + # timestamp + total retry count. ``error_count >= 1`` floats the # blocker into the ``needs_it`` triage bucket regardless of severity. last_error_message: Mapped[str | None] = mapped_column(Text) last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) error_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) # Triage buckets — UI-facing classification computed server-side so the # frontend renders directly off the API response. TRIAGE_NEEDS_IT = "needs_it" TRIAGE_AUTO_IN_PROGRESS = "auto_in_progress" TRIAGE_RESOLVED = "resolved" def compute_triage_bucket( *, resolution: str, severity: str, error_count: int, provisioning_task_status: str | None = None, provisioning_task_execution_mode: str | None = None, ) -> str: """Single source of truth for the three admin-facing buckets. Rules (priority order): 1. ``resolution != 'pending'`` → ``resolved``. 2. ``error_count >= 1`` → ``needs_it``. Auto-handler has already failed; admin needs to intervene before the next retry storm. 3. ``provisioning_task.status == 'failed'`` → ``needs_it``. 4. ``provisioning_task.execution_mode == 'manual_notify' and status == 'notified'`` → ``needs_it`` (Slack-style "click in vendor console" path). 5. ``severity == 'blocker'`` → ``needs_it``. Fail-closed default. 6. Otherwise → ``auto_in_progress``. """ if resolution != RESO_PENDING: return TRIAGE_RESOLVED if error_count >= 1: return TRIAGE_NEEDS_IT if provisioning_task_status == "failed": return TRIAGE_NEEDS_IT if ( provisioning_task_execution_mode == "manual_notify" and provisioning_task_status == "notified" ): return TRIAGE_NEEDS_IT if severity == SEVERITY_BLOCKER: return TRIAGE_NEEDS_IT return TRIAGE_AUTO_IN_PROGRESS class OnboardingReminderLog(Base): """Idempotency markers for the onboarding escalation tick. One row per (process_id, reminder_kind). The escalation worker SELECTs before INSERTing; the unique constraint enforces the contract at the DB level so out-of-band paths (admin re-send, backfill scripts) can't race and double-send. Kinds (all IT-facing — every onboarding push maps to a concrete IT action; 2026-06-15 removed the manager pre-arrival reminders): * ``day0_it_gaps`` — hire_date arrived with BLOCKER-severity dims still pending; pushed to IT with the hard-gap worklist * ``sla_urgent`` — 24–72h past hire_date, hard dims pending → IT * ``sla_breach`` — >72h past hire_date, hard dims pending → IT * ``t7_verify_failed`` — T+7 verify found a Day-1 gap and reopened a blocker; pushed to IT """ __tablename__ = "onboarding_reminder_log" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( UniqueConstraint( "process_id", "reminder_kind", name="uq_onboarding_reminder_log_process_kind", ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) process_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("onboarding_processes.id", ondelete="CASCADE"), nullable=False, ) reminder_kind: Mapped[str] = mapped_column(String(32), nullable=False) sent_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) channels: Mapped[list[str] | None] = mapped_column(JSON) detail: Mapped[dict[str, Any] | None] = mapped_column(JSON) REMINDER_T7_VERIFY_FAILED = "t7_verify_failed" # Escalation-chain kinds (onboarding-parity PR2, 2026-06-12). Mirrors # offboarding's escalation tiering: # day0_it_gaps — hire_date arrived with BLOCKER-severity dims still # pending → IT admins get the hard-gap worklist card # sla_urgent — 24–72h past hire_date with pending dims → IT admins # sla_breach — >72h past hire_date → IT admins ONLY (打扰预算红线, # 2026-06-12: no skip-level / manager fan-out — see # _emit_escalation_card + test_sla_breach_is_it_only) REMINDER_DAY0_IT_GAPS = "day0_it_gaps" REMINDER_SLA_URGENT = "sla_urgent" REMINDER_SLA_BREACH = "sla_breach" class OnboardingAckItem(Base): """Admin-managed 入职须知 checklist item (migration 278). Backs the ``training_ack`` readiness dimension: the scanner treats the dimension as satisfied when every ``is_active AND required`` item has an ``OnboardingAcknowledgement`` row for the hire. Non-required items render in the employee checklist but don't gate readiness. """ __tablename__ = "onboarding_ack_items" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text) # Optional deep link to the actual policy document (Feishu doc / # help-center anchor). The employee checklist renders it as the # "read this first" CTA next to the confirm button. doc_url: Mapped[str | None] = mapped_column(String(512)) required: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) sort_order: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) created_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="SET NULL"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) # Acknowledgement sources — which surface the confirm came from. ACK_SOURCE_WEB = "web" ACK_SOURCE_MOBILE = "mobile" ACK_SOURCE_MCP = "mcp" class OnboardingAcknowledgement(Base): """One row per (item, person) confirm — append-only, idempotent. ``process_id`` is a courtesy back-pointer for the audit trail; the readiness scanner keys on ``person_id`` so a re-hire's prior acks survive (re-confirming unchanged policies on a boomerang hire is deliberate noise we avoid — if the admin wants a re-ack they version the item by creating a new one). """ __tablename__ = "onboarding_acknowledgements" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( UniqueConstraint( "item_id", "person_id", name="uq_onboarding_acks_item_person", ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) item_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("onboarding_ack_items.id", ondelete="CASCADE"), nullable=False, ) person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="CASCADE"), nullable=False, ) process_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("onboarding_processes.id", ondelete="SET NULL"), ) source: Mapped[str] = mapped_column( String(16), nullable=False, default=ACK_SOURCE_WEB, server_default=ACK_SOURCE_WEB, ) acknowledged_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), )