gongxiangp2/scripts/sync-organization.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

128 lines
5.1 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.

/**
* 组织架构与在职状态同步
*
* 从上游 HR 系统镜像部门树,并依据离职表校正本地档案的在职状态。
*
* 默认预演,不写任何数据。加 --apply 才实际执行。
*
* 用法:
* npx tsx --env-file=.env scripts/sync-organization.ts # 预演
* npx tsx --env-file=.env scripts/sync-organization.ts --apply # 实际执行
* npx tsx --env-file=.env scripts/sync-organization.ts --org # 只同步组织架构
* npx tsx --env-file=.env scripts/sync-organization.ts --employment # 只同步在职状态
*/
import { PrismaClient } from '@prisma/client'
import {
isReportDbConfigured,
syncOrganizationUnits,
syncEmploymentStates,
applyResignationEffects,
} from '../lib/organization-sync'
const prisma = new PrismaClient()
async function main() {
const args = process.argv.slice(2)
const apply = args.includes('--apply')
const onlyOrg = args.includes('--org')
const onlyEmployment = args.includes('--employment')
const runOrg = !onlyEmployment
const runEmployment = !onlyOrg
if (!isReportDbConfigured()) {
console.error('未配置上游报表库(REPORT_DB_SERVER / DATABASE / USER / PASSWORD)')
process.exitCode = 1
return
}
console.log('\n组织架构与在职状态同步')
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
if (runOrg) {
console.log('\n===== 一、组织架构 =====')
const r = await syncOrganizationUnits({ dryRun: !apply })
console.log(' 上游部门总数 ' + r.fetched)
console.log(' 根节点 ' + (r.rootName ?? '未识别'))
console.log(' 最大层级 ' + r.maxDepth)
if (apply) {
console.log(' 新建 ' + r.created)
console.log(' 更新 ' + r.updated)
console.log(' 下线(上游已删)' + r.deactivated)
}
}
if (runEmployment) {
console.log('\n===== 二、在职状态 =====')
const r = await syncEmploymentStates({ dryRun: !apply })
console.log(' 上游离职表人数 ' + r.upstreamResigned)
console.log(' 上游在册人数 ' + r.upstreamRegistered)
console.log(' 本地档案总数 ' + r.localProfiles)
console.log(' ├ 判定为在职 ' + r.markedActive)
console.log(' ├ 判定为离职 ' + r.markedResigned)
console.log(' └ 状态未知 ' + r.markedUnknown)
console.log('\n===== 三、离职联动 =====')
// 只处理离职已满 30 天的人:避开交接期、返聘与上游数据延迟;
// 离职日期缺失者一并暂缓,交人工核实
const MIN_DAYS = Number(process.env.RESIGNATION_MIN_DAYS || 30)
const e = await applyResignationEffects({ dryRun: !apply, minDaysSinceResignation: MIN_DAYS })
console.log(' 人已离职、账号仍活跃 ' + e.candidates + ' 人')
console.log(' ├ 本次纳入处理 ' + e.pending + ' 人(离职已满 ' + MIN_DAYS + ' 天)')
console.log(' └ 暂缓 ' + e.deferred + ' 人')
if (apply) {
console.log('\n 已归档账号 ' + e.archivedAccounts)
console.log(' 已托管素材 ' + e.custodiedMaterials + ' 条')
}
if (e.details.length > 0) {
console.log('\n 纳入处理的(按素材数排序,前 20):')
e.details
.sort((a, b) => b.materials - a.materials)
.slice(0, 20)
.forEach((d) =>
console.log(
' ' + d.displayName.padEnd(12) + (d.department ?? '无部门').padEnd(14) +
String(d.materials).padStart(5) + ' 条素材 ' +
(d.resignedAt ? '最后工作日 ' + d.resignedAt.toLocaleDateString('zh-CN') : '离职日期未知'),
),
)
if (e.details.length > 20) console.log(' ...另有 ' + (e.details.length - 20) + ' 人')
}
if (e.deferredDetails.length > 0) {
console.log('\n 暂缓的(前 10):')
e.deferredDetails.slice(0, 10).forEach((d) =>
console.log(' ' + d.displayName.padEnd(12) + d.reason),
)
if (e.deferredDetails.length > 10) {
console.log(' ...另有 ' + (e.deferredDetails.length - 10) + ' 人')
}
}
if (e.failures.length > 0) {
console.log('\n 需人工处理:')
e.failures.forEach((f) => console.log(' ' + f.username.padEnd(14) + f.reason))
}
}
if (apply && runOrg) {
const total = await prisma.organizationUnit.count()
const active = await prisma.organizationUnit.count({ where: { isActive: true } })
const roots = await prisma.organizationUnit.count({ where: { parentId: null, isActive: true } })
console.log('\n===== 落库校验 =====')
console.log(' 组织节点总数 ' + total)
console.log(' 其中有效 ' + active)
console.log(' 根节点数量 ' + roots + (roots === 1 ? ' ✅ 单根树' : ' ⚠️ 预期为 1'))
}
if (!apply) {
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
}
await prisma.$disconnect()
}
main().catch(async (e) => {
console.error('\n同步失败:', e instanceof Error ? e.message : e)
await prisma.$disconnect()
process.exitCode = 1
})