"""Preset templates for common identity-sync target kinds. A preset is a starting-point bundle of (kind, mapping_config, filter_config, schedule_config, runner_options) the admin can pick from when creating a new target. The admin can then edit anything; presets are scaffolding, not magic. Why separate presets from runner kinds: * The runner is the wire-protocol implementation; the preset is the "what does Navan want sent to it" knowledge. One runner (SCIM) can back many presets (Navan, Okta, Rippling, generic). * Mapping defaults are most useful for the first 80% of the field set (email, name, manager, department); admins still tweak the rest. * The preset key is what the UI offers; the underlying ``kind`` is what the runner registry resolves. Adding a new preset = one entry in PRESETS — no new code, no migration, ships in the next deploy. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any from app.domains.identity_sync.mapping import COUNTRY_ISO3_TO_ISO2 # ── SCIM core defaults ──────────────────────────────────────────────── # Standard SCIM 2.0 user shape plus the Enterprise extension. These # render to a payload that the vast majority of SCIM-compliant systems # accept verbatim. SCIM_DEFAULT_MAPPING_RULES: list[dict[str, Any]] = [ # Core user identity { "source_template": "{work_email}", "transform": "lower", "target_field": "userName", }, { "source_template": "{first_name}", "target_field": "name.givenName", }, { "source_template": "{last_name}", "target_field": "name.familyName", }, { "source_template": "{full_name}", "target_field": "displayName", }, { "source_template": "{job_title}", "target_field": "title", }, # Primary email — SCIM expects an array; we render a single primary. # The runner doesn't expand single→array, so admins who want the # full SCIM emails[] array shape can configure that explicitly. { "source_template": "{work_email}", "transform": "lower", "target_field": "emails.0.value", }, { "source_template": "work", "target_field": "emails.0.type", }, { "source_template": "true", "target_field": "emails.0.primary", }, # Enterprise extension — manager + employee number + department. { "source_template": "{employee_id}", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.employeeNumber" ), }, # ``business_unit`` is derived from the org_path's first # non-tenant-root segment — the actual top-level BU # ("Engineering Group", "Finance", …). ``{department_l1}`` # would render the tenant root ("PingCAP") which is not what # downstream finance / HRIS expects in the SCIM department slot. { "source_template": "{business_unit}", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.department" ), }, { "source_template": "{cost_center}", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.costCenter" ), }, { "source_template": "{manager_email}", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.manager.value" ), }, # Subsidiary / legal entity → SCIM enterprise.division (Navan # documents this as "Subsidiary in Navan"). Sourced from # IdentityPerson.company_name (HRIS FOCompany name). { "source_template": "{company_name}", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.division" ), }, # Navan ``region`` field — geographic region. Navan validates # against an admin-curated list; PingCAP's Navan tenant uses # APAC / EMEA / JP / NA (note JP is its own region, NOT folded # into APAC; "NA" not "AMER"). Translated from HRIS ISO-3 country # codes via value_map. Countries not in the map render empty, # which Navan accepts as "(Blank)". { "source_template": "{country}", "value_map": { # JP is its own region in Navan "JPN": "JP", # APAC (excluding Japan) "CHN": "APAC", "SGP": "APAC", "MYS": "APAC", "HKG": "APAC", "TWN": "APAC", "KOR": "APAC", "AUS": "APAC", "IND": "APAC", "IDN": "APAC", "PHL": "APAC", "THA": "APAC", "VNM": "APAC", "NZL": "APAC", # NA (North America) "USA": "NA", "CAN": "NA", "MEX": "NA", # EMEA "GBR": "EMEA", "DEU": "EMEA", "FRA": "EMEA", "NLD": "EMEA", "ESP": "EMEA", "ITA": "EMEA", "IRL": "EMEA", "CHE": "EMEA", "SWE": "EMEA", "DNK": "EMEA", "NOR": "EMEA", "FIN": "EMEA", "POL": "EMEA", "ROU": "EMEA", "ARE": "EMEA", "ISR": "EMEA", "ZAF": "EMEA", }, "target_field": ( "urn:ietf:params:scim:schemas:extension:navan:2.0:User.region" ), }, # ``companyOffice`` — Navan-curated office name (not raw HRIS # city). PingCAP's Navan tenant maintains its own list under # Admin → Settings → Offices; the value_map below holds the # subset confirmed against the live Navan UI as of 2026-05-06. # Cities NOT in this map (e.g. CN-mainland offices that haven't # been provisioned in Navan yet) render empty, which Navan # accepts as "(Blank)". Extend the map as Navan adds offices. # # If a city is missing here AND should be in Navan, add it via # Navan Admin first, then add the entry here in the next PR — # don't shortcut by inserting "Beijing" without verifying Navan # has "Beijing" exactly; mismatched values 400 the whole push. { "source_template": "{location}", "value_map": { "Tokyo": "Tokyo", "Singapore": "Singapore", "Sunnyvale": "Sunnyvale", "San Mateo": "Sunnyvale", "Kuala Lumpur": "Malaysia", "Selangor": "Malaysia", }, "target_field": ( "urn:ietf:params:scim:schemas:extension:navan:2.0:User.companyOffice" ), }, ] # ── Navan-specific overrides ───────────────────────────────────────── # Navan's surface differs from the generic SCIM defaults in three # documented places: # # 1. ``enterprise.department`` carries the full path BELOW the legal # entity (L2 / L3 / L4 joined by " / "), not just the top-level BU. # Navan finance team sees a single column and wants depth. # 2. ``enterprise.division`` (= Navan "Subsidiary") strips the HRIS # numeric ordering prefix from FOCompany.name — ``01_PingCAP(US), # Inc.`` → ``PingCAP(US),Inc.`` — Navan's subsidiary picker has # no use for the sort key. # 3. ``title`` is omitted — PingCAP doesn't want job_title pushed # into Navan; the field is HR-system-of-record only. def _build_navan_mapping_rules() -> list[dict[str, Any]]: """Derive the Navan rule set from the generic SCIM defaults. Returns a fresh list each call — admins editing the saved target must not retroactively mutate the preset template. """ overrides = { # SCIM enterprise.department → combined L2/L3/L4 path. ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.department" ): { "source_template": "{department_combined}", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.department" ), }, # SCIM enterprise.division → company_name with numeric prefix # stripped. ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.division" ): { "source_template": "{company_name}", "transform": "strip_company_prefix", "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.division" ), }, # SCIM enterprise.manager.value → manager email, with a single # delegate override: reports whose HRIS manager is # liuqi@pingcap.com are pushed to Navan under # qiufang@pingcap.com instead (PingCAP T&E delegation — liuqi # does not approve travel directly in Navan). ``value_map`` + # ``value_map_passthrough`` rewrites only that one manager and # lets every other manager email flow through verbatim; people # with no manager still render empty → field dropped. # # NOTE: the rendered value here is an EMAIL, but RFC 7643 §4.3 # (and Navan on PUT — it only resolves emails on POST) require # the manager's SCIM resource *id*. ``ScimRunner`` resolves # email → id at write time (``_resolve_body_user_refs``); keep # this rule in email form so the payload hash stays stable and # the delegate value_map above keeps working. ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.manager.value" ): { "source_template": "{manager_email}", "value_map": {"liuqi@pingcap.com": "qiufang@pingcap.com"}, "value_map_passthrough": True, "target_field": ( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.manager.value" ), }, } # Drop title — PingCAP policy: no job_title to Navan. skip_targets = {"title"} out: list[dict[str, Any]] = [] for rule in SCIM_DEFAULT_MAPPING_RULES: tgt = rule["target_field"] if tgt in skip_targets: continue if tgt in overrides: out.append(dict(overrides[tgt])) else: out.append(dict(rule)) # ``travelerApprover`` — Navan-only extension field, routes T&E # approvals to a different inbox per subsidiary: # * US entity → nancy.wu@pingcap.com # * everyone else → qiufang@pingcap.com (PingCAP fallback approver) # # IMPORTANT — hidden whitespace: SAP HRIS stores the US legal # entity name as ``"01_PingCAP (US), Inc."`` with # U+2004 (THREE-PER-EM SPACE) instead of ASCII space, NOT a # data-entry typo we can fix at our layer (HR finance owns the # FOCompany.name field on the SF side). The value_map key MUST # use the same   so the post-``strip_company_prefix`` value # ``"PingCAP (US), Inc."`` lands exact-match. Without # this, EVERY US employee falls through to qiufang as fallback — # silently wrong. out.append({ "source_template": "{company_name}", "transform": "strip_company_prefix", "value_map": { "PingCAP (US), Inc.": "nancy.wu@pingcap.com", }, "default": "qiufang@pingcap.com", "target_field": ( "urn:ietf:params:scim:schemas:extension:navan:2.0:User.travelerApprover" ), }) return out NAVAN_MAPPING_RULES: list[dict[str, Any]] = _build_navan_mapping_rules() _DEFAULT_FILTER: dict[str, Any] = { "include_offboarded": False, "departments": [], "include_subtree": True, "countries": [], "company_codes": [], "employment_statuses": ["active", "on_leave"], "job_title_includes": [], # Only real HRIS employees (id like ``P01478``) reach any downstream # system. Non-P employee_no = hand-created service / admin rows # (verified 2026-06-30 against prod: 刘寅 [employee_no NULL, # manually created], TimeAdmin, LifecyleAdmin) that must be ignored # by every sync. The feishu / huilianyi / netsuite presets already # carry this gate explicitly; promoting it to the default means new # SCIM / Navan / google_schema targets are born with it too, instead # of leaking the service rows (the recurring source of Google 403s # and SCIM noise). The sweep treats a person excluded by THIS gate as # ``excluded`` (kept, never deactivated) regardless of on_offboard — # "not an employee" ≠ "left the company". "employee_no_pattern": r"^P\d+$", "person_ids": [], "mode": "and", } # Navan enrols employees of the non-China legal entities (T&E is run # out of the US / Singapore / Japan / Malaysia books). Scope by # ``company_code`` — the employing entity — NOT residence country: # EOR (Employer-of-Record) staff live in countries where PingCAP has no # entity but are legally employed under the US (DP00001) or Singapore # (DP00002) entity, so a residence-country filter wrongly dropped them # (verified 2026-07-07 against prod: 48 active EOR employees fell out # when HRIS corrected their ``country`` to their real residence, and # the mass-action brake then blocked their erroneous deactivation). # The four non-China entity codes: # DP00001 PingCAP (US), Inc. DP00002 PINGCAP PTE. LTD. (SG) # DP00004 PingCAP K.K. (JP) DP00003 PingCAP Sdn. Bhd. (MY) # All other DP000xx are Beijing / Shanghai China entities. Add a code # here if PingCAP ever incorporates a new overseas entity. _NAVAN_USER_FILTER: dict[str, Any] = { "include_offboarded": False, "departments": [], "include_subtree": True, "countries": [], "company_codes": ["DP00001", "DP00002", "DP00003", "DP00004"], "employment_statuses": ["active"], "job_title_includes": [], "email_suffixes": ["pingcap.com"], "employee_no_pattern": r"^P\d+$", "person_ids": [], "mode": "and", } _DEFAULT_SCHEDULE_DAILY_2AM: dict[str, Any] = { "trigger": "cron", "cron_expr": "0 2 * * *", "timezone": "UTC", } _DEFAULT_SCHEDULE_DAILY_2AM_SHANGHAI: dict[str, Any] = { "trigger": "cron", "cron_expr": "0 2 * * *", "timezone": "Asia/Shanghai", } # NOTE: there is intentionally NO ``FEISHU_DEPARTMENT_MAPPING_RULES`` # constant here. The dept phase (Phase A of ``feishu_contact``) is an # admin-invariant projection from IdentityOrgNode → Feishu wire body # (code / name / parent_department_id / leader_user_id / order). The # projection lives directly in :meth:`FeishuDepartmentRunner._build_body` # — no mapping_config round-trip, no admin customisation surface. Per- # tenant flexibility happens at Phase B (the rules below) and at the # office-dim value_map. # Default work_location_name → Feishu office-dept name map. Verified # 2026-05-28 against the live PingCAP tenant: Feishu has 7 top-level # "office" departments (Tokyo Office / SG Office / SV Office / # MY Office / China Business Group / PingCAP Intern / Consultants) # that aren't HRIS BUs — they're a SECOND grouping dimension by # physical work-location / employment type. # # Helix syncs this dimension too: a user ends up in BOTH their HRIS # BU (Engineering Group, Finance, …) AND their office dept (Tokyo # Office, SV Office, …). The user runner pushes ``department_ids = # [BU od-XXX, Office od-XXX]`` and the merge step treats the office # slot as Helix-managed too (so a relocation HRIS-side actually # moves the office assignment on Feishu). # # Defaults cover the offices PingCAP HRIS has people in today; admin # can extend per-target via the mapping rule's ``value_map``. OFFICE_LOCATION_TO_FEISHU_DEPT: dict[str, str] = { "Tokyo": "Tokyo Office", "Singapore": "SG Office", "Sunnyvale": "SV Office", "San Mateo": "SV Office", "Kuala Lumpur": "MY Office", "Selangor": "MY Office", # CN-mainland employees go into HRIS BUs without an office-dept # overlay today; admin can map Beijing/Shanghai → "China Business # Group" later via the value_map override if that team decides so. # PingCAP Intern / Consultants are populated by HR via Feishu UI # directly today — not driven by work_location. } # SF EmpJob.customString7 (区域邮件组/办公室 picklist code) → the Feishu # ``Location`` custom-attr label. This is the SECOND, distinct office # surface from ``OFFICE_LOCATION_TO_FEISHU_DEPT`` above (that one drives # the org-tree office DEPARTMENT; this one fills the free-text "Location" # custom attr that Authing populated, e.g. "SH Office"). Codes + 7 of 11 # labels verified live against the prod Feishu tenant (2026-06-26); the # remaining BJ/CD/MY follow the same " Office" convention as the # doc's office-list. A code not in this map renders empty → the rule is # dropped → Feishu keeps its existing value (never NULL-out). OFFICE_CODE_TO_FEISHU_LOCATION: dict[str, str] = { "2651": "BJ Office", "2653": "SV Office", "2655": "HZ Office", "2657": "CD Office", "2659": "SH Office", "2661": "SG Office", "2663": "GZ Office", "2665": "SZ Office", "2667": "Tokyo Office", "2669": "NJ Office", "2865": "MY Office", } # SF EmpJob.customString25 (员工类型 picklist code) → Feishu's # ``employee_type`` enum (int, sent as int by the runner). Mapping # SF EmpJob.customString25 employee-type code → Feishu employee_type # enum (int). Feishu tenant has ONLY the 5 built-in types (verified via # GET /contact/v3/employee_type_enums 2026-07-02): 1=正式 2=实习 3=外包 # 4=劳务(Contractor/派遣社員) 5=顾问. SF has 6 categories, so several # collapse. Mapping CONFIRMED WITH HR 2026-07-02; 1074/1075 corrected # 2026-07-15 — SF swapped 派遣人员/外包人员 between the two picklist codes, # so 1074 is now 外包人员 and 1075 is now 派遣人员 (the label→Feishu-type # semantics are unchanged: 外包人员→外包, 派遣社員→劳务): # 1070 正式 → 1 正式 # 1071 EOR → 4 劳务 (HR: EOR ≈ Contractor) # 1072 实习生 → 2 实习 # 1073 劳务/顾问 → 5 顾问 (HR: treat as consultant) # 1074 外包人员 → 3 外包 # 1075 派遣人员 → 4 劳务 (Feishu 劳务 == 派遣社員) # A code missing from this map renders empty → field dropped → Feishu # keeps its existing value (safe default for any future picklist add). # # ⚠️ Presets are CREATION TEMPLATES only — the runtime source of truth is # the live target's DB row (``identity_sync_targets.mapping_config``), # seeded from here once at target creation. Editing this map (or any # preset rule) does NOT change live behaviour by itself: the deployed # target keeps its stored copy until ``update_target`` writes a new # mapping_config (which also busts the payload-hash cache). The #1950 # 1074/1075 swap was fixed here AND landed in prod only via a direct # update of the target row — do both, in that order, every time. EMPLOYEE_TYPE_CODE_TO_FEISHU: dict[str, str] = { "1070": "1", # 正式 "1071": "4", # EOR → 劳务 "1072": "2", # 实习生 "1073": "5", # 劳务/顾问 → 顾问 "1074": "3", # 外包人员 → 外包 "1075": "4", # 派遣人员 → 劳务 } # SF PerPersonal.gender → Feishu ``gender`` enum (int). Verified live # (2026-06-26): M→1, F→2. Unknown/empty → field dropped → unchanged. GENDER_TO_FEISHU: dict[str, str] = {"M": "1", "F": "2"} # HRIS ISO-3 country code → Feishu ``country`` field value. Verified live # (2026-07-01): Feishu's standard ``country`` field holds **ISO-2** codes # (``CN`` renders as "Chinese Mainland", ``JP`` as "Japan"). Helix's # ``identity_persons.country`` is ISO-3 (``CHN``/``JPN``/…), so sending it # raw would break the Region display — it MUST be converted to ISO-2. # Keys mirror the ISO-3 set in ``_COUNTRY_DIALING_CODE`` (the countries # PingCAP has staff in). A value-map MISS (unmapped / empty country) renders # empty → the rule drops → Feishu keeps its existing Region (never NULL-out). # ``COUNTRY_ISO3_TO_ISO2`` lives in ``mapping.py`` (imported at the top of # this module) — the ``mobile_country_iso2`` token needs it too, and two # copies drifting apart renders an empty country field on exactly one # vendor: silent, and only visible on that vendor's profile page. # User target. Mapping rules verified against the LIVE PingCAP tenant # (custom_attrs 2026-05-28; gender/employee_type/office-code value-maps # 2026-06-26). The mapped custom_attrs (PinYin / Company Entity / # Location / Contact Email + HRBP GENERIC_USER) are schemas that exist on # Feishu AND have a faithful HRIS source. Job Function / Job Family are # deliberately NOT mapped (no clean SF source — see note at the bottom of # this list). Unknown schemas referenced by mapping rules get silently # dropped by the runner's catalog filter (admins zero-touch — no need to # pre-create anything on Feishu). FEISHU_CONTACT_USER_MAPPING_RULES: list[dict[str, Any]] = [ # Core identity {"source_template": "{work_email}", "transform": "lower", "target_field": "email"}, # name = the Chinese (native) name so Feishu is searchable by 中文名 # and displays the localised name — the CJK part of the SF displayName # ("孟凡生 Jason Meng" → "孟凡生"). Foreign staff (no CJK) pass through # as their English name. NOT the email-derived full_name ("Fansheng # Meng"), which erased the Chinese name and broke 中文 search. {"source_template": "{display_name}", "transform": "cjk_name", "target_field": "name"}, # en_name = the PREFERRED English name, i.e. the Latin part of the SF # displayName ("孟凡生 Jason Meng" → "Jason Meng") — his actual English # name, not the email romanisation "Fansheng Meng". Pure-CJK name → "" # → rule drops (Feishu keeps its existing en_name). {"source_template": "{display_name}", "transform": "latin_name", "target_field": "en_name"}, # employee_no = HRIS employee_id (P0001 style). Drives the # lookup-by-employee_no primary path in the runner. {"source_template": "{employee_id}", "target_field": "employee_no"}, # department_ids — runner translates the HRIS code into Feishu # od-XXX via the dept_state index, then MERGES with the user's # existing Feishu dept_ids (preserves geographic / intern / # contractor depts that Helix doesn't manage). {"source_template": "{department_code}", "target_field": "department_ids"}, # office_dept_name — the SECOND dept dimension. Driven by the # HRIS **office** (``office_code`` = SF EmpJob.customString7), NOT # work_location: an office spans many residence cities (SV Office = # Sunnyvale / Seattle / San Jose / …), so keying on the city missed # everyone whose city wasn't in the map. ``office_code`` → # Feishu office-dept name ('SV Office' / 'SG Office' / 'Tokyo Office' # / 'MY Office', identical to the Google Office-group axis) via the # same OFFICE_CODE_TO_FEISHU_LOCATION map the ``Location`` custom-attr # uses. The runner resolves that name against the Feishu dept catalog # at sweep time → od-XXX, added to ``department_ids`` ALONGSIDE the # HRIS BU dept. CN-mainland office labels with no Feishu office dept # (BJ/SH/GZ/SZ/HZ/CD/NJ Office) render the name but fall through the # catalog lookup benignly (no office dept assigned), same as before. # Office od-XXX is recognised as Helix-managed by the merge step, so # an office change actually MOVES the Feishu office dept, not just adds. { "source_template": "{office_code}", "value_map": dict(OFFICE_CODE_TO_FEISHU_LOCATION), "target_field": "office_dept_name", }, # Manager — runner resolves email → user_id internally. {"source_template": "{manager_email}", "target_field": "leader_user_id_email"}, # HRBP — sourced from SF EmpJobRelationships (2448) via # ``hris_hrbp_person_id``. The runner resolves this email → Feishu # user_id and writes it as the HRBP **GENERIC_USER** custom attribute # (auto-discovered in the live catalog by its "HRBP" i18n_name — no # hardcoded schema id). Empty when the person has no HRBP in SF → the # runner omits the attr (leaves Feishu's existing value untouched). {"source_template": "{hrbp_email}", "target_field": "hrbp_user_id_email"}, # Person attributes {"source_template": "{job_title}", "target_field": "job_title"}, # ``city`` — pushed from HRIS (user decision 2026-07-01: sync every # field Feishu carries, HRIS as source of truth). The HRIS value carries # the administrative 市 suffix ("上海市"); Feishu's city dictionary only # renders the bare name ("上海"), so ``cn_city`` strips it. JP stays # romanised ("Tokyo"); Feishu tolerates that as free text. {"source_template": "{city}", "transform": "cn_city", "target_field": "city"}, # ``country`` drives Feishu's Region display; Feishu stores ISO-2 ("CN" → # "Chinese Mainland"), so the ISO-3 HRIS code is value-mapped to ISO-2. # A miss (unmapped/empty) drops the field → Feishu keeps its Region. { "source_template": "{country}", "value_map": dict(COUNTRY_ISO3_TO_ISO2), "target_field": "country", }, # 性别 + 员工类型 — SF-sourced (migration 344), projected to Feishu's # integer enums via verified value-maps; the runner int-coerces both # (Feishu wire = int). An unmapped/empty source renders empty → the # rule drops → Feishu keeps its existing value (never NULL-out). {"source_template": "{gender}", "value_map": dict(GENDER_TO_FEISHU), "target_field": "gender"}, { "source_template": "{employee_type_code}", "value_map": dict(EMPLOYEE_TYPE_CODE_TO_FEISHU), "target_field": "employee_type", # NOTE: intentionally NO ``default`` here — the preset's drop-on-empty # principle (unmapped/empty → omit the field → Feishu keeps its # existing value) must hold on UPDATE so a sync never clobbers a good # downstream value. The Directory create call accepts a minimal # employee payload; Contact PATCH applies this field afterwards. }, # Authing-parity fields (the legacy 飞书#0 sync task pushed 手机号 # + 入职日期; verified against the live Authing task config # 2026-06-11). ``join_time`` is converted by the runner to the # unix-seconds integer Feishu's wire format requires. {"source_template": "{mobile_phone}", "target_field": "mobile"}, # Synthetic field: the RAW HRIS country (ISO-3, no value_map) carried # alongside {mobile} so the runner can tell "country set-but-unmapped" # (drop the mobile, don't guess +86) from "country genuinely empty" # (+86 default). The ``country`` rule above value-maps to ISO-2 and drops # on a miss, collapsing both cases to empty — this preserves the raw # signal. The runner pops it before building the Feishu body. {"source_template": "{country}", "target_field": "mobile_country_iso3"}, # Synthetic field: the mobile's OWN country ISO-2. Takes PRECEDENCE over # the home-country derivation in the runner's mobile formatter, so an # international assignee's host-country phone (+65) formats correctly # instead of being forced to the home dialing code (+86 → invalid → # Feishu 41004). The runner pops it before the wire body. # # Sourced from the derived ``{mobile_country_iso2}`` token, NOT from raw # ``{mobile_country_code}`` + an ISO-3 value-map. That older shape assumed # SF hands us ISO-3; it does not. SF's ``CountryCode`` picklist # externalCodes are MIXED per instance — live 2026-09-01: sf-cn-prod 17/19 # ISO-2, sf-eu-prod 17/27 ISO-2, the rest ISO-3 (``THA``/``NLD``/…) plus # the free-text ``GB / UK``. An ISO-3-keyed map therefore MISSES every # already-correct ISO-2 value ("CN" is not a key; "CHN" is), renders # empty, and the formatter silently falls back to the person's WORK # country — which is only harmless when the two agree. It did not agree # for 27 of prod's 411 phone-holders: 18 got a wrong dialing code welded # on (all 5 HK staff carry mainland numbers, e.g. P10027 # +85217688850795), and 9 were rejected by Feishu (41004) and dropped, so # Helix stopped maintaining their number at all. # # ``resolve_country_iso2`` handles every observed shape (2-letter → # passthrough, ISO-3 → mapped, junk → ""), and the token's work-country # fallback preserves the prior behaviour for the phone-country-less. The # sibling 汇联易 rule already sources this token; feishu_contact was left # on the old shape when it landed — that gap is what this closes. { "source_template": "{mobile_country_iso2}", "target_field": "mobile_country_iso2", }, {"source_template": "{hire_date}", "target_field": "join_time"}, # custom_attrs — 4 schemas that ACTUALLY exist on the PingCAP # Feishu tenant (verified 2026-05-28). Runner translates each # normalised key (``pinyin``, ``company_entity``, etc.) into # Feishu's wire format ``[{type, id, value: {text}}]`` using the # live catalog. Names not in the live catalog get dropped with # an info log — never blocks the user push. # PinYin — SF has no pinyin field, so derive it from the CJK run of # display_name only ("冯帅 Shuai Feng" → "fengshuai"). Sourcing the raw # {name_pinyin} leaked the romanised English tail ("fengshuai shuai # feng"); cjk_pinyin keeps the clean surname+given pinyin. Foreign # (no-CJK) staff render "" → rule drops → Feishu unchanged. {"source_template": "{display_name}", "transform": "cjk_pinyin", "target_field": "custom_attrs.pinyin"}, { "source_template": "{company_name}", "transform": "strip_company_prefix", "target_field": "custom_attrs.company_entity", }, # Location custom-attr ("SH Office" etc.) — sourced from SF # EmpJob.customString7 (区域邮件组/办公室, migration 344) via the # verified office-code → label map. (Previously mis-sourced from # {location}=work_location_name="上海市", which never matched the # office-label Authing wrote — fixed here.) { "source_template": "{office_code}", "value_map": dict(OFFICE_CODE_TO_FEISHU_LOCATION), "target_field": "custom_attrs.location", }, # Contact Email custom-attr — Authing populated it with the work # email; mirror that (TEXT schema, normalised key "contact_email"). {"source_template": "{work_email}", "transform": "lower", "target_field": "custom_attrs.contact_email"}, # GitHub ID custom-attr — SF cust_Other.cust_id (migration 369), # keyed by employee_no. The Feishu schema "GitHub ID" # (C-6934211695879389211, verified live 2026-06-30) normalises to # ``github_id`` so the runner resolves it via the live catalog (no # hardcoded id). Sparse: a person without a SF entry renders empty → # the rule drops → Feishu keeps its existing (Authing-era) value, # never NULL-out. (The 2026-06-26 ADR called GitHub "no SF source"; # cust_Other.cust_id has since been backfilled — see DECISIONS.md.) {"source_template": "{github_id}", "target_field": "custom_attrs.github_id"}, # Job Function / Job Family custom_attrs — pushed from SF (user # decision 2026-07-01: HRIS is source of truth, sync every field # Feishu carries). ``job_function`` = SF FOJobFunction name, # ``job_family`` = cust_JobFamily name. NOTE these can differ from # Feishu's current (北森-era) values — e.g. P0989 SF says "IT Service" # / "IT Management" while Feishu shows "IT" / "Cybersecurity"; the sync # will overwrite to the SF value (data accuracy is HR's to fix in SF). # Most staff match (HR/HRBP, Operations). Empty source → rule drops → # Feishu unchanged. (Reverses the 2026-06-26 "no source" ADR.) {"source_template": "{job_function}", "target_field": "custom_attrs.job_function"}, {"source_template": "{job_family}", "target_field": "custom_attrs.job_family"}, # NOTE: ``Beisen ID`` custom_attr is NOT mapped — 北森 (Beisen) is the # retired HRIS; SF carries no Beisen id, so there is no source. Feishu's # existing Beisen ID is left untouched (preserved). # NOTE: We intentionally do NOT map Feishu's ``Last Updated`` # custom_attr. Wiring a fresh-timestamp rule into the payload # would force every sweep to PATCH every user (payload hash # changes every render), losing the hash-skip optimisation that # keeps daily sweep cost flat. Admin tested both shapes and # picked hash-skip > timestamp freshness — see ROADMAP / DECISIONS # for the rationale. ] _FEISHU_USER_FILTER: dict[str, Any] = { "include_offboarded": False, "departments": [], "include_subtree": True, "countries": [], "company_codes": [], "employment_statuses": ["active", "on_leave"], "job_title_includes": [], # Only real HRIS employees (id like ``P01478``) reach Feishu. This # gate excludes service / admin rows whose employee_no isn't # P-shaped (verified 2026-06-08 against prod: exactly TimeAdmin + # LifecyleAdmin, both employee_no non-P, company NULL). Email-domain # filtering is NOT used here because a real employee carries # ``@pingcap-c.com`` — only employee_no cleanly isolates the two. "employee_no_pattern": r"^P\d+$", "person_ids": [], "mode": "and", } # ── 汇联易 (Huilianyi) ──────────────────────────────────────────────── # Employee-create/update V2 required fields (verified against # opendocs.huilianyi.com 2026-06-10): employeeID (工号, tenant-unique), # fullName, email (tenant-unique), custDeptNumber (dept code), # companyCode (multi-entity tenants). The dept phase is admin-invariant # (HRIS dept code/name/parent → 创建/更新部门V2) — no mapping rules, # mirroring feishu_contact's Phase A. # # companyCode passes Helix's HRIS company_code through verbatim by # default. PingCAP's 汇联易 tenant hosts multiple legal entities # (北京星云深图 / 平凯星辰 / …) — after reconciling against the vendor # employee export, add a ``value_map`` on this rule if the two code # systems differ. # # Intentionally NOT mapped: mobile (tenant-unique on the vendor side — # pushing a stale HRIS number collides with reassigned numbers), # dutyCode/rankCode (vendor-managed code tables, not HRIS free-text). # Helix SF company_code → 汇联易公司编码. Both sides enumerated live on # 2026-06-10 (Helix prod identity_persons × 汇联易后台 账套/法人/公司 → # 公司列表, 29 enabled companies). Every legal entity that currently has # an active employee in Helix is mapped; 汇联易-only companies (002 # 北京平凯星辰科技发展, 010 上海星数云集, 6 新纪星云, 00106 平凯南京) # have no Helix-active people and need no entry. NOTE the mapping # engine DROPS a rule whose value_map misses (renders None → field # absent, which on the vendor PUT means "leave unchanged") — the loud # failure for an unmapped company_code lives in HuilianyiRunner's # required-field guard, which fails the person with an # extend-the-value_map hint instead of letting a transfer silently # keep the old legal entity. Extend the map, don't add a default. HUILIANYI_COMPANY_CODE_MAP: dict[str, str] = { # Overseas entities were RE-CODED on the 汇联易 side (verified live via # 公司列表 2026-06-16): the old bare-numeric codes were 禁用 and replaced # with suffixed codes. Map to the ACTIVE (启用) code or the push fails # "公司不存在". Domestic 平凯星辰 / 星云深图 codes below were re-confirmed # active in the same pass. "DP00001": "006US", # PingCAP (US), Inc. (006 禁用 → 006US 启用) "DP00002": "008PTE", # PINGCAP PTE. LTD. (008 禁用 → 008PTE 启用) "DP00003": "25", # PINGCAP SDN. BHD. (25 启用) "DP00004": "009JP", # PingCAP 株式会社 (009 禁用 → 009JP 启用) "DP00005": "5", # 北京星云深图软件科技有限公司 "DP00006": "7", # 北京星云深图 上海分公司 "DP00007": "1", # 北京星云深图 杭州分公司 "DP00008": "2", # 北京星云深图 成都分公司 "DP00009": "4", # 北京星云深图 广州分公司 "DP00010": "3", # 北京星云深图 深圳分公司 "DP00011": "19", # 北京星云深图 南京分公司 "DP00012": "001", # 平凯星辰(北京)科技有限公司 "DP00013": "00105", # 平凯星辰 上海分公司 "DP00014": "00102", # 平凯星辰 杭州分公司 "DP00015": "00104", # 平凯星辰 成都分公司 "DP00016": "00101", # 平凯星辰 广州分公司 "DP00017": "00103", # 平凯星辰 深圳分公司 # 20_Shanghai CAP Technology and Development = 上海楷浦科技发展 # (楷浦 = CAP transliteration; the 汇联易 tenant itself runs under # this entity). Verify once more in the dry-run report before # cutover. "DP00020": "003", } # 汇联易 department cost-center custom field (customFormValues fieldCode="CC"). # 汇联易 stores the cost center as the FULL label "C0906 BO-Business Operation" # while Helix's HRIS only carries the bare code "C0906" (SF EmpJob.costCenter, # per-person, 1:1 with department — verified prod 2026-06-15: 425/425 in-scope # people have a cost center, every department's members share exactly one). This # map (code → full label) is the only way to render the value 汇联易 expects on # the dept push; it was enumerated live from the 131 vendor dept records' # customFormValues on 2026-06-15 (62 distinct in-use codes, 0 Helix codes # missing → 100% coverage). The dept phase (NOT the user mapping_rules) consumes # this directly. Extend with a new "C####