"""One-shot: backfill ``identity_persons.is_china_employee`` from the SF labour-contract entity (FOCompany). Why this exists --------------- ``is_china_employee`` (劳动合同主体 = 中国主体?) was, until the 2026-07-15 Day-1 welcome PR, written ONLY by the dormant bootstrap sync (``successfactors_sync.py``). The live hire path (``hr/service.py::_apply_event``) never touched it, so everyone hired or re-synced through the live pipeline *after* the 2026-04-21 bootstrap carries the column's ``False`` default regardless of their real entity. That PR made the welcome email + Feishu card choose language AND the reimbursement tool (汇联易 vs Navan) off this flag, and started maintaining it in ``_apply_event`` — so it is correct for every future hire. This script repairs the EXISTING rows: a routine incremental sync only re-applies ``_apply_event`` for changed people, so post-bootstrap hires would otherwise keep the stale ``False`` until their next SF change. Derivation mirrors ``successfactors_sync._is_china_employee`` exactly: FOCompany country ``CHN`` → China, else a ``CN``-prefixed work-location code as fallback. We read the entity country from the SF FOCompany master (``list_active_companies``) keyed by the person's persisted ``company_code``; no per-person EmpJob re-fetch needed. Dry-run by default. Pass ``--commit`` to persist. Usage:: cd server python scripts/backfill_is_china_employee_2026_07_15.py # dry-run python scripts/backfill_is_china_employee_2026_07_15.py --commit # apply """ from __future__ import annotations import argparse import asyncio import json import logging from sqlalchemy import select # Side-effect model imports so SQLAlchemy mappers resolve. import app.auth.models # noqa: F401 import app.identity.person_models # noqa: F401 from app.core.database import async_session_factory from app.core.safe_logging import get_safe_logger from app.identity.person_models import IdentityPerson from app.identity.successfactors_auth import build_auth_from_config from app.identity.successfactors_client import SuccessFactorsClient from app.identity.successfactors_sync import _is_china_employee from app.integrations.compat import list_hris_instances logger = get_safe_logger("backfill_is_china_employee_2026_07_15") async def _load_company_country_map() -> dict[str, dict]: """externalCode → FOCompany row (carrying ``country``) across every enabled HRIS instance. Later instances win on code collision — the country of a shared entity code is identical, so order is irrelevant. """ companies: dict[str, dict] = {} async with async_session_factory() as session: instances = await list_hris_instances(session=session) for row, cfg in instances: if not row.enabled: continue client = SuccessFactorsClient( base_url=cfg["base_url"], auth=build_auth_from_config(cfg), ) try: rows = await client.list_active_companies() except Exception as exc: # noqa: BLE001 — one bad instance mustn't abort logger.warning("list_active_companies failed for %s: %r", row.slug, exc) continue for c in rows: code = c.get("externalCode") if code: companies[code] = c return companies async def run(*, dry_run: bool) -> int: summary = { "companies_loaded": 0, "persons_scanned": 0, "flipped_to_china": 0, "flipped_to_non_china": 0, "unchanged": 0, "no_company_code": 0, } companies = await _load_company_country_map() summary["companies_loaded"] = len(companies) async with async_session_factory() as session: # Active, non-deleted people only — leavers don't receive the # welcome and their entity is frozen; no value in churning them. persons = (await session.execute( select(IdentityPerson).where( IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status == "active", ) )).scalars().all() for person in persons: summary["persons_scanned"] += 1 company_row = companies.get(person.company_code or "") or {} if not person.company_code: summary["no_company_code"] += 1 # No entity code → fall back to the work-location arm only. want = _is_china_employee(company_row, person.work_location_code) if want == bool(person.is_china_employee): summary["unchanged"] += 1 continue summary["flipped_to_china" if want else "flipped_to_non_china"] += 1 if not dry_run: person.is_china_employee = want if dry_run: await session.rollback() else: await session.commit() print("\n===== SUMMARY =====") print(json.dumps(summary, indent=2, ensure_ascii=False)) print( "\nDRY-RUN — no rows written. Re-run with --commit to persist." if dry_run else "\nCOMMITTED." ) return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--commit", action="store_true", help="Actually write changes. Without it the script prints the " "planned flips and rolls back.", ) parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s", ) return asyncio.run(run(dry_run=not args.commit)) if __name__ == "__main__": raise SystemExit(main())