"""Recipient-local working-hours gate for scheduled notifications. When a cron tick fires globally (UTC) but the user-facing payload is a person-targeted reminder, we want delivery to land during the recipient's own business day — not 03:00 their time. This module owns two primitives: * ``resolve_timezone(person)`` — best-effort ``IdentityPerson → ZoneInfo``. Reads ``person.country`` (ISO-2 code or a common name) and looks it up in a curated PingCAP-relevant map. Falls back to ``Asia/Shanghai`` (the company's HQ) for unknown / null values so the recipient still gets *some* sensible window rather than silent drop. * ``is_local_working_time(now_utc, tz)`` — True iff the moment maps to Mon–Fri, 10:00–17:59 local. The 10:00 lower bound is "well after morning standups, before lunch"; the 18:00 upper bound is end of business hours. The hourly inventory-reminder cron fires roughly once per local hour, so any weekday tick inside this window will hit a person at most once per local day (per-day dedup is enforced separately by the caller using ``reminded_at``). Public holidays are *not* modelled — Helix has no per-country holiday table today. The cost is occasional reminders on Lunar New Year or 4th-of-July; acceptable for now. """ from __future__ import annotations from datetime import UTC, date, datetime, time, timedelta from typing import TYPE_CHECKING from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from app.identity.person_models import IdentityPerson if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession # How far AHEAD of a hire's local start date we make their accounts + Day-1 # email ready. SF-agreed 2026-07-06: provision ~24h early so the account is # live and the welcome/password mail has landed BEFORE the employee walks in # on day 1 (and "info received <24h before start → create immediately" falls # out for free). Still far from the old "created weeks early" failure mode. # # 2026-07-23: this is now only the DEFAULT. The effective value is the # ``onboarding.provision_lead_hours`` platform setting (HR + global_admin # editable from the onboarding-strategy page); resolve it with # :func:`get_provision_lead_hours` and pass it into :func:`is_future_hire`. DEFAULT_PROVISION_LEAD_HOURS = 24 # Platform-setting key + plausibility band for the configurable lead. # 0 = strict "not before the local hire date"; 168 (one week) is the hard # ceiling — anything longer re-opens the "created weeks early" failure mode # (live mailbox + plaintext-password welcome mail sitting unattended). SETTING_PROVISION_LEAD_HOURS = "onboarding.provision_lead_hours" PROVISION_LEAD_HOURS_MIN = 0 PROVISION_LEAD_HOURS_MAX = 168 _DEFAULT_TZ: ZoneInfo = ZoneInfo("Asia/Shanghai") # Curated country → IANA zone map. Keys must be lowercase. Includes ISO # 3166-1 alpha-2 codes plus the most common English/Chinese long-form # names actually seen in HRIS payloads. _COUNTRY_TO_TZ: dict[str, str] = { # Greater China "cn": "Asia/Shanghai", "china": "Asia/Shanghai", "中国": "Asia/Shanghai", "hk": "Asia/Hong_Kong", "hong kong": "Asia/Hong_Kong", "中国香港": "Asia/Hong_Kong", "tw": "Asia/Taipei", "taiwan": "Asia/Taipei", "中国台湾": "Asia/Taipei", "mo": "Asia/Macau", # Asia "jp": "Asia/Tokyo", "japan": "Asia/Tokyo", "日本": "Asia/Tokyo", "kr": "Asia/Seoul", "korea": "Asia/Seoul", "south korea": "Asia/Seoul", "韩国": "Asia/Seoul", "sg": "Asia/Singapore", "singapore": "Asia/Singapore", "新加坡": "Asia/Singapore", "in": "Asia/Kolkata", "india": "Asia/Kolkata", "印度": "Asia/Kolkata", "id": "Asia/Jakarta", "indonesia": "Asia/Jakarta", "my": "Asia/Kuala_Lumpur", "malaysia": "Asia/Kuala_Lumpur", "ph": "Asia/Manila", "philippines": "Asia/Manila", "th": "Asia/Bangkok", "thailand": "Asia/Bangkok", "vn": "Asia/Ho_Chi_Minh", "vietnam": "Asia/Ho_Chi_Minh", "ae": "Asia/Dubai", # Americas "us": "America/Los_Angeles", "usa": "America/Los_Angeles", "united states": "America/Los_Angeles", "美国": "America/Los_Angeles", "ca": "America/Toronto", "canada": "America/Toronto", "mx": "America/Mexico_City", "br": "America/Sao_Paulo", "brazil": "America/Sao_Paulo", # Europe "gb": "Europe/London", "uk": "Europe/London", "united kingdom": "Europe/London", "ie": "Europe/Dublin", "ireland": "Europe/Dublin", "de": "Europe/Berlin", "germany": "Europe/Berlin", "德国": "Europe/Berlin", "fr": "Europe/Paris", "france": "Europe/Paris", "nl": "Europe/Amsterdam", "netherlands": "Europe/Amsterdam", "es": "Europe/Madrid", "spain": "Europe/Madrid", "it": "Europe/Rome", "italy": "Europe/Rome", "ch": "Europe/Zurich", "switzerland": "Europe/Zurich", "se": "Europe/Stockholm", "sweden": "Europe/Stockholm", # Oceania "au": "Australia/Sydney", "australia": "Australia/Sydney", "nz": "Pacific/Auckland", "new zealand": "Pacific/Auckland", # Europe (missing entirely before) "pl": "Europe/Warsaw", "poland": "Europe/Warsaw", "ro": "Europe/Bucharest", "romania": "Europe/Bucharest", # ── ISO 3166-1 alpha-3 aliases ── # HRIS (``identity_persons.country``) actually stores 3-letter codes # (USA / CHN / JPN / …), NOT the 2-letter codes the keys above assumed — # so every non-US hire silently defaulted to the HQ zone (Asia/Shanghai), # mistiming their hire-day gate & notifications. Cover the countries # PingCAP employs in. "chn": "Asia/Shanghai", "hkg": "Asia/Hong_Kong", "twn": "Asia/Taipei", "mac": "Asia/Macau", "jpn": "Asia/Tokyo", "kor": "Asia/Seoul", "sgp": "Asia/Singapore", "ind": "Asia/Kolkata", "idn": "Asia/Jakarta", "mys": "Asia/Kuala_Lumpur", "phl": "Asia/Manila", "tha": "Asia/Bangkok", "vnm": "Asia/Ho_Chi_Minh", "are": "Asia/Dubai", "pak": "Asia/Karachi", "bgd": "Asia/Dhaka", "gbr": "Europe/London", "irl": "Europe/Dublin", "deu": "Europe/Berlin", "fra": "Europe/Paris", "nld": "Europe/Amsterdam", "esp": "Europe/Madrid", "ita": "Europe/Rome", "che": "Europe/Zurich", "swe": "Europe/Stockholm", "pol": "Europe/Warsaw", "rou": "Europe/Bucharest", "can": "America/Toronto", "mex": "America/Mexico_City", "bra": "America/Sao_Paulo", "aus": "Australia/Sydney", "nzl": "Pacific/Auckland", } # Countries that span multiple IANA zones, where the single country default # above misplaces east/central/mountain hires. For these we refine by CITY # before falling back to the country default. Scoped to these countries so a # city-name collision in a single-zone country (e.g. a "San Jose" in Costa # Rica) can never pull a US zone. Keys lowercase. _MULTI_TZ_COUNTRIES: frozenset[str] = frozenset({ "us", "usa", "united states", "美国", "ca", "can", "canada", }) # Curated city → IANA zone map, consulted ONLY for _MULTI_TZ_COUNTRIES. Keys # are lowercase city names as they appear in the SF location record. Covers # the real ``identity_persons.city`` values on file plus the common US hubs # we hire into; anything unmapped falls through to the country default # (US → America/Los_Angeles, CA → America/Toronto), which is no worse than # the pre-refinement behaviour. Pacific US cities are intentionally omitted — # the US country default already resolves them to Pacific. Without this a # New-York (Eastern) hire is judged in Pacific time and deferred ~3h into # their actual first day (2026-07-06, hire P10002 Ritika Bharati). _CITY_TO_TZ: dict[str, str] = { # ── United States — Eastern ── "new york": "America/New_York", "new haven": "America/New_York", "chapel hill": "America/New_York", "norfolk": "America/New_York", "brockton": "America/New_York", "jacksonville": "America/New_York", "florida city": "America/New_York", "cincinnati": "America/New_York", "boston": "America/New_York", "atlanta": "America/New_York", "washington": "America/New_York", "philadelphia": "America/New_York", "pittsburgh": "America/New_York", "miami": "America/New_York", "orlando": "America/New_York", "detroit": "America/New_York", "columbus": "America/New_York", "charlotte": "America/New_York", "raleigh": "America/New_York", # ── United States — Central ── "chicago": "America/Chicago", "austin": "America/Chicago", "denton": "America/Chicago", "williamson county": "America/Chicago", "dallas": "America/Chicago", "houston": "America/Chicago", "san antonio": "America/Chicago", "minneapolis": "America/Chicago", "nashville": "America/Chicago", "kansas city": "America/Chicago", "new orleans": "America/Chicago", # ── United States — Mountain ── "denver": "America/Denver", "salt lake city": "America/Denver", "boulder": "America/Denver", "albuquerque": "America/Denver", # Arizona does not observe DST — its own zone. "phoenix": "America/Phoenix", "tempe": "America/Phoenix", "scottsdale": "America/Phoenix", # ── Canada — non-Eastern (country default is America/Toronto) ── "vancouver": "America/Vancouver", "victoria": "America/Vancouver", "calgary": "America/Edmonton", "edmonton": "America/Edmonton", "winnipeg": "America/Winnipeg", } # Inclusive lower bound, exclusive upper bound — same as datetime # ranges. 10:00 ≤ local hour < 18:00. _WORK_HOUR_START: time = time(10, 0) _WORK_HOUR_END: time = time(18, 0) # Python's ``weekday()``: Mon=0 … Sun=6. _WORK_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4}) def resolve_timezone(person: IdentityPerson) -> ZoneInfo: """Return the recipient's IANA timezone. Defaults to Asia/Shanghai. Resolution order: for countries that span multiple zones (``_MULTI_TZ_COUNTRIES``) a known ``person.city`` refines to the right zone (New York → Eastern, Denver → Mountain, Vancouver → Pacific); otherwise the ``person.country`` default applies. Unknown/null values fall back to the HQ timezone rather than dropping the notification / misfiring the hire-day gate entirely. """ raw = (person.country or "").strip().lower() # City refinement for multi-zone countries — the single country default # (e.g. US → Pacific) otherwise misplaces east/central/mountain hires. if raw in _MULTI_TZ_COUNTRIES: city = (person.city or "").strip().lower() if city: city_zone = _CITY_TO_TZ.get(city) if city_zone is not None: try: return ZoneInfo(city_zone) except ZoneInfoNotFoundError: pass # fall through to the country default below if not raw: return _DEFAULT_TZ zone_name = _COUNTRY_TO_TZ.get(raw) if zone_name is None: return _DEFAULT_TZ try: return ZoneInfo(zone_name) except ZoneInfoNotFoundError: # tzdata package missing in the running image — should never # happen on prod (uvicorn-base ships tzdata) but degrade # gracefully rather than crash the cron. return _DEFAULT_TZ # ── Dynamic lead-hours resolution ──────────────────────────────────── # ``is_future_hire`` is sync and called from deep inside sync predicates # (list comprehensions, gate helpers), so the platform-setting read can't # happen inline. Async entry flows resolve the value once via # ``get_provision_lead_hours`` and pass it down; the module-level cache # additionally lets ``lead_hours=None`` call sites pick up the # last-resolved value as a safety net (fresh processes start at the # 24h default until the first resolve). _LEAD_CACHE_TTL_SECONDS = 60.0 _lead_cache_value: int = DEFAULT_PROVISION_LEAD_HOURS _lead_cache_at: float | None = None def _coerce_lead_hours(value: object) -> int | None: """Best-effort coerce a JSON-stored setting value to a valid lead. Mirrors ``fx_rate._coerce_float``'s discipline: reject ``bool`` (an ``int`` subclass — ``True`` must not become a 1-hour lead), accept int/float/numeric-string, and refuse anything outside the [``PROVISION_LEAD_HOURS_MIN``, ``PROVISION_LEAD_HOURS_MAX``] band so a fat-fingered setting row can never silently disable the too-early gate. """ if value is None or isinstance(value, bool): return None if isinstance(value, (int, float)): hours = int(value) elif isinstance(value, str): try: hours = int(float(value)) except (TypeError, ValueError): return None else: return None if not (PROVISION_LEAD_HOURS_MIN <= hours <= PROVISION_LEAD_HOURS_MAX): return None return hours async def get_provision_lead_hours(session: AsyncSession | None = None) -> int: """Effective provisioning lead in hours (platform setting, cached 60s). Reads ``onboarding.provision_lead_hours`` from platform_settings; missing / malformed / out-of-band values fall back to :data:`DEFAULT_PROVISION_LEAD_HOURS`. The short in-process TTL keeps hot loops (HRIS confirm batches, identity-sync sweeps) at one PK read per minute while an HR edit still lands on every gate within ~60s (plus the entry-point resolve each flow already does). """ global _lead_cache_value, _lead_cache_at import time as _time now = _time.monotonic() if _lead_cache_at is not None and now - _lead_cache_at < _LEAD_CACHE_TTL_SECONDS: return _lead_cache_value # Late import — app.integrations.service imports broadly and a # module-top import from app.core would risk an import cycle. from app.integrations.service import get_platform_setting raw = await get_platform_setting( SETTING_PROVISION_LEAD_HOURS, default=DEFAULT_PROVISION_LEAD_HOURS, session=session, ) hours = _coerce_lead_hours(raw) _lead_cache_value = hours if hours is not None else DEFAULT_PROVISION_LEAD_HOURS _lead_cache_at = now return _lead_cache_value def set_provision_lead_hours_cache(hours: int) -> None: """Overwrite the in-process cache after a settings write, so the writing process's own gates see the new value immediately (other processes converge within the TTL).""" global _lead_cache_value, _lead_cache_at import time as _time _lead_cache_value = hours _lead_cache_at = _time.monotonic() def reset_provision_lead_hours_cache() -> None: """Test hook: drop the cache back to the cold-start default.""" global _lead_cache_value, _lead_cache_at _lead_cache_value = DEFAULT_PROVISION_LEAD_HOURS _lead_cache_at = None def provision_lead_window_days(lead_hours: int) -> int: """How many days past a reference UTC date a ``hire_date`` can sit while its lead window is already open — the index-friendly SQL prefilter bound for the hire-day cron. The gate opens at ``hire_date 00:00 local − lead_hours``. With the easternmost IANA offset being UTC+14, a hire_date can be up to ``ceil((lead_hours + 14) / 24)`` days ahead of the UTC date at that moment. For the 24h default this is 2 — exactly the historical ``[target−1, target+2]`` superset window. """ return (lead_hours + 14 + 23) // 24 def is_future_hire( person: IdentityPerson, *, now_utc: datetime | None = None, lead_hours: int | None = None, ) -> bool: """True iff provisioning for this hire should still be DEFERRED — i.e. we are more than ``lead_hours`` before the start of their ``hire_date`` in their local TZ. Single source of truth for the "don't provision too early" gate. A future-dated hire's downstream accounts (Google, Feishu, provisioning policy, welcome email) must NOT be created the moment SuccessFactors confirms the hire — sometimes weeks early — but they SHOULD be ready a little ahead of the person walking in. ``lead_hours=None`` (the default) uses the HR-configurable ``onboarding.provision_lead_hours`` platform setting as last resolved by :func:`get_provision_lead_hours` (cold-start default 24h): async flows resolve it at entry and pass it explicitly so every gate in one flow judges with the same number. With a 24h lead the gate opens at the start of the day BEFORE hire_date (employee-local) — SF-agreed 2026-07-06. ``lead_hours=0`` restores the strict "before the local hire date" behaviour. Computed in the employee's LOCAL timezone (see ``resolve_timezone``, incl. ISO-3 + city-level refinement) so a bare hire date resolves to the employee's own calendar day, not HQ's. Used by both the event-time onboarding hooks (hr_sync_coordinator) and the Identity Sync Center sweep (identity_sync) so the two agree on when a hire becomes eligible. """ if person.hire_date is None: return False try: tz = resolve_timezone(person) except Exception: # noqa: BLE001 tz = None now = now_utc or datetime.now(UTC) zone = tz if tz is not None else UTC if lead_hours is None: lead_hours = _lead_cache_value # Start of the hire's local calendar day, then back off the lead window. hire_start_local = datetime.combine(person.hire_date, time.min, tzinfo=zone) return now < hire_start_local - timedelta(hours=lead_hours) # How far in the PAST a hire_date may be before an incoming HRIS record is # treated as a historical backfill rather than a live new-hire onboarding. # The symmetric partner of ``is_future_hire`` (the "don't provision too # early" gate): this is the "don't provision — and don't page IT — for a # hire ingested long AFTER their start date" gate. # # When an existing / long-tenured employee is synced into Helix for the # first time (HRIS-late detection, a source-system migration, a bulk # backfill), their genuine hire_date can be months or years in the past. # Treating that like a same-day new hire (a) auto-mints Google / seat / VPN # accounts the person almost certainly already has — the 2026-07-08 # roger.zhou03 wrong-account incident, where a 5-year employee first synced # on their old 2021 hire_date got a fresh (welcome-failed → suspended) # Google mailbox minted — and (b) scores an instant SLA breach that pages # every IT admin「已入职 N 天仍未配齐」about someone who has worked here for # years. Both are pure noise: past this window the onboarding SLA is # meaningless (the arrival is long gone), so the row is a 补录 (backfill) # visibility record, not an actionable Day-1 worklist item. # # 90 days (one quarter) sits comfortably above the longest genuinely # in-flight onboarding observed in prod (~50 days), so a real but merely # slow new hire is never suppressed, while a years-late ingest always is. STALE_HIRE_BACKFILL_DAYS = 90 def hire_date_is_stale_backfill( hire_date: date | None, *, today: date | None = None, max_age_days: int = STALE_HIRE_BACKFILL_DAYS, ) -> bool: """True iff ``hire_date`` is more than ``max_age_days`` in the past. Date-only form for callers that hold just the date (e.g. ``OnboardingService.start_process``). ``today`` defaults to the current UTC date; at 90-day granularity the employee's local calendar day vs UTC is irrelevant, so no per-person TZ resolution is needed here. """ if hire_date is None: return False ref = today or datetime.now(UTC).date() return (ref - hire_date).days > max_age_days def is_stale_backfill_hire( person: IdentityPerson, *, now_utc: datetime | None = None, max_age_days: int = STALE_HIRE_BACKFILL_DAYS, ) -> bool: """True iff this person's ``hire_date`` is a stale historical backfill (see :data:`STALE_HIRE_BACKFILL_DAYS`), computed in their LOCAL timezone. Person form for the provisioning gate (``hr_sync_coordinator``). Mirrors ``is_future_hire``'s local-TZ resolution so the boundary is the employee's own calendar day, not HQ's. """ if person.hire_date is None: return False try: tz = resolve_timezone(person) except Exception: # noqa: BLE001 tz = None zone = tz if tz is not None else UTC now = now_utc or datetime.now(UTC) today_local = now.astimezone(zone).date() return hire_date_is_stale_backfill( person.hire_date, today=today_local, max_age_days=max_age_days, ) def is_local_working_time(now_utc: datetime, tz: ZoneInfo) -> bool: """True iff ``now_utc`` falls on Mon–Fri, 10:00–17:59 in ``tz``. Caller is responsible for ensuring ``now_utc`` is tz-aware (it almost always comes from ``datetime.now(UTC)``). """ local = now_utc.astimezone(tz) if local.weekday() not in _WORK_DAYS: return False return _WORK_HOUR_START <= local.timetz().replace(tzinfo=None) < _WORK_HOUR_END def local_start_of_day(now_utc: datetime, tz: ZoneInfo) -> datetime: """UTC instant corresponding to 00:00 *today* in ``tz``. Used by callers to dedup per-local-day — they compare ``item.reminded_at`` (UTC) against this returned UTC instant. """ local = now_utc.astimezone(tz) local_midnight = local.replace(hour=0, minute=0, second=0, microsecond=0) return local_midnight.astimezone(now_utc.tzinfo) __all__ = [ "get_provision_lead_hours", "is_future_hire", "is_local_working_time", "local_start_of_day", "provision_lead_window_days", "resolve_timezone", "set_provision_lead_hours_cache", ]