"""Write an employee's GitHub handle back to SuccessFactors. Helix READS the handle from the SF custom MDF entity ``cust_Other`` (``externalCode`` = employee_no, ``cust_id`` = the handle) — see ``SuccessFactorsClient.list_github_ids`` and migration 369. The field is collected once on the onboarding 「其他声明」form and never updated afterwards, so 154 of 417 active employees have no handle on file and the github_org domain cannot resolve them. This module is the write half. It mirrors ``hris_email_writeback`` (the platform's only other SF write path, live since 2026-06-11) down to the contract: never raises, returns a structured dict the caller folds into its audit row. Why so much is configurable --------------------------- ``PerEmail`` is a standard EC entity whose upsert shape HR handed us. ``cust_Other`` is a **custom MDF object**, and three of its properties are tenant configuration we do not control and could not verify from the read side: * whether it is effective-dated (an effective-dated MDF object rejects an upsert without ``effectiveStartDate``), * whether the API user may CREATE a row for someone who has none yet (263 of 417 have a ``cust_Other`` row; the rest would need an insert), * whether the object carries other required fields that block an upsert. All three are answered by HR's SF administrator, not by us, and each answer is a payload change. Encoding them as ``platform_settings`` keys means the answers land as a config edit rather than a code change + release. The defaults are the simplest shape (non-effective-dated, no extra fields), which is what the entity looks like from the read side. Config (``platform_settings.hris.github_writeback``):: { "enabled": true, # REQUIRED to write. Default is OFF — see below "entity": "cust_Other", # MDF entity holding the handle "field": "cust_id", # the property carrying the handle "key_field": "externalCode", # keyed by employee_no "effective_dated": false, # true → send effectiveStartDate "effective_start_date": null, # "YYYY-MM-DD"; null → today (UTC) "extra_fields": {} # any other required properties, verbatim } Why the default is OFF (unlike ``hris.email_writeback``, which defaults ON) -------------------------------------------------------------------------- The email write-back defaults on because HR handed us the exact payload and it targets a standard EC entity. Here the payload shape is the open question, and the failure mode is not symmetric: on an EFFECTIVE-DATED MDF object an upsert with no ``effectiveStartDate`` does not necessarily get rejected — it can create a time slice with the wrong start date, i.e. a silently wrong record in the HRIS of record. So "we have write permission" must not be sufficient to start writing; somebody has to have READ HR's answers and turned this on. Until then every approved change records ``skipped`` with the reason, the employee's value is still live in Helix and Feishu, and ``identity_persons.github_id_locked_at`` keeps the sync from reverting it. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.core.safe_logging import get_safe_logger from app.identity.hris_client_routing import sf_client_for_person from app.identity.person_models import IdentityPerson from app.integrations.service import get_platform_setting logger = get_safe_logger(__name__) _SETTING_KEY = "hris.github_writeback" _DEFAULT_ENTITY = "cust_Other" _DEFAULT_FIELD = "cust_id" _DEFAULT_KEY_FIELD = "externalCode" _MSG_CAP = 500 def _record_ok(rec: dict[str, Any]) -> bool: """SF's per-record verdict inside an HTTP-200 upsert envelope.""" status = str(rec.get("status") or "").upper() http_code = rec.get("httpCode") return status == "OK" or ( not status and isinstance(http_code, int) and http_code < 300 ) async def write_github_id_to_hris( db: AsyncSession, *, person: IdentityPerson, github_id: str, trace_id: str = "", ) -> dict[str, Any]: """Upsert ``github_id`` onto the person's SF ``cust_Other`` row. NEVER raises — a HRIS write-back failure must not roll back a change the employee already had approved. The caller records the returned ``status`` on the request row so a ``failed`` write is visible and actionable (HR updates SF by hand) instead of silent. ``status`` is one of: * ``skipped`` — switch off, no ``employee_no`` to key on, or the SF connector is not configured. ``reason`` says which. Nobody needs to act. * ``ok`` — SF acknowledged the upsert at the record level. * ``failed`` — SF was reachable and rejected the write (record-level ERROR inside the 200 envelope), or the transport failed. Someone has to fix SF by hand; until they do, ``identity_persons.github_id_locked_at`` keeps the sync from reverting the employee's value. """ setting = await get_platform_setting(_SETTING_KEY, default={}, session=db) if not isinstance(setting, dict): setting = {} # Opt-IN, not opt-out — see the module docstring. ``enabled`` missing is # "nobody has answered HR's questions yet", which must not write. if setting.get("enabled") is not True: return { "status": "skipped", "reason": ( "hris.github_writeback is not enabled — set " "platform_settings 'hris.github_writeback'.enabled=true once " "the SF cust_Other write permission and payload shape are " "confirmed" ), } # ``cust_Other`` is keyed by employee_no (externalCode), NOT by the # userId that keys EmpJob/PerEmail — so unlike the email write-back # there is no personIdExternal round-trip to make. employee_no = (person.employee_no or "").strip() if not employee_no: return { "status": "skipped", "reason": "person has no employee_no (cannot key cust_Other)", } entity = str(setting.get("entity") or _DEFAULT_ENTITY).strip() field = str(setting.get("field") or _DEFAULT_FIELD).strip() key_field = str(setting.get("key_field") or _DEFAULT_KEY_FIELD).strip() record: dict[str, Any] = {key_field: employee_no, field: github_id} extra = setting.get("extra_fields") if isinstance(extra, dict): record.update(extra) if setting.get("effective_dated") is True: start = str(setting.get("effective_start_date") or "").strip() record["effectiveStartDate"] = start or datetime.now(UTC).strftime( "%Y-%m-%d", ) try: client = await sf_client_for_person(db, person) except AppException as exc: logger.info( "hris.github_writeback: SuccessFactors connector not configured " "— skipping write-back for person_id=%s (%s)", person.id, exc.message, ) return {"status": "skipped", "reason": exc.message} # The whole body is SF HTTP I/O; ``db`` is threaded through only for the # connector lookup above and is never written to here. # savepoint-exempt: no DB writes; body is external HTTP only try: records = await client.upsert_records(entity, [record]) except Exception as exc: # noqa: BLE001 — contract: never raise logger.warning( "hris.github_writeback: upsert error for person_id=%s " "(externalCode=%s) — %s: %s", person.id, employee_no, type(exc).__name__, exc, ) return { "status": "failed", "reason": f"{type(exc).__name__}: {exc}"[:_MSG_CAP], } first = records[0] if records else {} if records and all(_record_ok(r) for r in records): logger.info( "hris.github_writeback: upserted %s.%s=%s for externalCode=%s " "(person_id=%s, trace=%s)", entity, field, github_id, employee_no, person.id, trace_id, ) return { "status": "ok", "entity": entity, "external_code": employee_no, "sf_status": str(first.get("status") or "").upper() or None, "sf_http_code": first.get("httpCode"), } failed = next((r for r in records if not _record_ok(r)), first) f_status = str(failed.get("status") or "").upper() f_http = failed.get("httpCode") f_msg = str(failed.get("message") or "")[:_MSG_CAP] logger.warning( "hris.github_writeback: SF rejected %s upsert for externalCode=%s " "(person_id=%s) — status=%s httpCode=%s %s", entity, employee_no, person.id, f_status, f_http, f_msg, ) return { "status": "failed", "entity": entity, "external_code": employee_no, "sf_status": f_status or None, "sf_http_code": f_http, # An empty envelope is NOT success — SF answering with no records at # all means we cannot claim the write landed. "reason": ( f_msg or (f"SF record status {f_status or f_http}" if records else "SF returned an empty upsert envelope") ), }