"""员工自助修改 GitHub ID:申请单 + 一道防回滚闸。 Revision ID: 579 Revises: 578 Create Date: 2026-08-30 ## 这张表补的是哪个洞 ``identity_persons.github_id`` 从入职表单(SF ``cust_Other.cust_id``)采集, **此后再没有任何更新入口**。github_org 域三处依赖它:自动加入规则跳过没有 handle 的人、对账靠它把「认不出是谁」的成员接回人、离职回收靠它找账号。 417 个在职员工里 154 个没有 handle,而 ``GithubOrgJoinRequest`` 的模型注释 写得很清楚:申请单里的 handle **不敢**回写 ``identity_persons``,因为 「下一次 HRIS 同步会被上游值盖掉」。 本迁移让那个回写变得安全,于是自助修改成为可能:员工提交 → HR Ops(缺失时 回退 HRBP)确认 → 先写 SF、再写 Helix、再推飞书。 ## ``identity_persons.github_id_locked_at`` —— 防回滚闸 HR 同步对 github_id 是 **write-when-present**(``hr/service.py::_apply_event``): SF 有值就覆盖。所以「Helix 改了、SF 没改」这个中间态一旦出现,下一次同步会 把员工刚改的值**静默改回去**——员工不会收到任何提示,只会某天发现自己改的 没了。 闸的语义:非 NULL = 「这个值是员工自助改的,Helix 侧当权威」。同步时 * SF 值 == 当前值 → SF 已经收敛,写入并**清掉闸**(恢复 HRIS 权威); * SF 值 != 当前值 → 保留本地值,不让上游覆盖; * 闸为 NULL → 完全维持原有行为,一行代码都不影响存量。 放在 ``identity_persons`` 上而不是从申请单查,是为了让 hr 域不必 import github_org 域(跨域 import 是本仓的硬禁令),同时也避免给全量同步的每个人 加一次查询 —— 同步循环读的就是它已经在读的那一行。 ## 唯一性 不建唯一索引。github_org 的人员解析(``service.py`` / ``reconcile.py``)按 ``lower(trim(github_id))`` 反查人,重复 handle 会让它一次拿到两个人;但历史 数据里是否已有重复未知,而一条建不上的唯一索引会让迁移在生产上直接失败。 拦截放在提交入口(服务层查在职员工是否已占用,占用即拒),存量重复留给 对账去暴露。 """ 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 = "579" down_revision = "578" branch_labels = None depends_on = None TABLE = "github_id_change_requests" PERSONS = "identity_persons" LOCK_COL = "github_id_locked_at" def _table_exists(bind, table: str) -> bool: return bool( bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = :t LIMIT 1", ), {"t": table}, ).scalar() ) def _column_exists(bind, table: str, column: str) -> bool: return bool( bind.execute( sa.text( "SELECT 1 FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t " "AND COLUMN_NAME = :c LIMIT 1", ), {"t": table, "c": column}, ).scalar() ) def upgrade() -> None: bind = op.get_bind() person_id = id_type_for(PERSONS) if not _table_exists(bind, TABLE): op.create_table( TABLE, sa.Column("id", person_id, primary_key=True), sa.Column("person_id", person_id, nullable=False), # 提交这一刻员工原来的 handle(可能为空 = 首次登记)。留档是为了 # 让审批人看到「从什么改成什么」,以及事后能答「谁把它改掉的」。 sa.Column("old_github_id", sa.String(39), nullable=True), sa.Column("new_github_id", sa.String(39), nullable=False), sa.Column("reason", sa.Text(), nullable=True), # pending | approved | rejected | cancelled sa.Column( "status", sa.String(16), nullable=False, server_default="pending", ), # 审批人:提交时解析出来的责任人(HR Ops,缺失时回退 HRBP)。 # 存下来而不是每次现算,是因为人员主数据会变 —— 一张三个月前 # 的单子必须还能说出「当时是谁该批」。 sa.Column("approver_person_id", person_id, nullable=True), # 实际做出裁决的人。与 ``approver_person_id`` 分开:代批是允许 # 的(HR / global_admin),而审计要的是「谁真的点了」。 sa.Column("decided_by_person_id", person_id, nullable=True), sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), sa.Column("decision_note", sa.String(500), nullable=True), # 提交时问过 GitHub「这个 handle 存不存在」的答案。**三态**: # 1=存在 / 0=GitHub 明确 404 / NULL=没问到(没配凭证、限流、 # 网络抖)。NULL 与 0 不能合并 —— 合并会让一次 GitHub 抖动在 # 审批人眼里变成「查无此人」,换来一次冤枉的驳回。 sa.Column("github_exists", sa.Boolean(), nullable=True), # ok | failed | skipped —— 见 constants.HANDLE_CHANGE_HRIS_* sa.Column("hris_write_status", sa.String(16), nullable=True), sa.Column("hris_write_error", sa.String(512), nullable=True), # 只有 pending 行非 NULL —— UNIQUE 索引因此等价于「一个人同时 # 只能有一张在途单」,而终态行不占坑,被驳回后可以重提。 # MySQL/TiDB 没有部分索引,这是本仓既有的 ``open_request_key`` # 手法(email_group_join_requests 迁移 397)。 sa.Column( "open_request_key", sa.String(40), sa.Computed( "CASE WHEN status = 'pending' THEN person_id ELSE NULL END", ), nullable=True, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), ), sa.ForeignKeyConstraint( ["person_id"], [f"{PERSONS}.id"], ondelete="CASCADE", ), sa.ForeignKeyConstraint( ["approver_person_id"], [f"{PERSONS}.id"], ondelete="SET NULL", ), sa.ForeignKeyConstraint( ["decided_by_person_id"], [f"{PERSONS}.id"], ondelete="SET NULL", ), **table_kw_for(PERSONS), ) op.create_index( "ix_github_id_change_requests_person", TABLE, ["person_id", "status"], ) op.create_index( "ix_github_id_change_requests_approver", TABLE, ["approver_person_id", "status"], ) op.create_index( "ix_github_id_change_requests_status", TABLE, ["status", "created_at"], ) op.create_index( "uq_github_id_change_requests_open", TABLE, ["open_request_key"], unique=True, ) if not _column_exists(bind, PERSONS, LOCK_COL): op.add_column( PERSONS, sa.Column(LOCK_COL, sa.DateTime(timezone=True), nullable=True), ) def downgrade() -> None: bind = op.get_bind() if _column_exists(bind, PERSONS, LOCK_COL): op.drop_column(PERSONS, LOCK_COL) if _table_exists(bind, TABLE): op.drop_table(TABLE)