素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时 逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随 上游 HR 系统自动更新。 人员身份 - 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一 - users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号 - 移除钉钉登录的同名自动合并,同名冲突一律转人工核对 - 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权 组织架构 - 新增 OrganizationUnit,直接镜像上游 HR 部门树 - 不再从员工部门路径倒推,同名部门不会再被合并 - 上游已删除的部门下线而非物理删除 人员状态 - 新增规范化的 employmentState,取代对混杂原始值做关键词匹配 - 接入上游离职表,离职判定不再依赖中文关键词 - 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者 素材归属 - 归属变更纳入正式转移,记录最初上传者、转移流水与理由 - 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名 - 编辑素材的权限判定统一到 canManageMaterial - 修复任务-详情上传把邮箱误存为作者名的问题 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
/**
|
||
* 人员架构地基改造 · 第 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()
|
||
})
|