"""Offboarding + transfer schemas.""" from __future__ import annotations from datetime import date, datetime from uuid import UUID from pydantic import BaseModel, Field, field_validator, model_validator class OffboardingPreviewBlockerRow(BaseModel): resource_type: str resource_id: str label: str software_id: str | None = None software_name: str | None = None policy: str needs_successor: bool auto_successor_id: str | None = None auto_successor_email: str | None = None auto_successor_reason: str = "" class OffboardingPreviewSummary(BaseModel): total_count: int by_resource_type: dict[str, int] by_policy: dict[str, int] must_pick_successor_count: int force_logout_will_fire: bool class OffboardingPreviewExistingProcess(BaseModel): # Pre-launch P2 fix (Round 1, CR-012): align with the canonical # ``OffboardingProcessResponse.id: UUID`` so frontend consumers # don't have to accept ``string | UUID`` for the same logical # field. Pydantic serializes UUID → str on the wire automatically. id: UUID status: str leaver_date: str class OffboardingPreviewPerson(BaseModel): id: str name: str email: str employment_status: str class OffboardingPreviewManager(BaseModel): id: str name: str email: str is_active: bool class OffboardingPreviewResponse(BaseModel): """Read-only preview of what would happen if ``mark-leaver`` were triggered now. Powers the confirm modal on UserDetailPage so the operator sees the resource list before clicking. """ person: OffboardingPreviewPerson manager: OffboardingPreviewManager | None = None existing_process: OffboardingPreviewExistingProcess | None = None blockers: list[OffboardingPreviewBlockerRow] summary: OffboardingPreviewSummary class SuspendSsoResultResponse(BaseModel): """Response body for ``POST /processes/{id}/suspend-sso``. Surfaces per-task outcomes so the operator can see which tasks failed (P0-2 fix, 2026-05-08). The process detail body is included so the UI can refresh inline. """ process: OffboardingProcessResponse executed_count: int executed_task_ids: list[str] failed_task_ids: list[str] class OffboardingBlockerResponse(BaseModel): id: UUID process_id: UUID resource_type: str resource_id: UUID label: str software_id: UUID | None software_name: str | None = None # Round-14 (2026-05-08): "blocker" -> needs IT decision (no auto # handler), "advisory" -> system will auto-revoke / force-transfer # at T+0+24h grace expiry. Frontend uses this to split the queue # so IT only sees real exceptions in the red banner. severity: str resolution: str resolution_ref: str | None provisioning_task_id: UUID | None transfer_request_id: UUID | None notes: str | None # Migration 148 (2026-05-12): structured error storage replaces # the legacy ``\n[T+0 自动转让失败] ...`` notes append. Three new # fields exposed so admins see the latest failure context without # parsing free-text notes. ``error_count >= 1`` is the signal the # frontend renders the row in the "needs IT" bucket even though # severity is ``advisory``. last_error_message: str | None = None last_error_at: datetime | None = None error_count: int = 0 # Server-computed UI bucket (one of needs_it / auto_in_progress / # resolved). Single source of truth for the 3-segment list in the # offboarding detail page; the frontend MUST NOT re-derive these # buckets locally — keep the bucket rules centralised here so # KPIs, list filters, and the detail page all agree. triage_bucket: str = "auto_in_progress" # Round-21 review fix-up (2026-05-12): true ONLY when the resource # is a hardware asset AND its current status is exactly # ``held_by_leaver``. The offboarding console renders a # "已转为离职持有" chip off this signal; without an explicit # server-side check the chip would falsely appear on skipped # hardware blockers where the auto-mark fell through (asset # deleted / already disposed mid-flight). Set by # ``_to_process_response`` after a batch lookup against # ``asset_items.status`` for the hardware-typed blockers. asset_held_by_leaver: bool = False created_at: datetime updated_at: datetime class OffboardingProcessResponse(BaseModel): id: UUID person_id: UUID person_name: str | None person_email: str person_employee_no: str | None = None person_employment_status: str | None = None department: str | None leaver_date: date status: str # ``pending_blocker_count`` keeps the historical "all pending rows" # semantics so existing API consumers (audit reports, MCP tools) # do not silently change behaviour. The round-14 split counts # below let the admin dashboard show the # "needs IT" vs "system will auto-handle" breakdown without # re-fetching the full blocker list. Sum equals # ``pending_blocker_count``. pending_action_required_count: int = 0 pending_advisory_count: int = 0 pending_blocker_count: int total_blocker_count: int # Migration 148 (2026-05-12): admin-facing triage counts. These are # the source of truth the unified offboarding console renders in # its KPI strip and per-row badges. ``needs_it_count`` is what an # admin must click on today; ``auto_in_progress_count`` is the # T+0+24h auto-handler backlog and only matters as a sanity-check # tile. ``resolved_count`` mirrors ``total_blocker_count - # pending_blocker_count`` and is included so the frontend can # render the "已完成 (N)" badge without a second computation. needs_it_count: int = 0 auto_in_progress_count: int = 0 resolved_count: int = 0 scanned_at: datetime | None ready_at: datetime | None completed_at: datetime | None sso_suspended_at: datetime | None # Per-channel identity-plane cutoff proof (migration 383). Keys: # google_session / google_account / feishu_session, each # ``{status, at, error?}``. NULL until force-logout has run. Powers # the detail-page 「访问切断」 panel + person-detail 离职 tab proof line. session_enforcement: dict | None = None # SLA bookkeeping (migration 090). ``first_blocked_at`` is set when # the process first enters BLOCKED after the leaver_date has passed # and is the basis the WARN / URGENT / BREACH tier comparisons run # against. Surfaced here so non-admin lookout pages (e.g. # ``/my/team-leavers``) can render an accurate "first blocked at" # column instead of the process row's ``created_at``. first_blocked_at: datetime | None = None sla_breach_level: str = "none" # Per-process default handover successor (migration 211, 2026-05-28). # NULL when the org doesn't follow a default convention; otherwise # the leaver's HRIS manager_id at process-start time (admin can # edit). UI uses ``default_handover_person_id`` to prefill the # per-blocker "指定接手人" modal; ``default_handover_person_name`` # is the resolved display name for header rendering without a # second fetch. default_handover_person_id: UUID | None = None default_handover_person_name: str | None = None default_handover_person_email: str | None = None default_handover_person_employee_no: str | None = None default_handover_person_employment_status: str | None = None blockers: list[OffboardingBlockerResponse] = [] created_at: datetime updated_at: datetime class BlockerRevokeRequest(BaseModel): """Admin resolves a blocker via 'revoke' (create a provisioning task). ``successor_user_id`` is required when the software's connector needs vendor-side data handoff (Zoom delete / Google user_suspend). If omitted the service falls back to the leaver's manager; if no valid successor is available the call fails so the admin picks one in the UI. """ notes: str | None = Field(None, max_length=2000) successor_user_id: UUID | None = None class BlockerSkipRequest(BaseModel): """Admin marks a blocker as skipped (out of scope / already handled).""" reason: str = Field(..., max_length=2000) class CancelProcessRequest(BaseModel): """Admin cancels an offboarding process (false-leaver rollback). Pre-launch P2 fix (Round 1, CR-008): split out from ``BlockerSkipRequest`` whose docstring described blocker-level skipping. The OpenAPI docs and a contributor reading the schema no longer have to puzzle out which fields apply to which flow. ``confirm_irreversible`` must be True when the process has already advanced past T+0 (force-transfer executed, SSO suspended). Cancel does NOT undo the FK rewrites — assets stay on the manager and need manual recovery. """ reason: str = Field(..., max_length=2000) confirm_irreversible: bool = False class BlockerHandoverRequest(BaseModel): """Admin hands a generic-flow blocker (SSO grant / domain registration) to a named successor; the resource's ``person_id`` is rewritten and the blocker resolves as ``transferred``.""" successor_person_id: UUID class DefaultHandoverPersonRequest(BaseModel): """Admin sets / clears the per-process default handover successor (migration 211). NULL ``successor_person_id`` clears the default — the per-blocker handover modal will open with an empty UserSelect. A non-NULL value must be an active, non-deleted employee (service-layer validation). """ successor_person_id: UUID | None = None class MyHandoverItem(BaseModel): """One offboarding handover the current user holds (2026-08-13). Backs 「我的交接」 — the self-service view that tells a lead what data is routing to them and lets them hand it to the right teammate without filing an IT ticket. """ process_id: UUID leaver_person_id: UUID leaver_name: str | None = None leaver_email: str | None = None leaver_date: date | None = None process_status: str #: The leaver's Google / Zoom account is still awaiting its terminal #: delete — i.e. this handover is what the delete is waiting on. google_delete_pending: bool = False zoom_delete_pending: bool = False class ReassignHandoverRequest(BaseModel): """Hand a handover you currently hold to a colleague. The recipient must be an active employee with a work email, and can never be the leaver. Authorisation (must BE the current handover person, or global_admin) is enforced in the service — this endpoint is reachable by every authenticated employee. """ new_person_id: UUID class FallbackHandoverPersonRequest(BaseModel): """Admin sets / clears the ORG-WIDE fallback handover person (platform setting ``offboarding.default_handover_fallback_person_id``, 2026-08-13). Last tier of the terminal-disposition successor chain: used only when a leaver has no per-process default handover person AND nobody in their management chain is still active. NULL clears it — which means such leavers' Google / Zoom accounts stay suspended rather than being deleted, and IT gets paged instead. """ person_id: UUID | None = None class FallbackHandoverPersonResponse(BaseModel): """Resolved view of the org-wide fallback handover person. Returns the person's identity fields (not just the raw UUID) so the admin surface can render a name instead of an opaque id, and can warn when a previously-configured person has since left. """ person_id: UUID | None = None display_name: str | None = None work_email: str | None = None employment_status: str | None = None #: True when a person id IS configured but that person can no longer #: receive a handover (left / soft-deleted / missing). The chain then #: behaves as if no fallback were set at all. is_stale: bool = False # ── Hardware disposition (migration 184, 2026-05-19) ───────────────── # Per-asset decision attached to the offboarding process: what should # happen to each hardware blocker at T+0+24h. Drives the # disposition-aware advance handler in ``hr_lifecycle.advance_service``. HARDWARE_DISPOSITION_KEEP = "keep" HARDWARE_DISPOSITION_RECYCLE_TO_OFFICE = "recycle_to_office" HARDWARE_DISPOSITION_TRANSFER_TO_PERSON = "transfer_to_person" _ALLOWED_HARDWARE_DISPOSITION_ACTIONS = frozenset({ HARDWARE_DISPOSITION_KEEP, HARDWARE_DISPOSITION_RECYCLE_TO_OFFICE, HARDWARE_DISPOSITION_TRANSFER_TO_PERSON, }) class HardwareDispositionEntry(BaseModel): """Per-asset hardware disposition. Action semantics: * ``keep`` — leaver is approved to keep the device. Asset is marked ``held_by_leaver`` and excluded from the T+0 force-transfer loop. * ``recycle_to_office`` — asset is recycled to a city office. ``office_id`` is required; if omitted, the advance handler resolves it from the leaver's ``work_location_name``. * ``transfer_to_person`` — asset is handed to a named successor (legacy force-transfer behaviour, now opt-in not the fallback). ``person_id`` is required. """ action: str = Field(..., max_length=32) office_id: UUID | None = None person_id: UUID | None = None note: str | None = Field(None, max_length=2000) @field_validator("action") @classmethod def validate_action(cls, v: str) -> str: if v not in _ALLOWED_HARDWARE_DISPOSITION_ACTIONS: raise ValueError( "action must be one of: " + ", ".join(sorted(_ALLOWED_HARDWARE_DISPOSITION_ACTIONS)) ) return v @model_validator(mode="after") def validate_action_fields(self) -> HardwareDispositionEntry: """Cross-field invariants (PR-#406 review fix). Without these, the backend accepts a malformed ``{action: transfer_to_person}`` with no ``person_id`` and the T+0 handler silently falls back to ``fallback_recipient`` — the exact "stack-on-Meng" pattern this PR is supposed to remove. """ if self.action == HARDWARE_DISPOSITION_KEEP: if self.office_id is not None or self.person_id is not None: raise ValueError( "action='keep' must not carry office_id or person_id" ) elif self.action == HARDWARE_DISPOSITION_TRANSFER_TO_PERSON: if self.person_id is None: raise ValueError( "action='transfer_to_person' requires person_id" ) if self.office_id is not None: raise ValueError( "action='transfer_to_person' must not carry office_id" ) elif self.action == HARDWARE_DISPOSITION_RECYCLE_TO_OFFICE: # office_id is OPTIONAL — the advance handler resolves the # leaver's home office when it's omitted. But person_id # must be absent. if self.person_id is not None: raise ValueError( "action='recycle_to_office' must not carry person_id" ) return self class HardwareDispositionRequest(BaseModel): """Bulk-set the hardware disposition map on an offboarding process. The keys are asset_item.id (UUID) — the same as the blocker ``resource_id`` for ``resource_type='hardware'`` blockers. Passing an entry for an asset that isn't a blocker on this process is silently dropped server-side (forward-compat with admin tools that batch-set dispositions from a saved template). """ dispositions: dict[UUID, HardwareDispositionEntry] class HardwareDispositionResponse(BaseModel): """Echo back the persisted disposition map for the admin UI.""" dispositions: dict[UUID, HardwareDispositionEntry] updated_at: datetime class HardwareDispositionPreviewEntry(BaseModel): """One row in the disposition picker preview. Returned by ``GET /offboarding/processes/{id}/hardware-disposition-preview`` so the admin UI can render the default action per asset (typically ``recycle_to_office`` to the leaver's home office) without an extra round-trip per asset. """ asset_id: UUID asset_tag: str asset_name: str | None = None category: str | None = None current_status: str suggested_action: str suggested_office_id: UUID | None = None suggested_office_name: str | None = None suggested_office_resolution: str | None = None # 'work_location_match' | 'no_match' class HardwareDispositionPreviewResponse(BaseModel): entries: list[HardwareDispositionPreviewEntry] class AssetTransferCreateRequest(BaseModel): recipient_id: UUID asset_type: str asset_ref_id: UUID reason: str | None = Field(None, max_length=2000) offboarding_process_id: UUID | None = None class AssetTransferResponseBody(BaseModel): response_note: str | None = None class AssetTransferResponse(BaseModel): id: UUID initiator_id: UUID initiator_name: str | None initiator_employee_no: str | None = None initiator_employment_status: str | None = None recipient_id: UUID recipient_name: str | None recipient_employee_no: str | None = None recipient_employment_status: str | None = None asset_type: str asset_ref_id: UUID asset_label: str status: str reason: str | None response_note: str | None responded_at: datetime | None completed_at: datetime | None offboarding_process_id: UUID | None created_at: datetime updated_at: datetime # Forward-ref resolution for SuspendSsoResultResponse.process. # OffboardingProcessResponse is defined above SuspendSsoResultResponse but # referenced by string; pydantic v2 needs explicit rebuild when forward # refs are used. SuspendSsoResultResponse.model_rebuild()