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