/** * 人员架构地基改造 · 第 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() 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() 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() 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() })