"""Secondary HRIS userIds that belong to a person Helix already has. Revision ID: 514 Revises: 513 Create Date: 2026-08-18 SuccessFactors keys a *person* by ``personIdExternal`` and an *employment* by ``userId``. Helix keys a person by ``userId`` (``identity_persons. hris_person_id``) — which is the same thing right up until someone holds two concurrent employments. PingCAP prod 2026-06-26: 孙若曦 has ``P0050`` (US entity, since 2018) and ``80100061`` (the CN assignment that heads Efficiency & Quality), both ``endDate IS NULL``, both ``personIdExternal = P0050``. Her team's ``EmpJob.managerId`` points at ``80100061``, which is not a row Helix keeps — so four active people resolved to no manager at all, and ``backfill_manager_links`` re-missed them on every sync because it chases the same single column. This table is the missing indirection: ``alias_user_id`` → the canonical person. Resolution order everywhere is ``hris_person_id`` first, then here, so nothing changes for the 99% of people whose userId is their only one. UNIQUE on ``(source_system, alias_user_id)`` because a userId identifies at most one human, ever — the same guarantee ``uq_identity_persons_source`` gives the primary column. """ from __future__ import annotations import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for, table_kw_for revision = "514" down_revision = "513" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "identity_hris_user_aliases", sa.Column("id", id_type_for(), primary_key=True), sa.Column( "person_id", id_type_for("identity_persons"), sa.ForeignKey("identity_persons.id"), nullable=False, ), sa.Column("source_system", sa.String(32), nullable=False), # The secondary HRIS userId. Same width as # ``identity_persons.hris_person_id`` so anything that fits the # primary column fits here. sa.Column("alias_user_id", sa.String(128), nullable=False), # What the HRIS said the person key was — the evidence for the # mapping, so a future reader can re-verify it against SF without # guessing which heuristic produced the row. sa.Column("person_id_external", sa.String(128), nullable=True), # Which tenant the alias was observed in. Nullable: legacy # single-instance rows carry NULL, mirroring identity_persons. sa.Column("hris_instance_id", id_type_for(), nullable=True), # 'concurrent_employment' (personIdExternal already owned by a # live person) | 'email_collision' (the canonical row already # owns the work_email) | 'manual'. sa.Column("reason", sa.String(32), nullable=False), sa.Column("note", sa.String(255), nullable=True), sa.Column( "first_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), # Bumped every time a sync re-observes the alias, so a mapping # that SF has since cleaned up is visible as a stale row rather # than silently steering resolution forever. sa.Column( "last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now(), ), sa.UniqueConstraint( "source_system", "alias_user_id", name="uq_identity_hris_alias_source_user", ), **table_kw_for("identity_hris_user_aliases"), ) op.create_index( "ix_identity_hris_alias_person", "identity_hris_user_aliases", ["person_id"], ) def downgrade() -> None: # No explicit ``drop_index`` for ``ix_identity_hris_alias_person``: # it is the index backing the ``person_id`` foreign key, so MySQL # rejects the drop with 1553 ("needed in a foreign key constraint"). # ``drop_table`` takes the index with it. op.drop_table("identity_hris_user_aliases")