chenpei 7b0ee5efb4 feat: 人员架构统一到钉钉唯一值,接入上游组织架构动态同步
素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时
逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随
上游 HR 系统自动更新。

人员身份
- 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一
- users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号
- 移除钉钉登录的同名自动合并,同名冲突一律转人工核对
- 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权

组织架构
- 新增 OrganizationUnit,直接镜像上游 HR 部门树
- 不再从员工部门路径倒推,同名部门不会再被合并
- 上游已删除的部门下线而非物理删除

人员状态
- 新增规范化的 employmentState,取代对混杂原始值做关键词匹配
- 接入上游离职表,离职判定不再依赖中文关键词
- 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者

素材归属
- 归属变更纳入正式转移,记录最初上传者、转移流水与理由
- 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名
- 编辑素材的权限判定统一到 canManageMaterial
- 修复任务-详情上传把邮箱误存为作者名的问题

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:53:48 +08:00

122 lines
4.8 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 素材库账号 ↔ 权威人员档案 正式绑定关系
--
-- 背景:此前账号与花名册人员之间没有任何外键,仅在运行时按
-- 钉钉ID → 邮箱 → sourcePayload JSON 逐层软匹配,数据库层面
-- 并不知道某个素材库账号和某条花名册档案是同一个人。
--
-- 本迁移建立正式绑定表,并按钉钉唯一值回填存量绑定。
-- 严格遵守:只按钉钉唯一值自动绑定,姓名与邮箱不参与自动建立绑定。
--
-- 可重复执行。
BEGIN;
-- ========== 1. 前置校验 ==========
-- 一个钉钉身份对应多个素材库账号时,回填会在同一人员档案上产生
-- 多条 ACTIVE 绑定,违反下面的部分唯一索引。此处提前拦截并给出明确原因,
-- 而不是让索引创建阶段抛出难以定位的报错。
DO $$
DECLARE
conflict_count INTEGER;
BEGIN
SELECT COUNT(*) INTO conflict_count
FROM (
SELECT "dingtalkUserId"
FROM users
WHERE "dingtalkUserId" IS NOT NULL
GROUP BY "dingtalkUserId"
HAVING COUNT(*) > 1
) AS duplicated;
IF conflict_count > 0 THEN
RAISE EXCEPTION
'存在 % 个钉钉账号绑定了多个素材库账号,必须先人工处理后再执行本迁移。禁止按姓名自动合并。',
conflict_count;
END IF;
END $$;
-- ========== 2. 钉钉唯一值:全项目唯一 ==========
-- 此前 users."dingtalkUserId" 没有任何唯一约束,数据库层面允许两个素材库账号
-- 填入同一个钉钉身份。既然钉钉唯一值是全项目唯一的人员判定依据,这条约束必须落到库上。
-- PostgreSQL 中多个 NULL 互不冲突,因此未绑定钉钉的账号不受影响。
CREATE UNIQUE INDEX IF NOT EXISTS "users_dingtalkUserId_key"
ON users("dingtalkUserId");
-- ========== 3. 建表 ==========
CREATE TABLE IF NOT EXISTS user_employee_bindings (
id TEXT NOT NULL,
user_id TEXT NOT NULL,
employee_profile_id TEXT NOT NULL,
binding_source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE',
verified_at TIMESTAMP(3),
note TEXT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL,
CONSTRAINT user_employee_bindings_pkey PRIMARY KEY (id)
);
-- 外键:账号或人员档案被删除时,绑定关系一并清除
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_user_id_fkey'
) THEN
ALTER TABLE user_employee_bindings
ADD CONSTRAINT user_employee_bindings_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_profile_id_fkey'
) THEN
ALTER TABLE user_employee_bindings
ADD CONSTRAINT user_employee_bindings_profile_id_fkey
FOREIGN KEY (employee_profile_id) REFERENCES performance_survey_employee_profiles(id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ========== 4. 索引 ==========
CREATE INDEX IF NOT EXISTS user_employee_binding_user_idx ON user_employee_bindings(user_id);
CREATE INDEX IF NOT EXISTS user_employee_binding_profile_idx ON user_employee_bindings(employee_profile_id);
CREATE INDEX IF NOT EXISTS user_employee_binding_status_idx ON user_employee_bindings(status);
-- 活跃绑定的双向唯一:一个账号只能绑一名权威人员,一名权威人员只能绑一个活跃账号。
-- 使用部分唯一索引,从而允许保留历史(status <> 'ACTIVE')绑定记录。
CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_user_key
ON user_employee_bindings(user_id) WHERE status = 'ACTIVE';
CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_profile_key
ON user_employee_bindings(employee_profile_id) WHERE status = 'ACTIVE';
-- ========== 5. 回填存量绑定 ==========
-- 只按钉钉唯一值匹配。行 ID 由 user_id + profile_id 确定性生成,保证重复执行幂等。
INSERT INTO user_employee_bindings (
id, user_id, employee_profile_id, binding_source, status, verified_at, created_at, updated_at
)
SELECT
md5('user_employee_binding:' || u.id || ':' || p.id)::uuid::text,
u.id,
p.id,
'MIGRATION',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM users u
JOIN performance_survey_employee_profiles p
ON p.dingtalk_user_id = u."dingtalkUserId"
WHERE u."dingtalkUserId" IS NOT NULL
AND u."dingtalkUserId" <> ''
AND NOT EXISTS (
SELECT 1 FROM user_employee_bindings b
WHERE b.user_id = u.id AND b.status = 'ACTIVE'
)
AND NOT EXISTS (
SELECT 1 FROM user_employee_bindings b
WHERE b.employee_profile_id = p.id AND b.status = 'ACTIVE'
);
COMMIT;