import uuid from datetime import date, datetime from typing import Any, Literal from pydantic import ( AliasChoices, BaseModel, Field, field_validator, model_validator, ) # Per-field degrade policy for the webhook's best-effort enrichment # fields (see the validator in SuccessFactorsWebhookEvent). _ENRICH_TRUNCATE = { "first_name": 128, "last_name": 128, "display_name": 256, "employee_no": 128, } _ENRICH_DROP = { "email_name": 128, "personal_email": 255, "mobile_phone": 64, "country": 8, "default_locale": 32, "termination_reason": 64, } class SuccessFactorsWebhookEvent(BaseModel): # P1-E (2026-06-02 audit): bound each string + reject absurd dates. # ``identity_persons.hris_person_id`` is String(128) and the DB # write at INSERT time would otherwise 1406 Data-too-long, which # the per-event isolation just logs+swallows → silent drop of one # legitimate slot. Date bounds catch SF-side junk (year=9999 from # null-poisoned exports) that would otherwise pin ``effective_date`` # forever past ``transition_overdue_leavers``'s sweep horizon. event_type: str = Field(min_length=1, max_length=32) user_id: str = Field(min_length=1, max_length=128) effective_date: date person_id_external: str | None = Field(default=None, max_length=128) # Optional desired mailbox name / corporate email the HRIS side wants # this hire to get. When present it is the TOP-priority source for the # email local-part (above SF User.email and the given.family pinyin # derivation). Accepts a bare local part ("zhang.wei") or a full # address ("zhang.wei@pingcap.com" — local part is taken). Absent ⇒ # fall back to the existing derivation. Accepts JSON key ``emailName`` # or ``email_name``. email_name: str | None = Field( default=None, max_length=128, validation_alias=AliasChoices("email_name", "emailName"), ) # Direct-carry fields (2026-07-02): the SF outbound event ships the # hire's core attributes so Helix can create the account WITHOUT waiting # on the full data sync (which only runs on the hire date, days after the # event arrives). Each mirrors what ``_upsert_person`` would otherwise # pull from ``User`` / ``PerEmail`` / ``PerPhone`` / company master data. # All optional — when absent, the code falls back to the SF pull (the # sync path is unchanged). camelCase JSON keys match the SF Integration # Center field names. first_name: str | None = Field( default=None, max_length=128, validation_alias=AliasChoices("first_name", "firstName"), ) last_name: str | None = Field( default=None, max_length=128, validation_alias=AliasChoices("last_name", "lastName"), ) display_name: str | None = Field( default=None, max_length=256, validation_alias=AliasChoices("display_name", "displayName"), ) personal_email: str | None = Field( default=None, max_length=255, validation_alias=AliasChoices("personal_email", "personalEmail"), ) mobile_phone: str | None = Field( default=None, max_length=64, validation_alias=AliasChoices( "mobile_phone", "mobilePhone", "phoneNumber", ), ) country: str | None = Field( default=None, max_length=8, validation_alias=AliasChoices("country", "countryOfCompany"), ) default_locale: str | None = Field( default=None, max_length=32, validation_alias=AliasChoices( "default_locale", "defaultLocale", "preferred_language", ), ) employee_no: str | None = Field( default=None, max_length=128, validation_alias=AliasChoices("employee_no", "empId", "employeeNo"), ) # Termination reason for ``offboard`` events (2026-07-30): the SF # EcEvenReason2 externalCode (``D01``…``D08``) from # ``EmpEmploymentTermination.customString85``. OPTIONAL — the SF # Integration Center mapping doesn't send it yet (HRIS filters # excluded reasons push-side instead); when present it is trusted # directly, when absent Helix resolves the reason from SF itself # before acting on an offboard. Codes on the integration's # ``excluded_termination_reason_codes`` list (人员分类转换 / 跨DC调动 / # 兼岗结束) skip account closure and land in the ledger as # ``offboard_skipped``. termination_reason: str | None = Field( default=None, max_length=64, validation_alias=AliasChoices( "termination_reason", "terminationReason", "eventReason", ), ) # The direct-carry fields above are BEST-EFFORT enrichment: a bad or # overlong value in any single event must degrade that one field, not # 422 the whole webhook call (the request model validates all ≤500 # events at parse time — one "United States" in an 8-char country # field would drop every onboard/offboard event in the batch). # Names truncate safely; identifier-like values (emails, phone, # country/locale codes) are garbage when truncated, so they fall # back to None and the SF-pull path fills them later. @field_validator( "first_name", "last_name", "display_name", "employee_no", "email_name", "personal_email", "mobile_phone", "country", "default_locale", "termination_reason", mode="before", ) @classmethod def _degrade_overlong_enrichment(cls, v: Any, info: Any) -> Any: if not isinstance(v, str): return v limit = _ENRICH_TRUNCATE.get(info.field_name) if limit is not None: return v[:limit] limit = _ENRICH_DROP.get(info.field_name) if limit is not None and len(v) > limit: return None return v @field_validator("effective_date") @classmethod def _bound_effective_date(cls, v: date) -> date: # Generous window — SF backdating is real (HR backfilling a # hire after the new employee already started), and future # dates carry the hire's start date. Reject only obviously # corrupted values: prior to 2000 or beyond 5 years from now. from datetime import date as _date_today today = _date_today.today() lower = today.replace(year=2000) upper = today.replace(year=today.year + 5) if v < lower or v > upper: raise ValueError( f"effective_date {v.isoformat()} outside accepted " f"range [{lower.isoformat()}, {upper.isoformat()}]" ) return v class SuccessFactorsWebhookRequest(BaseModel): # NOTE: nginx should also enforce client_max_body_size (e.g. 1m) to reject # oversized payloads before they reach the application layer. events: list[SuccessFactorsWebhookEvent] @field_validator('events') @classmethod def cap_events(cls, v: list) -> list: max_events = 500 if len(v) > max_events: raise ValueError(f'Too many events in single webhook call (max {max_events})') return v class SuccessFactorsWebhookResponse(BaseModel): events_received: int = Field(ge=0) events_created: int = Field(ge=0) people_upserted: int = Field(ge=0) hr_employees_upserted: int = Field(ge=0) errors: int = Field(default=0, ge=0) class OffboardingAssetStatusUpdateRequest(BaseModel): # Constrain to the legitimately-accepted handling states: the # frontend Select offers pending/returned/transferred/disposed/ # completed (web/src/pages/admin/UserDetailPage.tsx), and the # resolution check (``RESOLVED_OFFBOARDING_STATUSES`` in # hr/service.py) additionally treats ``reclaimed`` as resolved. # The union is the full valid set; previously an unconstrained str # let a typo persist a bogus status into custom_fields["offboarding"] # that no resolution/list query ever matches (asset stays "unresolved" # forever, silently blocking offboarding completion). status: Literal[ "pending", "returned", "reclaimed", "transferred", "disposed", "completed" ] note: str | None = Field(default=None, max_length=1000) class OffboardingCompletionRequest(BaseModel): note: str | None = Field(default=None, max_length=1000) class ManualMarkLeaverRequest(BaseModel): """Admin manually flips an active person to ``leaver_pending``. Mirrors the HRIS-driven offboarding entry point — runs through the same ``OffboardingService.start_process`` so the resulting flow is indistinguishable from a SuccessFactors-triggered termination. Intended for (a) testing the offboarding pipeline end-to-end on HRIS instances we haven't onboarded yet, (b) emergency manual terminations the HR team needs to push without waiting for the next SF sync window. """ last_working_day: date reason: str | None = Field(default=None, max_length=500) class ManualMarkLeaverPostCommitResult(BaseModel): """Pre-launch P2 fix (Round 1, CR-015): typed shape for the post-commit dispatch summary. Replaces the free-form blob previously merged via ``payload["post_commit"] = ...`` after ``model_dump()``. """ google_force_logout_pushed: bool = False google_force_logout_error: str | None = None notifications: dict[str, Any | None] = Field(default_factory=dict) notification_error: str | None = None post_commit_commit_error: str | None = None class ManualMarkLeaverResponse(BaseModel): person_id: str employment_status: str termination_date: date offboarding_process_id: str post_commit: ManualMarkLeaverPostCommitResult | None = None class OffboardingAssetResponse(BaseModel): asset_id: str asset_tag: str asset_name: str | None = None category: str asset_status: str handling_status: str note: str | None = None updated_at: str | None = None updated_by: str | None = None is_resolved: bool class OffboardingSoftwareResponse(BaseModel): software_id: str name: str asset_type: str | None = None status: str role: str department: str | None = None handling_status: str = "pending" note: str | None = None updated_at: str | None = None updated_by: str | None = None is_resolved: bool = False class OffboardingCaseResponse(BaseModel): user_id: str employee_id: str | None = None display_name: str email: str status: str last_day: date | None = None manager_name: str | None = None unresolved_asset_count: int = Field(ge=0) unresolved_software_count: int = Field(ge=0, default=0) can_complete: bool = True blocking_reasons: list[str] = Field(default_factory=list) assets: list[OffboardingAssetResponse] = Field(default_factory=list) software_assets: list[OffboardingSoftwareResponse] = Field(default_factory=list) completed_at: str | None = None completed_by: str | None = None completion_note: str | None = None class LifecycleOverviewItemResponse(BaseModel): user_id: str display_name: str email: str status: str effective_date: date unresolved_asset_count: int = Field(ge=0) department: str | None = None class LifecycleOverviewResponse(BaseModel): recent_hires: list[LifecycleOverviewItemResponse] recent_leavers: list[LifecycleOverviewItemResponse] # ---- HRIS sync staging/confirmation ---- class SyncPreviewRequest(BaseModel): start_date: date | None = None end_date: date | None = None @model_validator(mode="after") def validate_window(self) -> "SyncPreviewRequest": if self.start_date and self.end_date: if self.end_date <= self.start_date: raise ValueError("end_date must be after start_date") if (self.end_date - self.start_date).days > 366: raise ValueError("Date range cannot exceed 366 days") return self class StagedEventResponse(BaseModel): id: uuid.UUID batch_id: uuid.UUID event_type: str user_id: str person_data: dict[str, Any] | None = None employment_data: dict[str, Any] | None = None action: str existing_person_id: uuid.UUID | None = None status: str created_at: datetime class SyncBatchResponse(BaseModel): id: uuid.UUID triggered_by: str trigger_source: str = "manual" sync_mode: str = "window" # Migration 565. Only meaningful for ``sync_mode="full"``: # True = initial seed (coordinators suppressed, volume brakes # exempt), False = routine reconciliation. Surfaced so the batch # list can say which one an operator is looking at — "full" alone # no longer answers that question. is_seed: bool = False hris_instance_id: uuid.UUID | None = None start_date: date end_date: date since_datetime: datetime | None = None until_datetime: datetime | None = None status: str events_staged: int = Field(ge=0) new_count: int = Field(ge=0, default=0) updated_count: int = Field(ge=0, default=0) reactivated_count: int = Field(ge=0, default=0) deactivated_count: int = Field(ge=0, default=0) skipped_count: int = Field(ge=0, default=0) error_count: int = Field(ge=0, default=0) error_message: str | None = None created_at: datetime reviewed_at: datetime | None = None reviewed_by: str | None = None staged_events: list[StagedEventResponse] = Field(default_factory=list) class SyncBatchSummaryResponse(BaseModel): id: uuid.UUID triggered_by: str trigger_source: str = "manual" sync_mode: str = "window" # Migration 565. Only meaningful for ``sync_mode="full"``: # True = initial seed (coordinators suppressed, volume brakes # exempt), False = routine reconciliation. Surfaced so the batch # list can say which one an operator is looking at — "full" alone # no longer answers that question. is_seed: bool = False hris_instance_id: uuid.UUID | None = None start_date: date end_date: date since_datetime: datetime | None = None until_datetime: datetime | None = None status: str events_staged: int = Field(ge=0) new_count: int = Field(ge=0, default=0) updated_count: int = Field(ge=0, default=0) reactivated_count: int = Field(ge=0, default=0) deactivated_count: int = Field(ge=0, default=0) skipped_count: int = Field(ge=0, default=0) error_count: int = Field(ge=0, default=0) created_at: datetime reviewed_at: datetime | None = None reviewed_by: str | None = None class SyncConfirmResponse(BaseModel): batch_id: uuid.UUID status: str events_confirmed: int = Field(ge=0) events_skipped: int = Field(ge=0) people_upserted: int = Field(ge=0) lifecycle_events_created: int = Field(ge=0) new_count: int = Field(ge=0, default=0) updated_count: int = Field(ge=0, default=0) reactivated_count: int = Field(ge=0, default=0) deactivated_count: int = Field(ge=0, default=0) error_count: int = Field(ge=0, default=0) class IncrementalSyncRequest(BaseModel): """Explicit admin-triggered incremental sync (bypasses the scheduler). The hris_instance_id is required when more than one SF integration is configured so the caller is explicit about which region to sync — the server will return 400 otherwise instead of guessing. """ hris_instance_id: uuid.UUID | None = None since: datetime | None = None auto_confirm: bool = False # ── Leave / time-off (SF EC Time-Off mirror) ───────────────────────── class LeavePostingResponse(BaseModel): """One ledger line — a row of SF's 账户假期记录 grid.""" id: uuid.UUID booking_type: str # 发布类型 (raw code) booking_amount: float # 数额 (signed: + accrual, − taken) booking_unit: str | None = None booking_date: date | None = None # 发布日期 balance_before: float | None = None # 之前的剩余假期 (derived) balance_after: float | None = None # 之后的剩余假期 created_by: str | None = None # 创建者 created_at_source: datetime | None = None # 创建日期 comment: str | None = None # 评论 # From the matched SF EmployeeTime request (taken-leave lines only). reason: str | None = None # 事由 leave_type: str | None = None # 请假类型代码 request_status: str | None = None # 审批状态 class LeaveAccountResponse(BaseModel): """One 假期余额 card — a SF TimeAccount bucket.""" id: uuid.UUID account_type: str # SF accountType code account_type_label: str | None = None # 法定年假 / 福利年假 / … fiscal_year: int | None = None start_date: date | None = None end_date: date | None = None booking_end_date: date | None = None # 最后可申请日 (SF bookingEndDate) account_closed: bool = False is_current: bool = False balance_days: float | None = None # 可用余额 entitled_days: float | None = None # 累计授予 used_days: float | None = None # 已用 # ── Forfeit ("use it or lose it") signals ──────────────────────── # Whether an unused balance in THIS bucket is lost at period end. The # classification lives in the leave domain (leave_insights_service), not # in the frontend — 法定年假 forfeits, 调休/病假 do not, and which code # means what is tenant-configurable. forfeits: bool = False # Days from today to the last day leave can still be booked against this # account (booking_end_date, else end_date). ``None`` for closed accounts # and accounts with no end date; never negative. expires_in_days: int | None = None # Populated only on the per-account detail fetch. postings: list[LeavePostingResponse] | None = None class PersonLeaveResponse(BaseModel): """A person's full leave picture — every account bucket.""" person_id: uuid.UUID accounts: list[LeaveAccountResponse] class LeaveSyncResponse(BaseModel): """Result of an admin-triggered leave-data sync.""" accounts_upserted: int = 0 postings_upserted: int = 0 accounts_tombstoned: int = 0 persons_touched: int = 0 legacy_derived: int = 0