gongxiangp2/scripts/diagnose-people-architecture.ts
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

331 lines
13 KiB
TypeScript
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.

/**
* 人员架构地基改造 · 第 0 步只读盘点脚本
*
* 目的:在动任何代码之前,用真实数据量化改造的影响面与风险。
*
* 安全声明:
* - 全程只执行 SELECT / COUNT / groupBy,绝不写入、更新、删除任何数据。
* - 不调用任何 S3、钉钉、报表库等外部接口。
* - 仅连接 DATABASE_URL 指向的数据库做只读聚合。
*
* 用法:
* npx tsx scripts/diagnose-people-architecture.ts
*
* 可选参数:
* --verbose 额外打印异常明细(默认只打印统计数字,不输出人名)
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const VERBOSE = process.argv.slice(2).includes('--verbose')
// 与 lib/performance-survey-report-sync.ts:114 保持一致
const INACTIVE_EMPLOYEE_STATUS_KEYWORDS = ['离职', '停用', '退休']
function norm(value: unknown): string {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function section(title: string) {
console.log('\n' + '='.repeat(64))
console.log(title)
console.log('='.repeat(64))
}
function item(label: string, value: number | string, warn = false) {
const flag = warn && Number(value) > 0 ? ' ⚠️' : ''
console.log(` ${label.padEnd(44, '.')} ${String(value).padStart(6)}${flag}`)
}
async function main() {
console.log('\n人员架构地基改造 · 第 0 步只读盘点')
console.log(`执行时间:${new Date().toLocaleString('zh-CN')}`)
// ==================== 一、素材库账号 ====================
section('一、素材库账号(User)')
const totalUsers = await prisma.user.count()
const activeUsers = await prisma.user.count({
where: { isActive: true, archivedAt: null },
})
const archivedUsers = await prisma.user.count({ where: { archivedAt: { not: null } } })
const withDingtalk = await prisma.user.count({ where: { dingtalkUserId: { not: null } } })
item('账号总数', totalUsers)
item('活跃账号(isActive 且未归档)', activeUsers)
item('已归档账号', archivedUsers)
item('有钉钉 ID 的账号', withDingtalk)
item('无钉钉 ID 的账号(无法自动绑定)', totalUsers - withDingtalk, true)
// 一个钉钉身份对应多个账号 —— 绑定表唯一约束的直接阻塞项
const dupDingtalk = await prisma.user.groupBy({
by: ['dingtalkUserId'],
where: { dingtalkUserId: { not: null } },
_count: { _all: true },
having: { dingtalkUserId: { _count: { gt: 1 } } },
})
item('重复的钉钉 ID(一人多账号)', dupDingtalk.length, true)
// 姓名重复 —— 暴露"按姓名自动绑定"的历史风险面
const dupRealName = await prisma.user.groupBy({
by: ['realName'],
where: { realName: { not: null }, archivedAt: null },
_count: { _all: true },
having: { realName: { _count: { gt: 1 } } },
})
item('真实姓名重复(姓名绑定风险面)', dupRealName.length, true)
// ==================== 二、D-A 安全阀 ====================
section('二、D-A 安全阀:收窄同步对考核评估人的影响')
const activeProfileWhere = {
OR: [
{ employmentStatus: null },
{
AND: INACTIVE_EMPLOYEE_STATUS_KEYWORDS.map((keyword) => ({
employmentStatus: { not: { contains: keyword } },
})),
},
],
}
const totalProfiles = await prisma.performanceSurveyEmployeeProfile.count()
const evaluatorPool = await prisma.performanceSurveyEmployeeProfile.count({
where: activeProfileWhere as any,
})
const users = await prisma.user.findMany({
select: { id: true, email: true, dingtalkUserId: true },
})
const userIdSet = new Set(users.map((u) => u.id))
const dingSet = new Set(users.map((u) => u.dingtalkUserId).filter(Boolean) as string[])
const emailSet = new Set(users.map((u) => u.email.toLowerCase()))
const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({
where: activeProfileWhere as any,
select: {
id: true,
displayName: true,
dingtalkUserId: true,
dingEmail: true,
departmentName: true,
departmentPath: true,
sourcePayload: true,
},
})
// 与 lib/performance-survey-report-sync.ts 的账号匹配口径保持一致
function hasMaterialAccount(p: (typeof profiles)[number]): boolean {
if (p.dingtalkUserId && dingSet.has(p.dingtalkUserId)) return true
if (p.dingEmail && emailSet.has(p.dingEmail.toLowerCase())) return true
const payload = p.sourcePayload as any
const linked = payload?.materialUserId || payload?.userId
if (linked && userIdSet.has(String(linked))) return true
return false
}
const unmatched = profiles.filter((p) => !hasMaterialAccount(p))
item('花名册档案总数', totalProfiles)
item('当前可被选为评估人(全体在职)', evaluatorPool)
item('其中有素材库账号', evaluatorPool - unmatched.length)
item('★ 收窄后将选不到的人数', unmatched.length, true)
const shrinkRate = evaluatorPool > 0 ? ((unmatched.length / evaluatorPool) * 100).toFixed(1) : '0.0'
console.log(`\n → 评估人范围收缩比例:${shrinkRate}%`)
console.log(' → 判定标准:接近 0 可继续执行;数量可观则需回到 D-A 决策重新讨论')
if (unmatched.length > 0) {
const byDept = new Map<string, number>()
unmatched.forEach((p) => {
const key = norm(p.departmentName) || '(无部门)'
byDept.set(key, (byDept.get(key) || 0) + 1)
})
console.log('\n 受影响人员的部门分布(前 15):')
Array.from(byDept.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.forEach(([dept, count]) => console.log(` ${dept.padEnd(30, '.')} ${count}`))
if (VERBOSE) {
console.log('\n 明细(--verbose):')
unmatched.slice(0, 50).forEach((p) => {
console.log(` ${norm(p.displayName)} | ${norm(p.departmentPath)}`)
})
if (unmatched.length > 50) console.log(` ...另有 ${unmatched.length - 50} 人`)
}
}
// ==================== 三、部门数据一致性 ====================
section('三、部门数据一致性(双轨字段漂移)')
const departments = await prisma.department.findMany({
select: { id: true, name: true, parentId: true, status: true, managerId: true },
})
const deptNames = new Set(departments.map((d) => d.name))
const deptById = new Map(departments.map((d) => [d.id, d.name]))
const allUsers = await prisma.user.findMany({
select: {
id: true,
department: true,
departmentId: true,
managedDepartment: true,
archivedAt: true,
},
})
const liveUsers = allUsers.filter((u) => !u.archivedAt)
let noDeptId = 0
let mismatch = 0
let orphanDeptName = 0
let orphanManaged = 0
liveUsers.forEach((u) => {
const deptStr = norm(u.department)
if (deptStr && !u.departmentId) noDeptId++
if (u.departmentId) {
const refName = deptById.get(u.departmentId)
if (refName && deptStr && refName !== deptStr) mismatch++
}
if (deptStr && !deptNames.has(deptStr)) orphanDeptName++
const managed = norm(u.managedDepartment)
if (managed && !deptNames.has(managed)) orphanManaged++
})
item('部门表总数', departments.length)
item('有部门字符串但无 departmentId', noDeptId, true)
item('departmentId 与部门字符串不一致', mismatch, true)
item('部门字符串指向不存在的部门', orphanDeptName, true)
item('★ managedDepartment 指向不存在的部门', orphanManaged, true)
console.log('\n → managedDepartment 是无校验字段,却是权限判定的第一优先级输入')
// ==================== 四、部门权限表 ====================
section('四、部门权限表(改名漂移存量)')
const perms = await prisma.departmentMaterialPermission.findMany({
select: { department: true, linkedToDepartment: true, materialType: true },
})
const orphanPerms = perms.filter((p) => !deptNames.has(norm(p.department)))
const linkedPerms = perms.filter((p) => norm(p.linkedToDepartment))
const orphanLinks = linkedPerms.filter((p) => !deptNames.has(norm(p.linkedToDepartment)))
// 多跳关联链:A 关联到 B,而 B 自己也关联到 C
const linkMap = new Map<string, string>()
linkedPerms.forEach((p) => {
linkMap.set(`${p.department}||${p.materialType}`, norm(p.linkedToDepartment))
})
let multiHop = 0
linkMap.forEach((target, key) => {
const materialType = key.split('||')[1]
if (linkMap.has(`${target}||${materialType}`)) multiHop++
})
item('权限记录总数', perms.length)
item('★ 指向已不存在部门的记录', orphanPerms.length, true)
item('设置了关联部门的记录', linkedPerms.length)
item('关联到已不存在部门的记录', orphanLinks.length, true)
item('多跳关联链(现仅支持一跳)', multiHop, true)
if (orphanPerms.length > 0) {
const orphanNames = Array.from(new Set(orphanPerms.map((p) => norm(p.department))))
console.log('\n 失效的部门名(这些权限当前静默不生效):')
orphanNames.slice(0, 20).forEach((n) => console.log(` ${n}`))
if (orphanNames.length > 20) console.log(` ...另有 ${orphanNames.length - 20} 个`)
}
// ==================== 五、两套组织对照 ====================
section('五、两套组织体系对照')
const profileDeptNames = new Set(
profiles.map((p) => norm(p.departmentName)).filter(Boolean)
)
const overlap = Array.from(profileDeptNames).filter((n) => deptNames.has(n))
item('素材库自维护部门数', deptNames.size)
item('花名册出现的部门数(叶子)', profileDeptNames.size)
item('两边名称能对上的部门数', overlap.length)
item('仅存在于素材库的部门', deptNames.size - overlap.length, true)
item('仅存在于花名册的部门', profileDeptNames.size - overlap.length, true)
// 部门负责人两条路径的不同步情况
const managerDepts = departments.filter((d) => d.managerId)
const managedUsers = liveUsers.filter((u) => norm(u.managedDepartment))
item('通过部门表设了负责人的部门', managerDepts.length)
item('通过 managedDepartment 授权的账号', managedUsers.length)
console.log('\n → 这两条路径互不联动:经部门表设的负责人,managedDepartment 为空,')
console.log(' 权限会退回其本人部门,他负责的部门反而看不到')
// ==================== 六、待人工确认清单 ====================
section('六、待人工确认清单(钉钉登录已不再按姓名自动合并)')
const bindings = await prisma.userEmployeeBinding.findMany({
where: { status: 'ACTIVE' },
select: { userId: true },
})
const boundUserIds = new Set(bindings.map((b) => b.userId))
const unboundActive = await prisma.user.findMany({
where: { isActive: true, archivedAt: null },
select: {
id: true,
username: true,
realName: true,
department: true,
dingtalkUserId: true,
},
})
const pending = unboundActive.filter((u) => !boundUserIds.has(u.id))
item('未绑定权威人员的活跃账号', pending.length, true)
if (pending.length > 0) {
// 这些账号一旦有人用同名钉钉身份登录,会被挡下要求人工确认。
// 提前把花名册里的同名候选找出来,便于管理员核对后补绑。
const nameIndex = new Map<string, string[]>()
profiles.forEach((p) => {
;[norm(p.displayName), norm((p as any).realName)].forEach((n) => {
if (!n) return
const list = nameIndex.get(n) ?? []
list.push(`${norm(p.displayName)}|${norm(p.departmentPath) || '无部门'}`)
nameIndex.set(n, list)
})
})
console.log('\n 账号 / 所在部门 / 是否有钉钉ID / 花名册同名候选')
console.log(' ' + '-'.repeat(70))
pending.forEach((u) => {
const uname = norm(u.username)
const candidates = Array.from(
new Set([...(nameIndex.get(uname) ?? []), ...(nameIndex.get(norm(u.realName)) ?? [])])
)
const hint = candidates.length > 0 ? candidates.join(' / ') : '花名册无同名'
console.log(
` ${uname.padEnd(14)} ${(norm(u.department) || '无部门').padEnd(14)} ` +
`${u.dingtalkUserId ? '有' : '无'} ${hint}`
)
})
console.log(
'\n → 有同名候选的:核对无误后补绑,否则这些人首次用钉钉登录会被挡下\n' +
' → 无钉钉ID的:只能用密码登录,不受影响'
)
}
section('盘点结束')
console.log(' 本次执行未写入任何数据。\n')
}
main()
.catch((error) => {
console.error('\n盘点失败:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})