feat: 人员架构统一到钉钉唯一值,接入上游组织架构动态同步
素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时 逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随 上游 HR 系统自动更新。 人员身份 - 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一 - users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号 - 移除钉钉登录的同名自动合并,同名冲突一律转人工核对 - 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权 组织架构 - 新增 OrganizationUnit,直接镜像上游 HR 部门树 - 不再从员工部门路径倒推,同名部门不会再被合并 - 上游已删除的部门下线而非物理删除 人员状态 - 新增规范化的 employmentState,取代对混杂原始值做关键词匹配 - 接入上游离职表,离职判定不再依赖中文关键词 - 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者 素材归属 - 归属变更纳入正式转移,记录最初上传者、转移流水与理由 - 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名 - 编辑素材的权限判定统一到 canManageMaterial - 修复任务-详情上传把邮箱误存为作者名的问题 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
33f4d0b9b8
commit
7b0ee5efb4
@ -86,6 +86,21 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 作者名取数据库中的用户名,与其它上传入口保持一致。
|
||||
// 此前直接读 session.user.username / realName,但登录态从未写入这两个字段,
|
||||
// 实际一路兜底存成了邮箱,导致任务-详情素材的作者显示与其它类型不一致。
|
||||
const uploader = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
if (!uploader) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '当前登录账号不存在' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskDetailSourceType) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' },
|
||||
@ -126,7 +141,7 @@ export async function POST(request: NextRequest) {
|
||||
materialType: '任务-详情',
|
||||
// taskStatus: 不写入,上传完成后再写入
|
||||
previewUrl: '', // 临时占位,上传完成后更新
|
||||
authorName: (session.user as any).username || (session.user as any).realName || session.user.email,
|
||||
authorName: uploader.username,
|
||||
userId: (session.user as any).id,
|
||||
renderer: '', // 任务-详情不需要渲染器
|
||||
isFullSet: false,
|
||||
|
||||
@ -258,7 +258,9 @@ describe('performance survey position admin', () => {
|
||||
})
|
||||
|
||||
const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
|
||||
expect(JSON.stringify(query.where)).toContain('employmentStatus')
|
||||
// 用在职状态过滤,而不是按"有没有素材库账号"过滤。
|
||||
// 判定字段已从 employmentStatus(上游原样值,口径混杂)改为规范化的 employmentState。
|
||||
expect(JSON.stringify(query.where)).toContain('employmentState')
|
||||
expect(JSON.stringify(query.where)).not.toContain('materialUserId')
|
||||
expect(JSON.stringify(query.where)).not.toContain('materialUserEmail')
|
||||
})
|
||||
|
||||
@ -459,10 +459,12 @@ describe('performance survey service policies', () => {
|
||||
expect(result.permissions.canDeletePublicDashboardAssessment).toBe(false)
|
||||
})
|
||||
|
||||
test('只有无邪账号获得公共看板单项调研删除权限', async () => {
|
||||
test('公共看板调研删除权限按钉钉唯一值判定,同名账号不得越权', async () => {
|
||||
// 钉钉唯一值命中本人:有权限
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
...baseUser,
|
||||
username: 'wuxie',
|
||||
username: '无邪',
|
||||
dingtalkUserId: '09061817188066',
|
||||
})
|
||||
|
||||
const result = await getPerformanceSurveyDashboard(session, new URLSearchParams({
|
||||
@ -472,16 +474,33 @@ describe('performance survey service policies', () => {
|
||||
|
||||
expect(result.permissions.canDeletePublicDashboardAssessment).toBe(true)
|
||||
|
||||
// 用户名同样读作「无邪」但钉钉唯一值不是本人:不得获得权限。
|
||||
// 生产库里确实同时存在简体「无邪」与繁体「無邪」两个账号,
|
||||
// 此前按用户名字符串比对,会把权限判给其中一个——而那未必是本人。
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
...baseUser,
|
||||
username: '无邪',
|
||||
username: '無邪',
|
||||
dingtalkUserId: 'ding-someone-else',
|
||||
})
|
||||
|
||||
const productionAccountResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
|
||||
const homoglyphResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
|
||||
view: 'public',
|
||||
month: '2026-07',
|
||||
}))
|
||||
expect(productionAccountResult.permissions.canDeletePublicDashboardAssessment).toBe(true)
|
||||
expect(homoglyphResult.permissions.canDeletePublicDashboardAssessment).toBe(false)
|
||||
|
||||
// 未绑定钉钉的账号:无法验证身份,一律无权限
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
...baseUser,
|
||||
username: '无邪',
|
||||
dingtalkUserId: null,
|
||||
})
|
||||
|
||||
const unboundResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
|
||||
view: 'public',
|
||||
month: '2026-07',
|
||||
}))
|
||||
expect(unboundResult.permissions.canDeletePublicDashboardAssessment).toBe(false)
|
||||
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
...baseUser,
|
||||
@ -506,6 +525,7 @@ describe('performance survey service policies', () => {
|
||||
...baseUser,
|
||||
username: '无邪',
|
||||
realName: '无邪',
|
||||
dingtalkUserId: '09061817188066',
|
||||
})
|
||||
transactionClient.performanceSurveyTarget.findMany.mockResolvedValue([{
|
||||
id: 'target-public',
|
||||
|
||||
@ -22,6 +22,7 @@ jest.mock('@/lib/prisma', () => ({
|
||||
},
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@ -36,10 +37,11 @@ describe('product-package init route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
// 贴近真实登录态:lib/auth.ts 的 session 回调只写入 id/email/role 等字段,
|
||||
// 从不写入 username 或 realName。此前实现误读这两个字段,一路兜底把邮箱
|
||||
// 存成了作者名,而旧测试恰好给了一个含 username 的理想化 session,把问题掩盖了。
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
realName: '测试用户',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
})
|
||||
@ -51,6 +53,9 @@ describe('product-package init route', () => {
|
||||
username: 'operator',
|
||||
realName: '运营甲',
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
username: 'tester',
|
||||
})
|
||||
})
|
||||
|
||||
test('未填写出图方式时应拒绝初始化任务详情上传', async () => {
|
||||
@ -124,8 +129,15 @@ describe('product-package init route', () => {
|
||||
taskDetailSourceType: '渲染',
|
||||
taskDetailRequesterUserId: 'requester-1',
|
||||
taskDetailRequesterName: '运营甲',
|
||||
// 作者名必须取自数据库中的用户名,而不是登录态里并不存在的字段,
|
||||
// 更不能兜底成邮箱
|
||||
authorName: 'tester',
|
||||
}),
|
||||
})
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
select: { username: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('未选择需求运营人员时应返回 400', async () => {
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
getDingtalkWorkNoticeAppAccessToken,
|
||||
} from '@/lib/dingtalk-client'
|
||||
import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync'
|
||||
import { ensureBindingByDingtalkUserId } from '@/lib/employee-directory'
|
||||
import {
|
||||
getAuthSessionCookieCandidates,
|
||||
getAuthSessionCookieName,
|
||||
@ -247,9 +248,15 @@ function warmPerformanceSurveyProfile(user: {
|
||||
username: user.username,
|
||||
realName: user.realName,
|
||||
displayName: user.realName || user.username,
|
||||
}).catch((error) => {
|
||||
console.warn('[performance-survey] 钉钉登录后预热人员档案失败:', error)
|
||||
})
|
||||
.then(() =>
|
||||
// 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定,
|
||||
// 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。
|
||||
ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('[performance-survey] 钉钉登录后预热人员档案或建立绑定失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function setDingtalkAuthenticatedSessionCookies(
|
||||
@ -358,52 +365,52 @@ async function createOrUpdateUserSession(
|
||||
|
||||
let needCompleteProfile = false
|
||||
if (!user) {
|
||||
// 同名不再自动合并身份。钉钉唯一值是全项目唯一的人员判定依据,
|
||||
// 仅凭姓名相同就把一个新的钉钉身份挂到已有账号上,等于让重名直接变成越权。
|
||||
// 另一方面,若同名账号已绑定其他钉钉身份,用户名唯一约束也不允许再建同名账号。
|
||||
// 两种情况都转人工核对,不在登录链路里自作主张。
|
||||
const existingUserByName = await prisma.user.findUnique({
|
||||
where: { username: displayName },
|
||||
})
|
||||
|
||||
if (existingUserByName && !existingUserByName.dingtalkUserId) {
|
||||
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
|
||||
user = await prisma.user.update({
|
||||
where: { id: existingUserByName.id },
|
||||
data: {
|
||||
dingtalkUserId: userId,
|
||||
loginType: 'dingtalk',
|
||||
realName: displayName,
|
||||
avatar: userInfo.avatar || existingUserByName.avatar,
|
||||
phone: userInfo.mobile || existingUserByName.phone,
|
||||
},
|
||||
})
|
||||
console.log('账号关联成功:', user.username)
|
||||
} else {
|
||||
console.log('创建新用户(使用钉钉姓名):', displayName)
|
||||
|
||||
let emailToUse = userInfo.email || generateRandomEmail()
|
||||
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
|
||||
if (emailExists) {
|
||||
emailToUse = generateRandomEmail()
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username: displayName,
|
||||
email: emailToUse,
|
||||
password: await createDingtalkOnlyPasswordHash(),
|
||||
realName: displayName,
|
||||
avatar: userInfo.avatar || null,
|
||||
phone: userInfo.mobile || null,
|
||||
department: '',
|
||||
role: 'USER',
|
||||
isActive: false,
|
||||
status: 'pending',
|
||||
loginType: 'dingtalk',
|
||||
dingtalkUserId: userId,
|
||||
},
|
||||
})
|
||||
|
||||
needCompleteProfile = true
|
||||
console.log('用户创建成功:', user.id)
|
||||
if (existingUserByName) {
|
||||
console.warn(
|
||||
`[dingtalk-login] 拒绝按姓名自动合并身份:username=${displayName},` +
|
||||
`既有账号=${existingUserByName.id},` +
|
||||
`既有钉钉ID=${existingUserByName.dingtalkUserId ?? '未绑定'}`,
|
||||
)
|
||||
throw new Error(
|
||||
'系统中已存在同名账号,为避免身份混淆,请联系管理员确认并完成账号绑定后再登录。',
|
||||
)
|
||||
}
|
||||
|
||||
console.log('创建新用户(使用钉钉姓名):', displayName)
|
||||
|
||||
let emailToUse = userInfo.email || generateRandomEmail()
|
||||
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
|
||||
if (emailExists) {
|
||||
emailToUse = generateRandomEmail()
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username: displayName,
|
||||
email: emailToUse,
|
||||
password: await createDingtalkOnlyPasswordHash(),
|
||||
realName: displayName,
|
||||
avatar: userInfo.avatar || null,
|
||||
phone: userInfo.mobile || null,
|
||||
department: '',
|
||||
role: 'USER',
|
||||
isActive: false,
|
||||
status: 'pending',
|
||||
loginType: 'dingtalk',
|
||||
dingtalkUserId: userId,
|
||||
},
|
||||
})
|
||||
|
||||
needCompleteProfile = true
|
||||
console.log('用户创建成功:', user.id)
|
||||
} else {
|
||||
console.log('更新现有用户:', user.username)
|
||||
user = await prisma.user.update({
|
||||
@ -526,52 +533,49 @@ async function resolveDingtalkUserForSession(
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// 与 OAuth 登录路径保持同一口径:不按姓名自动合并身份,同名一律转人工核对。
|
||||
const existingUserByName = await prisma.user.findUnique({
|
||||
where: { username: displayName },
|
||||
})
|
||||
|
||||
if (existingUserByName && !existingUserByName.dingtalkUserId) {
|
||||
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
|
||||
user = await prisma.user.update({
|
||||
where: { id: existingUserByName.id },
|
||||
data: {
|
||||
dingtalkUserId: userId,
|
||||
loginType: 'dingtalk',
|
||||
realName: displayName,
|
||||
avatar: userInfo.avatar || existingUserByName.avatar,
|
||||
phone: userInfo.mobile || existingUserByName.phone,
|
||||
},
|
||||
})
|
||||
console.log('账号关联成功:', user.username)
|
||||
} else {
|
||||
console.log('创建新用户(使用钉钉姓名):', displayName)
|
||||
|
||||
let emailToUse = userInfo.email || generateRandomEmail()
|
||||
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
|
||||
if (emailExists) {
|
||||
emailToUse = generateRandomEmail()
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username: displayName,
|
||||
email: emailToUse,
|
||||
password: await createDingtalkOnlyPasswordHash(),
|
||||
realName: displayName,
|
||||
avatar: userInfo.avatar || null,
|
||||
phone: userInfo.mobile || null,
|
||||
department: '',
|
||||
role: 'USER',
|
||||
isActive: false,
|
||||
status: 'pending',
|
||||
loginType: 'dingtalk',
|
||||
dingtalkUserId: userId,
|
||||
},
|
||||
})
|
||||
|
||||
warmPerformanceSurveyProfile(user)
|
||||
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
|
||||
if (existingUserByName) {
|
||||
console.warn(
|
||||
`[dingtalk-session] 拒绝按姓名自动合并身份:username=${displayName},` +
|
||||
`既有账号=${existingUserByName.id},` +
|
||||
`既有钉钉ID=${existingUserByName.dingtalkUserId ?? '未绑定'}`,
|
||||
)
|
||||
throw new Error(
|
||||
'系统中已存在同名账号,为避免身份混淆,请联系管理员确认并完成账号绑定后再登录。',
|
||||
)
|
||||
}
|
||||
|
||||
console.log('创建新用户(使用钉钉姓名):', displayName)
|
||||
|
||||
let emailToUse = userInfo.email || generateRandomEmail()
|
||||
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
|
||||
if (emailExists) {
|
||||
emailToUse = generateRandomEmail()
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username: displayName,
|
||||
email: emailToUse,
|
||||
password: await createDingtalkOnlyPasswordHash(),
|
||||
realName: displayName,
|
||||
avatar: userInfo.avatar || null,
|
||||
phone: userInfo.mobile || null,
|
||||
department: '',
|
||||
role: 'USER',
|
||||
isActive: false,
|
||||
status: 'pending',
|
||||
loginType: 'dingtalk',
|
||||
dingtalkUserId: userId,
|
||||
},
|
||||
})
|
||||
|
||||
warmPerformanceSurveyProfile(user)
|
||||
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
|
||||
} else {
|
||||
console.log('更新现有用户:', user.username)
|
||||
user = await prisma.user.update({
|
||||
|
||||
283
lib/employee-directory.ts
Normal file
283
lib/employee-directory.ts
Normal file
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 通用人员目录服务
|
||||
*
|
||||
* 平台级的人员主数据入口。素材库、部门看板等功能统一通过本模块访问人员信息,
|
||||
* 不再直接读取带 `PerformanceSurvey` 前缀的模型——那套表虽然是当前的物理载体,
|
||||
* 但语义上已经是全平台共享的权威人员档案(数据源为 HR 报表库,非钉钉通讯录)。
|
||||
*
|
||||
* 身份关联原则(与《素材库店铺与多层权限改造实施计划》3.1 节一致):
|
||||
* - 钉钉唯一值是唯一的自动匹配依据
|
||||
* - 姓名、花名、邮箱只用于人工排错,不能自动建立权威绑定
|
||||
* - 上游原始 JSON 只作审计快照,不参与运行时身份判断
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/** 绑定来源 */
|
||||
export const BINDING_SOURCE = {
|
||||
/** 钉钉登录时自动建立 */
|
||||
DINGTALK_LOGIN: 'DINGTALK_LOGIN',
|
||||
/** 管理员人工核验后建立 */
|
||||
MANUAL_VERIFIED: 'MANUAL_VERIFIED',
|
||||
/** 存量数据迁移建立 */
|
||||
MIGRATION: 'MIGRATION',
|
||||
} as const
|
||||
|
||||
export const BINDING_STATUS = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
/** 历史绑定,保留可追溯性 */
|
||||
RETIRED: 'RETIRED',
|
||||
} as const
|
||||
|
||||
/** 统一的人员视图,屏蔽底层表结构 */
|
||||
export interface EmployeeDirectoryEntry {
|
||||
employeeProfileId: string
|
||||
/** 关联的素材库账号;未开通账号的人员为 null */
|
||||
userId: string | null
|
||||
displayName: string
|
||||
realName: string | null
|
||||
flowerName: string | null
|
||||
jobNumber: string | null
|
||||
dingtalkUserId: string | null
|
||||
departmentName: string | null
|
||||
/** 部门全路径,如「品牌中心/品牌创意A部」 */
|
||||
departmentPath: string | null
|
||||
positionTitle: string | null
|
||||
positionLevel: string | null
|
||||
/** 职级类型,P(专业)/ M(管理) */
|
||||
levelType: string | null
|
||||
isLeadership: boolean
|
||||
/** 直属上级 */
|
||||
managerName: string | null
|
||||
managerDingtalkUserId: string | null
|
||||
employmentStatus: string | null
|
||||
}
|
||||
|
||||
const PROFILE_SELECT = {
|
||||
id: true,
|
||||
displayName: true,
|
||||
realName: true,
|
||||
flowerName: true,
|
||||
jobNumber: true,
|
||||
dingtalkUserId: true,
|
||||
departmentName: true,
|
||||
departmentPath: true,
|
||||
positionTitle: true,
|
||||
positionLevel: true,
|
||||
levelType: true,
|
||||
isLeadership: true,
|
||||
managerName: true,
|
||||
managerDingtalkUserId: true,
|
||||
employmentStatus: true,
|
||||
} as const
|
||||
|
||||
type RawProfile = {
|
||||
[K in keyof typeof PROFILE_SELECT]: any
|
||||
}
|
||||
|
||||
function toEntry(profile: RawProfile, userId: string | null): EmployeeDirectoryEntry {
|
||||
return {
|
||||
employeeProfileId: profile.id,
|
||||
userId,
|
||||
displayName: profile.displayName,
|
||||
realName: profile.realName,
|
||||
flowerName: profile.flowerName,
|
||||
jobNumber: profile.jobNumber,
|
||||
dingtalkUserId: profile.dingtalkUserId,
|
||||
departmentName: profile.departmentName,
|
||||
departmentPath: profile.departmentPath,
|
||||
positionTitle: profile.positionTitle,
|
||||
positionLevel: profile.positionLevel,
|
||||
levelType: profile.levelType,
|
||||
isLeadership: Boolean(profile.isLeadership),
|
||||
managerName: profile.managerName,
|
||||
managerDingtalkUserId: profile.managerDingtalkUserId,
|
||||
employmentStatus: profile.employmentStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按素材库账号查询其权威人员档案。
|
||||
*
|
||||
* 优先读取正式绑定关系;迁移期内若尚无绑定记录,回退到按钉钉唯一值查询
|
||||
* (仅钉钉值,不使用姓名或邮箱)。绑定回填完成后可关闭回退。
|
||||
*/
|
||||
export async function getEmployeeByUserId(
|
||||
userId: string,
|
||||
options: { allowDingtalkFallback?: boolean } = {}
|
||||
): Promise<EmployeeDirectoryEntry | null> {
|
||||
const { allowDingtalkFallback = true } = options
|
||||
|
||||
const binding = await prisma.userEmployeeBinding.findFirst({
|
||||
where: { userId, status: BINDING_STATUS.ACTIVE },
|
||||
select: { employeeProfile: { select: PROFILE_SELECT } },
|
||||
})
|
||||
|
||||
if (binding?.employeeProfile) {
|
||||
return toEntry(binding.employeeProfile, userId)
|
||||
}
|
||||
|
||||
if (!allowDingtalkFallback) return null
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dingtalkUserId: true },
|
||||
})
|
||||
if (!user?.dingtalkUserId) return null
|
||||
|
||||
const profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
|
||||
where: { dingtalkUserId: user.dingtalkUserId },
|
||||
select: PROFILE_SELECT,
|
||||
})
|
||||
|
||||
return profile ? toEntry(profile, userId) : null
|
||||
}
|
||||
|
||||
/** 按人员档案反查其素材库账号 ID,无账号返回 null */
|
||||
export async function getUserIdByEmployeeId(employeeProfileId: string): Promise<string | null> {
|
||||
const binding = await prisma.userEmployeeBinding.findFirst({
|
||||
where: { employeeProfileId, status: BINDING_STATUS.ACTIVE },
|
||||
select: { userId: true },
|
||||
})
|
||||
return binding?.userId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立账号与人员的绑定。
|
||||
*
|
||||
* 同一账号或同一人员已存在活跃绑定时,旧绑定会被置为历史状态而非删除,
|
||||
* 保证可追溯。数据库层面的部分唯一索引是最终防线。
|
||||
*/
|
||||
export async function bindUserToEmployee(params: {
|
||||
userId: string
|
||||
employeeProfileId: string
|
||||
source: (typeof BINDING_SOURCE)[keyof typeof BINDING_SOURCE]
|
||||
note?: string
|
||||
}): Promise<void> {
|
||||
const { userId, employeeProfileId, source, note } = params
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.userEmployeeBinding.findFirst({
|
||||
where: { userId, employeeProfileId, status: BINDING_STATUS.ACTIVE },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) return
|
||||
|
||||
// 退休掉该账号或该人员上的其它活跃绑定
|
||||
await tx.userEmployeeBinding.updateMany({
|
||||
where: {
|
||||
status: BINDING_STATUS.ACTIVE,
|
||||
OR: [{ userId }, { employeeProfileId }],
|
||||
},
|
||||
data: { status: BINDING_STATUS.RETIRED },
|
||||
})
|
||||
|
||||
await tx.userEmployeeBinding.create({
|
||||
data: {
|
||||
userId,
|
||||
employeeProfileId,
|
||||
bindingSource: source,
|
||||
status: BINDING_STATUS.ACTIVE,
|
||||
verifiedAt: new Date(),
|
||||
note: note ?? null,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出已开通素材库账号的人员。
|
||||
*
|
||||
* 这是平台的收窄口径(决策 D-A):只有具备素材库账号的人员参与权限与展示,
|
||||
* 未开通账号的花名册人员不纳入。
|
||||
*/
|
||||
export async function listEmployeesWithAccount(options: {
|
||||
departmentPathPrefix?: string
|
||||
keyword?: string
|
||||
take?: number
|
||||
} = {}): Promise<EmployeeDirectoryEntry[]> {
|
||||
const { departmentPathPrefix, keyword, take = 500 } = options
|
||||
|
||||
const bindings = await prisma.userEmployeeBinding.findMany({
|
||||
where: {
|
||||
status: BINDING_STATUS.ACTIVE,
|
||||
employeeProfile: {
|
||||
...(departmentPathPrefix
|
||||
? { departmentPath: { startsWith: departmentPathPrefix } }
|
||||
: {}),
|
||||
...(keyword
|
||||
? {
|
||||
OR: [
|
||||
{ displayName: { contains: keyword } },
|
||||
{ realName: { contains: keyword } },
|
||||
{ flowerName: { contains: keyword } },
|
||||
{ jobNumber: { contains: keyword } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
select: { userId: true, employeeProfile: { select: PROFILE_SELECT } },
|
||||
take,
|
||||
})
|
||||
|
||||
return bindings
|
||||
.filter((b) => b.employeeProfile)
|
||||
.map((b) => toEntry(b.employeeProfile, b.userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉钉登录后确保账号与权威人员之间存在绑定。
|
||||
*
|
||||
* 严格以钉钉唯一值匹配人员档案;档案尚未同步或绑定已存在时静默跳过。
|
||||
* 供登录链路以 fire-and-forget 方式调用,任何情况下都不应阻塞登录本身。
|
||||
*/
|
||||
export async function ensureBindingByDingtalkUserId(
|
||||
userId: string,
|
||||
dingtalkUserId: string | null | undefined
|
||||
): Promise<void> {
|
||||
if (!userId || !dingtalkUserId) return
|
||||
|
||||
const existing = await prisma.userEmployeeBinding.findFirst({
|
||||
where: { userId, status: BINDING_STATUS.ACTIVE },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) return
|
||||
|
||||
const profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
|
||||
where: { dingtalkUserId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!profile) return
|
||||
|
||||
await bindUserToEmployee({
|
||||
userId,
|
||||
employeeProfileId: profile.id,
|
||||
source: BINDING_SOURCE.DINGTALK_LOGIN,
|
||||
})
|
||||
}
|
||||
|
||||
/** 绑定覆盖情况统计,用于迁移对账与健康检查 */
|
||||
export async function getBindingCoverage(): Promise<{
|
||||
activeUsers: number
|
||||
boundUsers: number
|
||||
unboundUsers: number
|
||||
coverageRate: number
|
||||
}> {
|
||||
const activeUsers = await prisma.user.count({
|
||||
where: { isActive: true, archivedAt: null },
|
||||
})
|
||||
const boundUsers = await prisma.userEmployeeBinding.count({
|
||||
where: {
|
||||
status: BINDING_STATUS.ACTIVE,
|
||||
user: { isActive: true, archivedAt: null },
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
activeUsers,
|
||||
boundUsers,
|
||||
unboundUsers: activeUsers - boundUsers,
|
||||
coverageRate: activeUsers > 0 ? Number(((boundUsers / activeUsers) * 100).toFixed(1)) : 0,
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
isSuperAdmin,
|
||||
isSystemAdmin,
|
||||
@ -7,15 +8,49 @@ export interface MaterialAuthorizationUser {
|
||||
id: string
|
||||
role?: string | null
|
||||
isSuperAdmin?: boolean | null
|
||||
/** 所属部门 ID。未提供时会在需要判定托管权限的场景下按需查库 */
|
||||
departmentId?: string | null
|
||||
}
|
||||
|
||||
export interface MaterialAuthorizationTarget {
|
||||
/** 素材 ID。当调用方未查询 custodyDepartmentId 时,用它补查 */
|
||||
id?: string
|
||||
userId: string
|
||||
/**
|
||||
* 素材托管部门。
|
||||
* `undefined` 表示调用方没有查这个字段,`null` 表示确认未处于托管状态。
|
||||
*/
|
||||
custodyDepartmentId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析用户所属部门 ID。
|
||||
*
|
||||
* 优先取 departmentId 外键;部分历史账号只填了部门名称字符串而没有回填
|
||||
* departmentId,这里按名称兜底反查,避免这些人无法接手本部门托管的素材。
|
||||
*/
|
||||
async function resolveUserDepartmentId(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { departmentId: true, department: true },
|
||||
})
|
||||
if (!user) return null
|
||||
if (user.departmentId) return user.departmentId
|
||||
|
||||
const name = user.department?.trim()
|
||||
if (!name) return null
|
||||
|
||||
const department = await prisma.department.findUnique({
|
||||
where: { name },
|
||||
select: { id: true },
|
||||
})
|
||||
return department?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材写操作的最小统一授权:
|
||||
* - 素材所有者
|
||||
* - 素材托管部门的成员(原归属人离职后由该部门接手维护)
|
||||
* - 系统管理员(兼容 ADMIN / SYSTEM_ADMIN)
|
||||
* - 超级管理员
|
||||
*
|
||||
@ -33,5 +68,25 @@ export async function canManageMaterial(
|
||||
return true
|
||||
}
|
||||
|
||||
// 多数调用方只 select 了 id / userId,没有查托管字段。此处按需补查,
|
||||
// 避免因为某个调用点漏 select 就让托管权限静默失效——调用方若已带上
|
||||
// custodyDepartmentId(包括显式的 null),这里不会产生任何额外查询。
|
||||
let custodyDepartmentId = material.custodyDepartmentId
|
||||
if (custodyDepartmentId === undefined && material.id) {
|
||||
const row = await prisma.material.findUnique({
|
||||
where: { id: material.id },
|
||||
select: { custodyDepartmentId: true },
|
||||
})
|
||||
custodyDepartmentId = row?.custodyDepartmentId ?? null
|
||||
}
|
||||
|
||||
if (custodyDepartmentId) {
|
||||
const userDepartmentId =
|
||||
user.departmentId ?? (await resolveUserDepartmentId(user.id))
|
||||
if (userDepartmentId && userDepartmentId === custodyDepartmentId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return isSuperAdmin(user.id)
|
||||
}
|
||||
|
||||
348
lib/material-transfer.ts
Normal file
348
lib/material-transfer.ts
Normal file
@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 素材归属转移
|
||||
*
|
||||
* 此前系统里的"转移"是隐式发生的:在编辑素材时改掉作者,`Material.userId`
|
||||
* 被直接覆盖,既不留转移流水,也查不到最初的上传者是谁。而 `MaterialTransfer`
|
||||
* 表和 `Material` 上的 originalOwnerId / transferredAt / transferredBy / transferNote
|
||||
* 四个字段虽然早已建好,却从未被写入过。
|
||||
*
|
||||
* 本模块把归属变更收敛成一件有据可查的事:
|
||||
* - `MaterialTransfer` 记录每一次转移的流水(谁转给谁、谁操作的、为什么)
|
||||
* - `Material` 上的四个字段保存当前状态(最初属于谁、最近一次何时被谁转移)
|
||||
*
|
||||
* originalOwnerId 只在首次转移时落定,之后不再变动,用于永久保留"最初是谁上传的"。
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/** 转移来源,便于区分是人工单条操作还是离职批量交接 */
|
||||
export const TRANSFER_REASON = {
|
||||
/** 编辑素材时更换作者 */
|
||||
EDIT: '编辑素材时更换归属人',
|
||||
/** 账号离职/归档后的批量交接 */
|
||||
HANDOVER: '账号离职交接',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 计算随归属变更一并写入素材的转移字段。
|
||||
*
|
||||
* @param currentOwnerId 变更前的归属人
|
||||
* @param existingOriginalOwnerId 素材上已有的最初所有者,为空表示这是第一次转移
|
||||
*/
|
||||
export function buildMaterialTransferFields(params: {
|
||||
currentOwnerId: string
|
||||
existingOriginalOwnerId?: string | null
|
||||
operatorUserId: string
|
||||
note?: string | null
|
||||
}) {
|
||||
const { currentOwnerId, existingOriginalOwnerId, operatorUserId, note } = params
|
||||
return {
|
||||
// 首次转移时记下最初的上传者,后续转移不再覆盖
|
||||
originalOwnerId: existingOriginalOwnerId || currentOwnerId,
|
||||
transferredAt: new Date(),
|
||||
transferredBy: operatorUserId,
|
||||
transferNote: note ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入一条转移流水。可传入事务客户端以保证与素材更新的原子性。 */
|
||||
export async function recordMaterialTransfer(
|
||||
client: { materialTransfer: { create: (args: any) => Promise<any> } },
|
||||
params: {
|
||||
materialId: string
|
||||
fromUserId: string
|
||||
toUserId: string
|
||||
operatorUserId: string
|
||||
note?: string | null
|
||||
}
|
||||
): Promise<void> {
|
||||
await client.materialTransfer.create({
|
||||
data: {
|
||||
materialId: params.materialId,
|
||||
fromUserId: params.fromUserId,
|
||||
toUserId: params.toUserId,
|
||||
transferredBy: params.operatorUserId,
|
||||
transferNote: params.note ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface BulkTransferResult {
|
||||
/** 转出账号名下的素材总数 */
|
||||
total: number
|
||||
/** 实际完成转移的条数 */
|
||||
transferred: number
|
||||
/** 失败条数 */
|
||||
failed: number
|
||||
/** 是否为预演,预演不写入任何数据 */
|
||||
dryRun: boolean
|
||||
failures: Array<{ materialId: string; reason: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某个账号名下的素材整体转移给另一个账号。
|
||||
*
|
||||
* 主要面向离职交接:账号归档后无法登录,其名下素材无人可维护,而系统此前
|
||||
* 只能在编辑页里一条一条改作者。
|
||||
*
|
||||
* 逐条使用事务,保证「素材归属 + 署名 + 转移流水」三者一致;单条失败不影响
|
||||
* 其余素材,失败明细一并返回,便于重试与核对。
|
||||
*/
|
||||
export async function transferMaterialsBetweenUsers(params: {
|
||||
fromUserId: string
|
||||
toUserId: string
|
||||
operatorUserId: string
|
||||
note?: string | null
|
||||
/** 仅转移指定素材类型,不传则转移全部 */
|
||||
materialTypes?: string[]
|
||||
/** 预演模式:只统计不写入 */
|
||||
dryRun?: boolean
|
||||
}): Promise<BulkTransferResult> {
|
||||
const {
|
||||
fromUserId,
|
||||
toUserId,
|
||||
operatorUserId,
|
||||
note = TRANSFER_REASON.HANDOVER,
|
||||
materialTypes,
|
||||
dryRun = false,
|
||||
} = params
|
||||
|
||||
if (fromUserId === toUserId) {
|
||||
throw new Error('转出与接收账号不能是同一个人')
|
||||
}
|
||||
|
||||
const toUser = await prisma.user.findUnique({
|
||||
where: { id: toUserId },
|
||||
select: { id: true, username: true, isActive: true, archivedAt: true },
|
||||
})
|
||||
if (!toUser) throw new Error('接收账号不存在')
|
||||
if (toUser.archivedAt || !toUser.isActive) {
|
||||
throw new Error('接收账号已归档或已停用,不能作为素材归属人')
|
||||
}
|
||||
|
||||
const materials = await prisma.material.findMany({
|
||||
where: {
|
||||
userId: fromUserId,
|
||||
deletedAt: null,
|
||||
...(materialTypes?.length ? { materialType: { in: materialTypes } } : {}),
|
||||
},
|
||||
select: { id: true, userId: true, originalOwnerId: true },
|
||||
})
|
||||
|
||||
const result: BulkTransferResult = {
|
||||
total: materials.length,
|
||||
transferred: 0,
|
||||
failed: 0,
|
||||
dryRun,
|
||||
failures: [],
|
||||
}
|
||||
|
||||
if (dryRun) return result
|
||||
|
||||
for (const material of materials) {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.material.update({
|
||||
where: { id: material.id },
|
||||
data: {
|
||||
userId: toUserId,
|
||||
// 署名与归属保持一致,避免出现"作者写着甲、归属却是乙"
|
||||
authorName: toUser.username,
|
||||
...buildMaterialTransferFields({
|
||||
currentOwnerId: material.userId,
|
||||
existingOriginalOwnerId: material.originalOwnerId,
|
||||
operatorUserId,
|
||||
note,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await recordMaterialTransfer(tx, {
|
||||
materialId: material.id,
|
||||
fromUserId: material.userId,
|
||||
toUserId,
|
||||
operatorUserId,
|
||||
note,
|
||||
})
|
||||
})
|
||||
result.transferred += 1
|
||||
} catch (error) {
|
||||
result.failed += 1
|
||||
result.failures.push({
|
||||
materialId: material.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 统计某账号名下待交接的素材数量,用于离职处理前的预览 */
|
||||
export async function countMaterialsByOwner(userId: string): Promise<number> {
|
||||
return prisma.material.count({ where: { userId, deletedAt: null } })
|
||||
}
|
||||
|
||||
/** 读取一条素材的完整转移历史,最近一次在前 */
|
||||
export async function listMaterialTransferHistory(materialId: string) {
|
||||
return prisma.materialTransfer.findMany({
|
||||
where: { materialId },
|
||||
orderBy: { transferredAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
transferredAt: true,
|
||||
transferNote: true,
|
||||
fromUser: { select: { id: true, username: true, realName: true } },
|
||||
toUser: { select: { id: true, username: true, realName: true } },
|
||||
operator: { select: { id: true, username: true, realName: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 部门托管
|
||||
// ============================================================
|
||||
|
||||
export const CUSTODY_REASON = {
|
||||
RESIGNATION: '原归属人离职,素材由所在部门接管',
|
||||
} as const
|
||||
|
||||
export interface CustodyResult {
|
||||
/** 待托管素材数 */
|
||||
total: number
|
||||
/** 实际完成托管数 */
|
||||
custodied: number
|
||||
departmentName: string | null
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某个账号名下的素材托管给部门。
|
||||
*
|
||||
* 与 `transferMaterialsBetweenUsers` 的本质区别:**托管不转移归属人,也不改署名**。
|
||||
* `userId` 与 `authorName` 保持原样——素材是谁做的,永远记在谁名下;变的只是
|
||||
* 「现在谁负责维护它」。人离职后,其所在部门的成员即可接手。
|
||||
*
|
||||
* 不指定部门时自动落到该账号所属部门。已处于托管状态的素材会被跳过。
|
||||
*/
|
||||
export async function custodyMaterialsToDepartment(params: {
|
||||
fromUserId: string
|
||||
/** 不指定时自动取该账号所属部门 */
|
||||
departmentId?: string
|
||||
reason?: string
|
||||
dryRun?: boolean
|
||||
}): Promise<CustodyResult> {
|
||||
const { fromUserId, reason = CUSTODY_REASON.RESIGNATION, dryRun = false } = params
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: fromUserId },
|
||||
select: { username: true, departmentId: true, department: true },
|
||||
})
|
||||
if (!user) throw new Error('账号不存在')
|
||||
|
||||
// 优先用显式指定的部门,其次是账号的部门外键,最后按部门名称兜底反查
|
||||
let departmentId = params.departmentId ?? user.departmentId ?? null
|
||||
if (!departmentId && user.department?.trim()) {
|
||||
const matched = await prisma.department.findUnique({
|
||||
where: { name: user.department.trim() },
|
||||
select: { id: true },
|
||||
})
|
||||
departmentId = matched?.id ?? null
|
||||
}
|
||||
if (!departmentId) {
|
||||
throw new Error(
|
||||
`账号「${user.username}」没有可识别的所属部门,无法自动托管,请显式指定部门`,
|
||||
)
|
||||
}
|
||||
|
||||
const department = await prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!department) throw new Error('托管部门不存在')
|
||||
|
||||
const where = {
|
||||
userId: fromUserId,
|
||||
deletedAt: null,
|
||||
custodyDepartmentId: null,
|
||||
}
|
||||
const total = await prisma.material.count({ where })
|
||||
|
||||
if (dryRun) {
|
||||
return { total, custodied: 0, departmentName: department.name, dryRun: true }
|
||||
}
|
||||
|
||||
const updated = await prisma.material.updateMany({
|
||||
where,
|
||||
data: {
|
||||
custodyDepartmentId: department.id,
|
||||
custodyAt: new Date(),
|
||||
custodyReason: reason,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
total,
|
||||
custodied: updated.count,
|
||||
departmentName: department.name,
|
||||
dryRun: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所有已归档(离职)账号名下尚未托管的素材,批量交由各自部门接管。
|
||||
*
|
||||
* 逐个账号处理,某个账号失败(例如识别不出部门)不影响其余账号。
|
||||
*/
|
||||
export async function custodyMaterialsOfArchivedUsers(params: {
|
||||
reason?: string
|
||||
dryRun?: boolean
|
||||
} = {}): Promise<{
|
||||
accounts: number
|
||||
totalMaterials: number
|
||||
custodied: number
|
||||
details: Array<{ username: string; department: string | null; count: number }>
|
||||
skipped: Array<{ username: string; count: number; reason: string }>
|
||||
}> {
|
||||
const { reason = CUSTODY_REASON.RESIGNATION, dryRun = false } = params
|
||||
|
||||
const archived = await prisma.user.findMany({
|
||||
where: { archivedAt: { not: null } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
|
||||
const details: Array<{ username: string; department: string | null; count: number }> = []
|
||||
const skipped: Array<{ username: string; count: number; reason: string }> = []
|
||||
let totalMaterials = 0
|
||||
let custodied = 0
|
||||
|
||||
for (const user of archived) {
|
||||
const pending = await prisma.material.count({
|
||||
where: { userId: user.id, deletedAt: null, custodyDepartmentId: null },
|
||||
})
|
||||
if (pending === 0) continue
|
||||
|
||||
totalMaterials += pending
|
||||
try {
|
||||
const result = await custodyMaterialsToDepartment({
|
||||
fromUserId: user.id,
|
||||
reason,
|
||||
dryRun,
|
||||
})
|
||||
custodied += result.custodied
|
||||
details.push({
|
||||
username: user.username,
|
||||
department: result.departmentName,
|
||||
count: dryRun ? result.total : result.custodied,
|
||||
})
|
||||
} catch (error) {
|
||||
skipped.push({
|
||||
username: user.username,
|
||||
count: pending,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { accounts: details.length, totalMaterials, custodied, details, skipped }
|
||||
}
|
||||
@ -21,7 +21,7 @@ import {
|
||||
canManuallyReplacePreview,
|
||||
supportsIsFullSet,
|
||||
} from '@/lib/material-type-options'
|
||||
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
|
||||
@ -30,6 +30,11 @@ import {
|
||||
getDingtalkApprovalNoError,
|
||||
normalizeDingtalkApprovalNo,
|
||||
} from '@/lib/dingtalk-approval-no'
|
||||
import {
|
||||
TRANSFER_REASON,
|
||||
buildMaterialTransferFields,
|
||||
recordMaterialTransfer,
|
||||
} from '@/lib/material-transfer'
|
||||
|
||||
export interface MaterialUpdateInput {
|
||||
productModel: string
|
||||
@ -42,6 +47,8 @@ export interface MaterialUpdateInput {
|
||||
renderer?: string | null
|
||||
authorName: string
|
||||
userId?: string | null
|
||||
/** 更换归属人时的转移理由,留空则记为默认的编辑转移 */
|
||||
transferNote?: string | null
|
||||
taskNo?: string | null
|
||||
dingtalkApprovalNo?: string | null
|
||||
taskDetailSourceType?: string | null
|
||||
@ -121,10 +128,14 @@ async function ensureEditableMaterial(
|
||||
return { error: '素材不存在或已被删除', status: 404 as const }
|
||||
}
|
||||
|
||||
const hasSuperAdminPermission = await isSuperAdmin(operatorUserId)
|
||||
const hasSystemAdminRole = isSystemAdmin(operatorRole)
|
||||
// 统一走 canManageMaterial,与资产、SKU、白底图等写操作保持同一套判定,
|
||||
// 托管给部门的素材由此自动对该部门成员开放维护权限。
|
||||
const canManage = await canManageMaterial(
|
||||
{ id: operatorUserId, role: operatorRole },
|
||||
material,
|
||||
)
|
||||
|
||||
if (material.userId !== operatorUserId && !hasSystemAdminRole && !hasSuperAdminPermission) {
|
||||
if (!canManage) {
|
||||
return { error: '您没有权限编辑此素材', status: 403 as const }
|
||||
}
|
||||
|
||||
@ -365,8 +376,22 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
|
||||
: false,
|
||||
}
|
||||
|
||||
if (nextUserId && nextUserId !== material.userId) {
|
||||
// 更换归属人按正式转移处理:在素材上落下最初上传者与本次转移信息,
|
||||
// 让"改作者"这个动作不再是一次无据可查的静默覆盖。
|
||||
const isOwnershipTransfer = Boolean(nextUserId && nextUserId !== material.userId)
|
||||
const transferNote = normalizeOptionalString(input.transferNote) || TRANSFER_REASON.EDIT
|
||||
|
||||
if (isOwnershipTransfer && nextUserId) {
|
||||
updateData.userId = nextUserId
|
||||
Object.assign(
|
||||
updateData,
|
||||
buildMaterialTransferFields({
|
||||
currentOwnerId: material.userId,
|
||||
existingOriginalOwnerId: material.originalOwnerId,
|
||||
operatorUserId,
|
||||
note: transferNote,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const uploadedPreviewUrl = previewImage && previewImage.size > 0 && previewUrl !== material.previewUrl
|
||||
@ -411,6 +436,21 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
|
||||
})
|
||||
}
|
||||
|
||||
if (isOwnershipTransfer && nextUserId) {
|
||||
// 归属变更已随素材更新一并落库。流水写入失败不回滚归属本身——素材上的
|
||||
// originalOwnerId / transferredAt / transferredBy / transferNote 已完整记下了
|
||||
// 这次转移的事实,缺的只是流水表里的一行,可事后补录。
|
||||
await recordMaterialTransfer(prisma, {
|
||||
materialId,
|
||||
fromUserId: material.userId,
|
||||
toUserId: nextUserId,
|
||||
operatorUserId,
|
||||
note: transferNote,
|
||||
}).catch((error) => {
|
||||
console.error('[material-update] 素材转移流水写入失败,归属变更已生效:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const changes = buildMaterialChanges(material, updateData)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await createMaterialLog({
|
||||
|
||||
659
lib/organization-sync.ts
Normal file
659
lib/organization-sync.ts
Normal file
@ -0,0 +1,659 @@
|
||||
/**
|
||||
* 组织架构与在职状态同步
|
||||
*
|
||||
* 直接镜像上游 HR 系统(rpt schema),取代此前两处靠猜的做法:
|
||||
*
|
||||
* 1. 组织架构——此前部门树是遍历已同步员工的 departmentPath 字符串现场拼出来的,
|
||||
* 因此只能看到"有人的部门",同名部门会被合并成一行,父子归属取决于谁先被遍历到。
|
||||
* 上游 hr_groups 实际是一棵完整单根树(单一根节点、零孤儿节点),直接同步即可。
|
||||
*
|
||||
* 2. 在职状态——上游口径混杂:数字码(2 试用 / 3 正式 / 5 已离职)、中文(正式/离职/试用)
|
||||
* 与空值并存。此前用「离职/停用/退休」做关键词包含匹配,对数字码和空值完全失效,
|
||||
* 已核实 216 条已离职档案中有 215 条被判定为在职。现改为接入离职表 + 状态码,
|
||||
* 结果写入规范化的 employmentState 字段,原始值保留作审计。
|
||||
*
|
||||
* 本模块独立于既有的 performance-survey-report-sync,不改动那条链路。
|
||||
*/
|
||||
|
||||
import mssql from 'mssql'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const GROUP_TABLE = {
|
||||
schema: process.env.REPORT_DB_GROUP_SCHEMA || 'rpt',
|
||||
table: process.env.REPORT_DB_GROUP_TABLE || 'hr_groups',
|
||||
}
|
||||
const DIMISSION_TABLE = {
|
||||
schema: process.env.REPORT_DB_DIMISSION_SCHEMA || 'rpt',
|
||||
table: process.env.REPORT_DB_DIMISSION_TABLE || 'hr_employee_dimission',
|
||||
}
|
||||
const REGISTER_TABLE = {
|
||||
schema: process.env.REPORT_DB_REGISTER_SCHEMA || 'rpt',
|
||||
table: process.env.REPORT_DB_REGISTER_TABLE || 'hr_employee_register',
|
||||
}
|
||||
|
||||
/** 上游员工状态码。5 已离职,2 试用、3 正式均视为在职 */
|
||||
const STATUS_CODE_RESIGNED = 5
|
||||
const STATUS_CODE_ACTIVE = [2, 3]
|
||||
|
||||
export const EMPLOYMENT_STATE = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
RESIGNED: 'RESIGNED',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const
|
||||
|
||||
function quoteIdentifier(value: string) {
|
||||
return '[' + String(value).replace(/]/g, ']]') + ']'
|
||||
}
|
||||
|
||||
function tableRef(t: { schema: string; table: string }) {
|
||||
return quoteIdentifier(t.schema) + '.' + quoteIdentifier(t.table)
|
||||
}
|
||||
|
||||
export function isReportDbConfigured() {
|
||||
return Boolean(
|
||||
process.env.REPORT_DB_SERVER &&
|
||||
process.env.REPORT_DB_DATABASE &&
|
||||
process.env.REPORT_DB_USER &&
|
||||
process.env.REPORT_DB_PASSWORD,
|
||||
)
|
||||
}
|
||||
|
||||
function buildConfig() {
|
||||
return {
|
||||
server: process.env.REPORT_DB_SERVER as string,
|
||||
database: process.env.REPORT_DB_DATABASE as string,
|
||||
user: process.env.REPORT_DB_USER as string,
|
||||
password: process.env.REPORT_DB_PASSWORD as string,
|
||||
port: Number(process.env.REPORT_DB_PORT || 1433),
|
||||
options: {
|
||||
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
|
||||
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
|
||||
},
|
||||
connectionTimeout: 20000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 30000 },
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 bigint 时间戳(秒或毫秒均可能)转 Date */
|
||||
function toDate(value: unknown): Date | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n <= 0) return null
|
||||
const ms = n > 1e12 ? n : n * 1000
|
||||
const d = new Date(ms)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function clean(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const s = String(value).trim()
|
||||
return s === '' ? null : s
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 一、组织架构同步
|
||||
// ============================================================
|
||||
|
||||
export interface OrganizationSyncResult {
|
||||
fetched: number
|
||||
created: number
|
||||
updated: number
|
||||
deactivated: number
|
||||
rootName: string | null
|
||||
maxDepth: number
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
interface RawGroup {
|
||||
Id: number
|
||||
parent_id: number | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在内存中把上游扁平部门列表整理成树,算出每个节点的全路径与层级。
|
||||
*
|
||||
* 采用逐层展开而非递归,避免异常数据造成的深递归;父节点缺失或成环的节点
|
||||
* 会留在 unresolved 中,由调用方决定如何处理,不会静默丢失。
|
||||
*/
|
||||
function buildTree(rows: RawGroup[]) {
|
||||
const byId = new Map<number, RawGroup>()
|
||||
rows.forEach((r) => byId.set(r.Id, r))
|
||||
|
||||
const resolved = new Map<number, { path: string; level: number }>()
|
||||
const roots = rows.filter((r) => r.parent_id === null || r.parent_id === 0 || r.parent_id === r.Id)
|
||||
|
||||
const displayName = (r: RawGroup) => clean(r.name) ?? `部门#${r.Id}`
|
||||
|
||||
const queue: RawGroup[] = []
|
||||
roots.forEach((r) => {
|
||||
resolved.set(r.Id, { path: displayName(r), level: 1 })
|
||||
queue.push(r)
|
||||
})
|
||||
|
||||
const childrenOf = new Map<number, RawGroup[]>()
|
||||
rows.forEach((r) => {
|
||||
if (r.parent_id === null || r.parent_id === 0 || r.parent_id === r.Id) return
|
||||
const list = childrenOf.get(r.parent_id) ?? []
|
||||
list.push(r)
|
||||
childrenOf.set(r.parent_id, list)
|
||||
})
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as RawGroup
|
||||
const parentInfo = resolved.get(current.Id)
|
||||
if (!parentInfo) continue
|
||||
for (const child of childrenOf.get(current.Id) ?? []) {
|
||||
if (resolved.has(child.Id)) continue // 防成环
|
||||
resolved.set(child.Id, {
|
||||
path: parentInfo.path + '/' + displayName(child),
|
||||
level: parentInfo.level + 1,
|
||||
})
|
||||
queue.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
const unresolved = rows.filter((r) => !resolved.has(r.Id))
|
||||
return { resolved, unresolved, roots, byId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步组织架构。
|
||||
*
|
||||
* 分两阶段写入:先落所有节点(此时不设 parentId),再回填父子关系——
|
||||
* 因为父节点的本地主键要等它自己写完才存在。
|
||||
* 上游已不存在的部门标记为 isActive=false 而非物理删除,避免历史引用断裂。
|
||||
*/
|
||||
export async function syncOrganizationUnits(options: { dryRun?: boolean } = {}): Promise<OrganizationSyncResult> {
|
||||
const { dryRun = false } = options
|
||||
if (!isReportDbConfigured()) {
|
||||
throw new Error('未配置上游报表库(REPORT_DB_*),无法同步组织架构')
|
||||
}
|
||||
|
||||
const pool = await mssql.connect(buildConfig())
|
||||
let rows: RawGroup[]
|
||||
try {
|
||||
const result = await pool.request().query(
|
||||
`SELECT Id, parent_id, name FROM ${tableRef(GROUP_TABLE)}`,
|
||||
)
|
||||
rows = result.recordset as RawGroup[]
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
const { resolved, unresolved, roots } = buildTree(rows)
|
||||
const maxDepth = Math.max(1, ...Array.from(resolved.values()).map((v) => v.level))
|
||||
const rootName = roots.length > 0 ? clean(roots[0].name) : null
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
console.warn(
|
||||
`[organization-sync] ${unresolved.length} 个部门无法定位到根(父部门缺失或成环),本次跳过`,
|
||||
)
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
fetched: rows.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deactivated: 0,
|
||||
rootName,
|
||||
maxDepth,
|
||||
dryRun: true,
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const existing = await prisma.organizationUnit.findMany({
|
||||
select: { id: true, sourceDeptId: true },
|
||||
})
|
||||
const existingBySource = new Map(existing.map((e) => [e.sourceDeptId, e.id]))
|
||||
|
||||
const payload = rows
|
||||
.map((row) => {
|
||||
const info = resolved.get(row.Id)
|
||||
if (!info) return null
|
||||
return {
|
||||
sourceDeptId: row.Id,
|
||||
name: clean(row.name) ?? `部门#${row.Id}`,
|
||||
sourceParentId: row.parent_id ?? null,
|
||||
path: info.path,
|
||||
level: info.level,
|
||||
}
|
||||
})
|
||||
.filter((v): v is NonNullable<typeof v> => v !== null)
|
||||
|
||||
const toCreate = payload.filter((p) => !existingBySource.has(p.sourceDeptId))
|
||||
const toUpdate = payload.filter((p) => existingBySource.has(p.sourceDeptId))
|
||||
|
||||
// 新节点批量插入。父子关系稍后统一回填,此处不设 parentId
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.organizationUnit.createMany({
|
||||
data: toCreate.map((p) => ({ ...p, isActive: true, syncedAt: now })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 已有节点分批更新。逐条 await 在远程库上要一次次往返,这里用 $transaction
|
||||
// 把一批操作打包成一次往返,既保持类型安全,也不必手工拼接 SQL 字符串。
|
||||
const BATCH = 200
|
||||
for (let i = 0; i < toUpdate.length; i += BATCH) {
|
||||
const batch = toUpdate.slice(i, i + BATCH)
|
||||
await prisma.$transaction(
|
||||
batch.map((p) =>
|
||||
prisma.organizationUnit.update({
|
||||
where: { sourceDeptId: p.sourceDeptId },
|
||||
data: {
|
||||
name: p.name,
|
||||
sourceParentId: p.sourceParentId,
|
||||
path: p.path,
|
||||
level: p.level,
|
||||
isActive: true,
|
||||
syncedAt: now,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const created = toCreate.length
|
||||
const updated = toUpdate.length
|
||||
|
||||
// 父子关系整体回填:按上游的 source_parent_id 关联到父节点的本地主键,
|
||||
// 一条语句取代逐节点更新。SQL 中没有任何外部字符串拼接。
|
||||
await prisma.$executeRaw`
|
||||
UPDATE organization_units AS child
|
||||
SET parent_id = parent.id, updated_at = NOW()
|
||||
FROM organization_units AS parent
|
||||
WHERE child.source_parent_id = parent.source_dept_id
|
||||
AND child.source_parent_id IS NOT NULL
|
||||
AND child.source_parent_id <> 0
|
||||
AND child.source_dept_id <> child.source_parent_id
|
||||
AND child.parent_id IS DISTINCT FROM parent.id
|
||||
`
|
||||
|
||||
// 根节点保持 parent_id 为空
|
||||
await prisma.$executeRaw`
|
||||
UPDATE organization_units
|
||||
SET parent_id = NULL, updated_at = NOW()
|
||||
WHERE (source_parent_id IS NULL OR source_parent_id = 0 OR source_parent_id = source_dept_id)
|
||||
AND parent_id IS NOT NULL
|
||||
`
|
||||
|
||||
// 上游已消失的部门下线,不物理删除
|
||||
const upstreamIds = new Set(rows.map((r) => r.Id))
|
||||
const stale = existing.filter((e) => !upstreamIds.has(e.sourceDeptId))
|
||||
let deactivated = 0
|
||||
if (stale.length > 0) {
|
||||
const result = await prisma.organizationUnit.updateMany({
|
||||
where: { id: { in: stale.map((s) => s.id) }, isActive: true },
|
||||
data: { isActive: false, syncedAt: now },
|
||||
})
|
||||
deactivated = result.count
|
||||
}
|
||||
|
||||
return { fetched: rows.length, created, updated, deactivated, rootName, maxDepth, dryRun: false }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 二、在职状态同步
|
||||
// ============================================================
|
||||
|
||||
export interface EmploymentSyncResult {
|
||||
upstreamResigned: number
|
||||
upstreamRegistered: number
|
||||
localProfiles: number
|
||||
markedResigned: number
|
||||
markedActive: number
|
||||
markedUnknown: number
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据上游离职表与员工状态码,校正本地档案的在职状态。
|
||||
*
|
||||
* 判定优先级:
|
||||
* 1. 出现在离职表中 → RESIGNED(同时落最后工作日与交接人)
|
||||
* 2. 状态码 5 → RESIGNED
|
||||
* 3. 状态码 2 / 3 → ACTIVE
|
||||
* 4. 其余 → UNKNOWN(不臆断,交由人工核对)
|
||||
*/
|
||||
export async function syncEmploymentStates(options: { dryRun?: boolean } = {}): Promise<EmploymentSyncResult> {
|
||||
const { dryRun = false } = options
|
||||
if (!isReportDbConfigured()) {
|
||||
throw new Error('未配置上游报表库(REPORT_DB_*),无法同步在职状态')
|
||||
}
|
||||
|
||||
const pool = await mssql.connect(buildConfig())
|
||||
let resignedRows: Array<{ userid: string; last_work_day: any; handover_userid: string | null }>
|
||||
let registerRows: Array<{ userid: string; status: any }>
|
||||
try {
|
||||
const r1 = await pool.request().query(
|
||||
`SELECT userid, last_work_day, handover_userid
|
||||
FROM ${tableRef(DIMISSION_TABLE)} WHERE userid IS NOT NULL`,
|
||||
)
|
||||
resignedRows = r1.recordset as any[]
|
||||
|
||||
const r2 = await pool.request().query(
|
||||
`SELECT userid, [sys01-employeeStatus] AS status
|
||||
FROM ${tableRef(REGISTER_TABLE)} WHERE userid IS NOT NULL`,
|
||||
)
|
||||
registerRows = r2.recordset as any[]
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
const resignedMap = new Map<string, { at: Date | null; handover: string | null }>()
|
||||
resignedRows.forEach((r) => {
|
||||
const key = clean(r.userid)
|
||||
if (!key) return
|
||||
resignedMap.set(key, {
|
||||
at: toDate(r.last_work_day),
|
||||
handover: clean(r.handover_userid),
|
||||
})
|
||||
})
|
||||
|
||||
const statusMap = new Map<string, number | null>()
|
||||
registerRows.forEach((r) => {
|
||||
const key = clean(r.userid)
|
||||
if (!key) return
|
||||
const n = Number(clean(r.status))
|
||||
statusMap.set(key, Number.isFinite(n) ? n : null)
|
||||
})
|
||||
|
||||
const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
select: { id: true, dingtalkUserId: true, employmentState: true },
|
||||
})
|
||||
|
||||
let markedResigned = 0
|
||||
let markedActive = 0
|
||||
let markedUnknown = 0
|
||||
|
||||
for (const profile of profiles) {
|
||||
const ding = clean(profile.dingtalkUserId)
|
||||
let nextState: string = EMPLOYMENT_STATE.UNKNOWN
|
||||
let resignedAt: Date | null = null
|
||||
let handover: string | null = null
|
||||
|
||||
if (ding) {
|
||||
const resigned = resignedMap.get(ding)
|
||||
if (resigned) {
|
||||
nextState = EMPLOYMENT_STATE.RESIGNED
|
||||
resignedAt = resigned.at
|
||||
handover = resigned.handover
|
||||
} else {
|
||||
const code = statusMap.get(ding)
|
||||
if (code === STATUS_CODE_RESIGNED) nextState = EMPLOYMENT_STATE.RESIGNED
|
||||
else if (code !== null && code !== undefined && STATUS_CODE_ACTIVE.includes(code)) {
|
||||
nextState = EMPLOYMENT_STATE.ACTIVE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextState === EMPLOYMENT_STATE.RESIGNED) markedResigned += 1
|
||||
else if (nextState === EMPLOYMENT_STATE.ACTIVE) markedActive += 1
|
||||
else markedUnknown += 1
|
||||
|
||||
if (!dryRun && profile.employmentState !== nextState) {
|
||||
await prisma.performanceSurveyEmployeeProfile.update({
|
||||
where: { id: profile.id },
|
||||
data: {
|
||||
employmentState: nextState,
|
||||
resignedAt,
|
||||
handoverDingtalkUserId: handover,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upstreamResigned: resignedMap.size,
|
||||
upstreamRegistered: statusMap.size,
|
||||
localProfiles: profiles.length,
|
||||
markedResigned,
|
||||
markedActive,
|
||||
markedUnknown,
|
||||
dryRun,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在职人员的查询条件,供替换此前基于关键词包含匹配的写法。
|
||||
*
|
||||
* UNKNOWN 视为在职:这些人上游查不到明确状态,宁可多留也不误删——
|
||||
* 判定错误导致的是"看不见的人",比"多出来的人"更难被发现。
|
||||
*/
|
||||
export function getActiveEmployeeWhere() {
|
||||
return {
|
||||
OR: [
|
||||
{ employmentState: EMPLOYMENT_STATE.ACTIVE },
|
||||
{ employmentState: EMPLOYMENT_STATE.UNKNOWN },
|
||||
{ employmentState: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 三、离职联动
|
||||
// ============================================================
|
||||
|
||||
export interface ResignationEffectResult {
|
||||
/** 权威人员已离职、素材库账号却仍活跃的总人数 */
|
||||
candidates: number
|
||||
/** 满足处理条件、实际纳入本次联动的人数 */
|
||||
pending: number
|
||||
/** 因离职时间不足或日期缺失而暂缓的人数 */
|
||||
deferred: number
|
||||
archivedAccounts: number
|
||||
custodiedMaterials: number
|
||||
details: Array<{
|
||||
displayName: string
|
||||
username: string
|
||||
department: string | null
|
||||
materials: number
|
||||
resignedAt: Date | null
|
||||
}>
|
||||
deferredDetails: Array<{ displayName: string; reason: string }>
|
||||
failures: Array<{ username: string; reason: string }>
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据权威人员的离职状态,联动处理素材库侧。
|
||||
*
|
||||
* 上游标记离职后,此前需要人工发现并逐个处理:账号一直可登录、素材一直挂在
|
||||
* 离职者名下无人维护。这里把两件事自动化:
|
||||
* 1. 归档素材库账号(软归档,历史记录全部保留)
|
||||
* 2. 把其名下素材托管给所在部门,userId 与 authorName 保持不变
|
||||
*
|
||||
* 仅处理已通过钉钉唯一值建立正式绑定的账号,不按姓名猜测。
|
||||
*
|
||||
* `minDaysSinceResignation` 用于避开交接期、返聘与上游数据延迟:只处理离职已满
|
||||
* 指定天数的人;离职日期缺失者一并暂缓,交人工核实,不做推断。
|
||||
*/
|
||||
export async function applyResignationEffects(
|
||||
options: { dryRun?: boolean; minDaysSinceResignation?: number } = {},
|
||||
): Promise<ResignationEffectResult> {
|
||||
const { dryRun = false, minDaysSinceResignation } = options
|
||||
|
||||
// 通过正式绑定关系找出"人已离职、账号还活着"的情况
|
||||
const bindings = await prisma.userEmployeeBinding.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
employeeProfile: { employmentState: EMPLOYMENT_STATE.RESIGNED },
|
||||
user: { archivedAt: null },
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
employeeProfile: { select: { displayName: true, resignedAt: true } },
|
||||
user: { select: { username: true, department: true, isSuperAdmin: true, role: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const result: ResignationEffectResult = {
|
||||
candidates: bindings.length,
|
||||
pending: 0,
|
||||
deferred: 0,
|
||||
archivedAccounts: 0,
|
||||
custodiedMaterials: 0,
|
||||
details: [],
|
||||
deferredDetails: [],
|
||||
failures: [],
|
||||
dryRun,
|
||||
}
|
||||
|
||||
const cutoff =
|
||||
typeof minDaysSinceResignation === 'number'
|
||||
? new Date(Date.now() - minDaysSinceResignation * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
|
||||
for (const binding of bindings) {
|
||||
const username = binding.user.username
|
||||
const displayName = binding.employeeProfile.displayName
|
||||
const resignedAt = binding.employeeProfile.resignedAt
|
||||
|
||||
if (cutoff) {
|
||||
if (!resignedAt) {
|
||||
result.deferred += 1
|
||||
result.deferredDetails.push({ displayName, reason: '离职日期缺失,需人工核实' })
|
||||
continue
|
||||
}
|
||||
if (resignedAt > cutoff) {
|
||||
result.deferred += 1
|
||||
result.deferredDetails.push({
|
||||
displayName,
|
||||
reason: '离职未满 ' + minDaysSinceResignation + ' 天(' + resignedAt.toLocaleDateString('zh-CN') + ')',
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const materialCount = await prisma.material.count({
|
||||
where: { userId: binding.userId, deletedAt: null },
|
||||
})
|
||||
|
||||
result.pending += 1
|
||||
result.details.push({
|
||||
displayName,
|
||||
username,
|
||||
department: binding.user.department || null,
|
||||
materials: materialCount,
|
||||
resignedAt,
|
||||
})
|
||||
|
||||
if (dryRun) continue
|
||||
|
||||
try {
|
||||
// 超级管理员不由自动流程降权归档,避免误伤关键账号,交人工确认
|
||||
if (binding.user.isSuperAdmin) {
|
||||
result.failures.push({ username, reason: '该账号是超级管理员,需人工确认后处理' })
|
||||
continue
|
||||
}
|
||||
|
||||
const { custodyMaterialsToDepartment } = await import('@/lib/material-transfer')
|
||||
if (materialCount > 0) {
|
||||
const custody = await custodyMaterialsToDepartment({ fromUserId: binding.userId })
|
||||
result.custodiedMaterials += custody.custodied
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: binding.userId },
|
||||
data: {
|
||||
isActive: false,
|
||||
status: 'archived',
|
||||
archivedAt: new Date(),
|
||||
statusChangedAt: new Date(),
|
||||
employmentNote: '上游花名册标记离职,系统自动归档',
|
||||
},
|
||||
})
|
||||
result.archivedAccounts += 1
|
||||
} catch (error) {
|
||||
result.failures.push({
|
||||
username,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 四、定时同步
|
||||
// ============================================================
|
||||
|
||||
let dailySyncStarted = false
|
||||
|
||||
/**
|
||||
* 启动组织架构与人事状态的每日同步。
|
||||
*
|
||||
* 沿用项目既有的进程内定时器模式:首次有人访问相关功能时懒启动,定时器全部
|
||||
* unref,不会阻止进程退出。多实例部署时各实例会各自执行,同步本身是幂等的,
|
||||
* 重复执行只会得到相同结果。
|
||||
*
|
||||
* 三件事按顺序跑,任一失败只记录日志、不影响后续周期:
|
||||
* 组织架构 → 在职状态 → 离职联动
|
||||
*/
|
||||
export function ensureDailyOrganizationSyncStarted() {
|
||||
if (dailySyncStarted) return
|
||||
dailySyncStarted = true
|
||||
|
||||
if (!isReportDbConfigured()) {
|
||||
console.info('[organization-sync] 未配置上游报表库,跳过每日同步')
|
||||
return
|
||||
}
|
||||
|
||||
const intervalHours = Math.max(1, Number(process.env.ORGANIZATION_SYNC_INTERVAL_HOURS || 24))
|
||||
const initialDelaySeconds = Math.max(
|
||||
30,
|
||||
Number(process.env.ORGANIZATION_SYNC_INITIAL_DELAY_SECONDS || 600),
|
||||
)
|
||||
const minDays = Math.max(0, Number(process.env.RESIGNATION_MIN_DAYS || 30))
|
||||
|
||||
const runOnce = async () => {
|
||||
try {
|
||||
const org = await syncOrganizationUnits()
|
||||
console.info(
|
||||
`[organization-sync] 组织架构:上游 ${org.fetched} 个部门,新建 ${org.created},更新 ${org.updated},下线 ${org.deactivated}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[organization-sync] 组织架构同步失败:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const emp = await syncEmploymentStates()
|
||||
console.info(
|
||||
`[organization-sync] 在职状态:在职 ${emp.markedActive},离职 ${emp.markedResigned},未知 ${emp.markedUnknown}`,
|
||||
)
|
||||
} catch (error) {
|
||||
// 状态没更新就不做离职联动,避免依据陈旧数据归档账号
|
||||
console.warn('[organization-sync] 在职状态同步失败,本轮跳过离职联动:', error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const effect = await applyResignationEffects({ minDaysSinceResignation: minDays })
|
||||
if (effect.archivedAccounts > 0 || effect.custodiedMaterials > 0) {
|
||||
console.info(
|
||||
`[organization-sync] 离职联动:归档 ${effect.archivedAccounts} 个账号,托管 ${effect.custodiedMaterials} 条素材,暂缓 ${effect.deferred} 人`,
|
||||
)
|
||||
}
|
||||
if (effect.failures.length > 0) {
|
||||
console.warn(
|
||||
`[organization-sync] 离职联动有 ${effect.failures.length} 人需人工处理:` +
|
||||
effect.failures.map((f) => f.username + '(' + f.reason + ')').join('、'),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[organization-sync] 离职联动失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const starter = setTimeout(() => {
|
||||
void runOnce()
|
||||
const timer = setInterval(() => void runOnce(), intervalHours * 60 * 60 * 1000)
|
||||
timer.unref?.()
|
||||
}, initialDelaySeconds * 1000)
|
||||
starter.unref?.()
|
||||
}
|
||||
@ -111,17 +111,25 @@ const GROUP_TABLE = {
|
||||
table: process.env.REPORT_DB_GROUP_TABLE || 'hr_groups',
|
||||
}
|
||||
|
||||
const INACTIVE_EMPLOYEE_STATUS_KEYWORDS = ['离职', '停用', '退休']
|
||||
|
||||
/**
|
||||
* 在职人员的查询条件。
|
||||
*
|
||||
* 改为依据规范化的 employmentState 判定。此前是拿「离职/停用/退休」对
|
||||
* employmentStatus 做包含匹配,而上游状态口径混杂——既有数字码(2 试用 /
|
||||
* 3 正式 / 5 已离职),也有中文,还有大量空值,关键词匹配对前两类完全失效:
|
||||
* 核实时 216 条已离职档案中有 215 条被判定为在职。
|
||||
*
|
||||
* employmentState 由 lib/organization-sync.ts 依据上游离职表与状态码同步维护。
|
||||
* UNKNOWN 一并视为在职:这些人上游查不到明确状态,宁可多留也不误删——
|
||||
* 少判导致的是"人凭空消失",比多出几个人难发现得多。
|
||||
*/
|
||||
export function getActivePerformanceSurveyEmployeeProfileWhere() {
|
||||
return {
|
||||
OR: [
|
||||
{ employmentStatus: null },
|
||||
{
|
||||
AND: INACTIVE_EMPLOYEE_STATUS_KEYWORDS.map((keyword) => ({
|
||||
employmentStatus: { not: { contains: keyword } },
|
||||
})),
|
||||
},
|
||||
{ employmentState: 'ACTIVE' },
|
||||
{ employmentState: 'UNKNOWN' },
|
||||
{ employmentState: null },
|
||||
],
|
||||
} as any
|
||||
}
|
||||
|
||||
@ -69,6 +69,7 @@ import {
|
||||
previewPerformanceSurveyEmployeeSync,
|
||||
syncPerformanceSurveyEmployees,
|
||||
} from '@/lib/performance-survey-report-sync'
|
||||
import { ensureDailyOrganizationSyncStarted } from '@/lib/organization-sync'
|
||||
import {
|
||||
PERFORMANCE_SURVEY_IMAGE_LIMITS,
|
||||
PerformanceSurveyImageProcessingError,
|
||||
@ -886,9 +887,26 @@ function ensureSystemAdministrator(ctx: UserContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共看板调研的删除权限名单,按钉钉唯一值判定。
|
||||
*
|
||||
* 此前这里比对的是用户名字符串。但库中曾同时存在「无邪」(简体 U+65E0)与
|
||||
* 「無邪」(繁体 U+7121)两个账号,字符串比对只会命中其中一个——而命中的
|
||||
* 那个未必是本人。钉钉唯一值全项目唯一,且不随改名或繁简体写法变化。
|
||||
*
|
||||
* 通过环境变量覆盖,多个以逗号分隔。
|
||||
*/
|
||||
const PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS = new Set(
|
||||
(process.env.PERFORMANCE_SURVEY_PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS || '09061817188066')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
function canDeletePerformanceSurveyPublicDashboardAssessment(ctx: UserContext) {
|
||||
const username = ctx.user.username.trim().toLowerCase()
|
||||
return username === 'wuxie' || username === '无邪'
|
||||
const dingtalkUserId = ctx.user.dingtalkUserId?.trim()
|
||||
if (!dingtalkUserId) return false
|
||||
return PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS.has(dingtalkUserId)
|
||||
}
|
||||
|
||||
function ensureCanDeletePerformanceSurveyPublicDashboardAssessment(ctx: UserContext) {
|
||||
@ -1068,6 +1086,8 @@ async function getManagerSurveyFilterWhere(ctx: UserContext, params?: URLSearchP
|
||||
export async function getPerformanceSurveyContext(session: Session | null): Promise<UserContext> {
|
||||
ensurePerformanceSurveyNotificationWorkerStarted()
|
||||
ensurePerformanceSurveyDailyEmployeeSyncStarted()
|
||||
// 组织架构、在职状态与离职联动的每日同步,与人员档案同步一并懒启动
|
||||
ensureDailyOrganizationSyncStarted()
|
||||
|
||||
const sessionUserId = (session?.user as any)?.id
|
||||
if (!sessionUserId) {
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
-- 素材托管部门
|
||||
--
|
||||
-- 归属人离职后,其名下素材此前会变成"孤儿":账号已归档无法登录,
|
||||
-- 素材却仍挂在该账号下,部门里没有任何人能接手维护,且系统没有批量处理手段。
|
||||
--
|
||||
-- 本迁移引入"托管部门":人离职后素材交由其所在部门负责维护,
|
||||
-- 但 userId 与 authorName 保持不变——素材是谁做的,永远记在谁名下。
|
||||
-- 归属(谁负责维护)与署名(谁做的)由此分离。
|
||||
--
|
||||
-- 可重复执行。
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE materials
|
||||
ADD COLUMN IF NOT EXISTS custody_department_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS custody_at TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS custody_reason TEXT;
|
||||
|
||||
-- 部门被删除时解除托管而不是连带删除素材
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'materials_custody_department_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE materials
|
||||
ADD CONSTRAINT materials_custody_department_id_fkey
|
||||
FOREIGN KEY (custody_department_id) REFERENCES departments(id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS materials_custody_department_id_idx
|
||||
ON materials(custody_department_id);
|
||||
|
||||
COMMIT;
|
||||
@ -0,0 +1,84 @@
|
||||
-- 共享组织架构 + 规范化在职状态
|
||||
--
|
||||
-- 一、组织架构
|
||||
-- 此前系统没有真正的组织表,部门树靠遍历已同步员工的 departmentPath 字符串现场拼出,
|
||||
-- 只能看到"有人的部门",同名部门会被合并,父子归属取决于遍历顺序。
|
||||
-- 上游 rpt.hr_groups 实际是一棵完整单根树(1075 个部门、单一根节点、零孤儿节点),
|
||||
-- 直接镜像即可。
|
||||
--
|
||||
-- 二、在职状态
|
||||
-- 上游员工状态口径混杂:既有数字码(2 试用 / 3 正式 / 5 已离职),也有「正式」「离职」
|
||||
-- 「试用」等中文,还有大量空值。现有代码用「离职/停用/退休」做关键词包含匹配,
|
||||
-- 对数字码和空值完全失效——已核实 216 条已离职档案中有 215 条仍被判定为在职。
|
||||
-- 因此单独规范化一列供运行时判定,原始值保留作审计。
|
||||
--
|
||||
-- 可重复执行。
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ========== 1. 组织架构表 ==========
|
||||
CREATE TABLE IF NOT EXISTS organization_units (
|
||||
id TEXT NOT NULL,
|
||||
source_dept_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
source_parent_id INTEGER,
|
||||
path TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
synced_at TIMESTAMP(3),
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT organization_units_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS organization_units_source_dept_id_key
|
||||
ON organization_units(source_dept_id);
|
||||
|
||||
-- 父部门被移除时解除挂靠而不是连带删除子部门
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'organization_units_parent_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE organization_units
|
||||
ADD CONSTRAINT organization_units_parent_id_fkey
|
||||
FOREIGN KEY (parent_id) REFERENCES organization_units(id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS organization_units_parent_id_idx ON organization_units(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS organization_units_source_parent_id_idx ON organization_units(source_parent_id);
|
||||
CREATE INDEX IF NOT EXISTS organization_units_path_idx ON organization_units(path);
|
||||
CREATE INDEX IF NOT EXISTS organization_units_is_active_idx ON organization_units(is_active);
|
||||
|
||||
-- ========== 2. 人员档案的规范化在职状态 ==========
|
||||
ALTER TABLE performance_survey_employee_profiles
|
||||
ADD COLUMN IF NOT EXISTS employment_state TEXT DEFAULT 'UNKNOWN',
|
||||
ADD COLUMN IF NOT EXISTS resigned_at TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS handover_dingtalk_user_id TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ps_employee_employment_state_idx
|
||||
ON performance_survey_employee_profiles(employment_state);
|
||||
|
||||
-- 依据现有中文状态先做一轮保守初始化:能明确判断的先落定,
|
||||
-- 其余保持 UNKNOWN,等接入上游离职表后再由同步逐条校正。
|
||||
UPDATE performance_survey_employee_profiles
|
||||
SET employment_state = 'RESIGNED'
|
||||
WHERE employment_state IS DISTINCT FROM 'RESIGNED'
|
||||
AND employment_status IS NOT NULL
|
||||
AND (employment_status LIKE '%离职%' OR employment_status LIKE '%停用%' OR employment_status LIKE '%退休%');
|
||||
|
||||
UPDATE performance_survey_employee_profiles
|
||||
SET employment_state = 'ACTIVE'
|
||||
WHERE employment_state IS DISTINCT FROM 'ACTIVE'
|
||||
AND employment_status IS NOT NULL
|
||||
AND (employment_status IN ('2', '3') OR employment_status LIKE '%正式%' OR employment_status LIKE '%试用%');
|
||||
|
||||
UPDATE performance_survey_employee_profiles
|
||||
SET employment_state = 'RESIGNED'
|
||||
WHERE employment_state IS DISTINCT FROM 'RESIGNED'
|
||||
AND employment_status = '5';
|
||||
|
||||
COMMIT;
|
||||
@ -0,0 +1,121 @@
|
||||
-- 素材库账号 ↔ 权威人员档案 正式绑定关系
|
||||
--
|
||||
-- 背景:此前账号与花名册人员之间没有任何外键,仅在运行时按
|
||||
-- 钉钉ID → 邮箱 → sourcePayload JSON 逐层软匹配,数据库层面
|
||||
-- 并不知道某个素材库账号和某条花名册档案是同一个人。
|
||||
--
|
||||
-- 本迁移建立正式绑定表,并按钉钉唯一值回填存量绑定。
|
||||
-- 严格遵守:只按钉钉唯一值自动绑定,姓名与邮箱不参与自动建立绑定。
|
||||
--
|
||||
-- 可重复执行。
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ========== 1. 前置校验 ==========
|
||||
-- 一个钉钉身份对应多个素材库账号时,回填会在同一人员档案上产生
|
||||
-- 多条 ACTIVE 绑定,违反下面的部分唯一索引。此处提前拦截并给出明确原因,
|
||||
-- 而不是让索引创建阶段抛出难以定位的报错。
|
||||
DO $$
|
||||
DECLARE
|
||||
conflict_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO conflict_count
|
||||
FROM (
|
||||
SELECT "dingtalkUserId"
|
||||
FROM users
|
||||
WHERE "dingtalkUserId" IS NOT NULL
|
||||
GROUP BY "dingtalkUserId"
|
||||
HAVING COUNT(*) > 1
|
||||
) AS duplicated;
|
||||
|
||||
IF conflict_count > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'存在 % 个钉钉账号绑定了多个素材库账号,必须先人工处理后再执行本迁移。禁止按姓名自动合并。',
|
||||
conflict_count;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ========== 2. 钉钉唯一值:全项目唯一 ==========
|
||||
-- 此前 users."dingtalkUserId" 没有任何唯一约束,数据库层面允许两个素材库账号
|
||||
-- 填入同一个钉钉身份。既然钉钉唯一值是全项目唯一的人员判定依据,这条约束必须落到库上。
|
||||
-- PostgreSQL 中多个 NULL 互不冲突,因此未绑定钉钉的账号不受影响。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "users_dingtalkUserId_key"
|
||||
ON users("dingtalkUserId");
|
||||
|
||||
-- ========== 3. 建表 ==========
|
||||
CREATE TABLE IF NOT EXISTS user_employee_bindings (
|
||||
id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
employee_profile_id TEXT NOT NULL,
|
||||
binding_source TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
verified_at TIMESTAMP(3),
|
||||
note TEXT,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT user_employee_bindings_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- 外键:账号或人员档案被删除时,绑定关系一并清除
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE user_employee_bindings
|
||||
ADD CONSTRAINT user_employee_bindings_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_profile_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE user_employee_bindings
|
||||
ADD CONSTRAINT user_employee_bindings_profile_id_fkey
|
||||
FOREIGN KEY (employee_profile_id) REFERENCES performance_survey_employee_profiles(id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ========== 4. 索引 ==========
|
||||
CREATE INDEX IF NOT EXISTS user_employee_binding_user_idx ON user_employee_bindings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS user_employee_binding_profile_idx ON user_employee_bindings(employee_profile_id);
|
||||
CREATE INDEX IF NOT EXISTS user_employee_binding_status_idx ON user_employee_bindings(status);
|
||||
|
||||
-- 活跃绑定的双向唯一:一个账号只能绑一名权威人员,一名权威人员只能绑一个活跃账号。
|
||||
-- 使用部分唯一索引,从而允许保留历史(status <> 'ACTIVE')绑定记录。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_user_key
|
||||
ON user_employee_bindings(user_id) WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_profile_key
|
||||
ON user_employee_bindings(employee_profile_id) WHERE status = 'ACTIVE';
|
||||
|
||||
-- ========== 5. 回填存量绑定 ==========
|
||||
-- 只按钉钉唯一值匹配。行 ID 由 user_id + profile_id 确定性生成,保证重复执行幂等。
|
||||
INSERT INTO user_employee_bindings (
|
||||
id, user_id, employee_profile_id, binding_source, status, verified_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
md5('user_employee_binding:' || u.id || ':' || p.id)::uuid::text,
|
||||
u.id,
|
||||
p.id,
|
||||
'MIGRATION',
|
||||
'ACTIVE',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM users u
|
||||
JOIN performance_survey_employee_profiles p
|
||||
ON p.dingtalk_user_id = u."dingtalkUserId"
|
||||
WHERE u."dingtalkUserId" IS NOT NULL
|
||||
AND u."dingtalkUserId" <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_employee_bindings b
|
||||
WHERE b.user_id = u.id AND b.status = 'ACTIVE'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_employee_bindings b
|
||||
WHERE b.employee_profile_id = p.id AND b.status = 'ACTIVE'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@ -21,7 +21,7 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
department String @default("")
|
||||
dingtalkUserId String?
|
||||
dingtalkUserId String? @unique
|
||||
loginType String @default("normal")
|
||||
realName String?
|
||||
phone String?
|
||||
@ -54,6 +54,7 @@ model User {
|
||||
productPackageUploadJobs ProductPackageUploadJob[] @relation("ProductPackageUploadJobOperator")
|
||||
departmentRef Department? @relation("UserDepartment", fields: [departmentId], references: [id])
|
||||
transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id])
|
||||
employeeBindings UserEmployeeBinding[]
|
||||
|
||||
@@index([status])
|
||||
@@index([archivedAt])
|
||||
@ -100,6 +101,11 @@ model Material {
|
||||
transferredAt DateTime?
|
||||
transferredBy String?
|
||||
transferNote String?
|
||||
/// 原归属人离职后,素材托管给的部门。托管只改变"谁负责维护",
|
||||
/// 不改变 userId 与 authorName——素材是谁做的,永远记在谁名下。
|
||||
custodyDepartmentId String? @map("custody_department_id")
|
||||
custodyAt DateTime? @map("custody_at")
|
||||
custodyReason String? @map("custody_reason")
|
||||
taskNo String? @map("task_no")
|
||||
dingtalkApprovalNo String? @map("dingtalk_approval_no")
|
||||
taskStatus String? @map("task_status")
|
||||
@ -120,8 +126,10 @@ model Material {
|
||||
productPackageUploadJobs ProductPackageUploadJob[]
|
||||
user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull)
|
||||
custodyDepartment Department? @relation("MaterialCustodyDepartment", fields: [custodyDepartmentId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([custodyDepartmentId])
|
||||
@@index([taskDetailRequesterUserId])
|
||||
@@index([createdAt])
|
||||
@@index([categoryName])
|
||||
@ -333,6 +341,7 @@ model Department {
|
||||
children Department[] @relation("DepartmentHierarchy")
|
||||
users User[] @relation("UserDepartment")
|
||||
transferredUsers User[] @relation("UserTransferDepartment")
|
||||
custodyMaterials Material[] @relation("MaterialCustodyDepartment")
|
||||
|
||||
@@index([parentId])
|
||||
@@index([status])
|
||||
@ -550,6 +559,14 @@ model PerformanceSurveyEmployeeProfile {
|
||||
managerJobNumber String? @map("manager_job_number")
|
||||
managerName String? @map("manager_name")
|
||||
employmentStatus String? @map("employment_status")
|
||||
/// 规范化的在职状态:ACTIVE / RESIGNED / UNKNOWN。
|
||||
/// employmentStatus 是上游原样值,口径混杂(有数字码 2/3/5,也有「正式」「离职」「试用」等中文,
|
||||
/// 还有大量空值),靠关键词匹配判断在职会大面积误判,因此单独规范化一列供运行时判定使用。
|
||||
employmentState String? @default("UNKNOWN") @map("employment_state")
|
||||
/// 最后工作日,来自上游离职表
|
||||
resignedAt DateTime? @map("resigned_at")
|
||||
/// 离职交接人的钉钉唯一值,上游填写率较低,仅作参考
|
||||
handoverDingtalkUserId String? @map("handover_dingtalk_user_id")
|
||||
workPlace String? @map("work_place")
|
||||
sourcePayload Json? @map("source_payload")
|
||||
syncedAt DateTime? @map("synced_at")
|
||||
@ -563,6 +580,7 @@ model PerformanceSurveyEmployeeProfile {
|
||||
notifications PerformanceSurveyNotification[]
|
||||
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
|
||||
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
|
||||
userBindings UserEmployeeBinding[]
|
||||
submittedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewSubmitter")
|
||||
managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager")
|
||||
finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer")
|
||||
@ -577,9 +595,66 @@ model PerformanceSurveyEmployeeProfile {
|
||||
@@index([isDingtalkAdmin])
|
||||
@@index([managerDingtalkUserId])
|
||||
@@index([employmentStatus])
|
||||
@@index([employmentState])
|
||||
@@map("performance_survey_employee_profiles")
|
||||
}
|
||||
|
||||
/// 共享组织架构,直接镜像上游 HR 系统的部门树(rpt.hr_groups)。
|
||||
///
|
||||
/// 此前系统没有真正的组织表:部门树是遍历已同步员工的 departmentPath 字符串
|
||||
/// 现场拼出来的,因而只能看到"有人的部门",且同名部门会被合并、父子归属取决于
|
||||
/// 谁先被遍历到。上游其实是一棵完整的单根树(单一根节点、零孤儿节点),
|
||||
/// 直接同步即可,不必倒推。
|
||||
model OrganizationUnit {
|
||||
id String @id @default(uuid())
|
||||
/// 上游 hr_groups.Id
|
||||
sourceDeptId Int @unique @map("source_dept_id")
|
||||
name String
|
||||
parentId String? @map("parent_id")
|
||||
/// 上游 hr_groups.parent_id,同步期间用于重建父子关系
|
||||
sourceParentId Int? @map("source_parent_id")
|
||||
/// 以 / 分隔的全路径,展示与前缀匹配用
|
||||
path String
|
||||
level Int @default(1)
|
||||
/// 本次同步是否仍存在于上游;上游删除的部门置为 false 而非物理删除
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
syncedAt DateTime? @map("synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
parent OrganizationUnit? @relation("OrganizationHierarchy", fields: [parentId], references: [id])
|
||||
children OrganizationUnit[] @relation("OrganizationHierarchy")
|
||||
|
||||
@@index([parentId])
|
||||
@@index([sourceParentId])
|
||||
@@index([path])
|
||||
@@index([isActive])
|
||||
@@map("organization_units")
|
||||
}
|
||||
|
||||
/// 素材库账号与权威人员档案的正式绑定关系。
|
||||
/// 取代此前"运行时按钉钉ID/邮箱/sourcePayload 逐层软匹配"的隐式关联做法。
|
||||
/// 活跃绑定的唯一性由部分唯一索引保证(仅约束 status='ACTIVE',见对应 migration),
|
||||
/// 保证一个账号只对应一名权威人员、一名权威人员只对应一个活跃账号。
|
||||
/// 冲突一律进人工处理,禁止按姓名自动合并。
|
||||
model UserEmployeeBinding {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
employeeProfileId String @map("employee_profile_id")
|
||||
bindingSource String @map("binding_source")
|
||||
status String @default("ACTIVE")
|
||||
verifiedAt DateTime? @map("verified_at")
|
||||
note String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
employeeProfile PerformanceSurveyEmployeeProfile @relation(fields: [employeeProfileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId], map: "user_employee_binding_user_idx")
|
||||
@@index([employeeProfileId], map: "user_employee_binding_profile_idx")
|
||||
@@index([status], map: "user_employee_binding_status_idx")
|
||||
@@map("user_employee_bindings")
|
||||
}
|
||||
|
||||
model PerformanceSurveyEmployeeDepartmentMembership {
|
||||
id String @id @default(uuid())
|
||||
employeeProfileId String @map("employee_profile_id")
|
||||
|
||||
102
scripts/backfill-email-author-names.ts
Normal file
102
scripts/backfill-email-author-names.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 回填作者名:把误存成邮箱的 authorName 改回真实用户名
|
||||
*
|
||||
* 成因:任务-详情上传入口曾读取 session.user.username,而登录态从不写入该字段,
|
||||
* 一路兜底存成了邮箱。入口已修复,本脚本清理存量。
|
||||
*
|
||||
* 只改 authorName(署名展示用),不动 userId(归属)。
|
||||
* 取值口径与其它上传入口一致:使用 users.username。
|
||||
*
|
||||
* 默认预演,不写任何数据。加 --apply 才实际执行。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/backfill-email-author-names.ts # 预演
|
||||
* npx tsx scripts/backfill-email-author-names.ts --apply # 实际执行
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient({ log: [] })
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply')
|
||||
|
||||
console.log('\n回填被误存为邮箱的作者名')
|
||||
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
|
||||
|
||||
const targets = await prisma.material.findMany({
|
||||
where: { deletedAt: null, authorName: { contains: '@' } },
|
||||
select: {
|
||||
id: true,
|
||||
authorName: true,
|
||||
materialType: true,
|
||||
user: { select: { username: true, realName: true, archivedAt: true } },
|
||||
},
|
||||
})
|
||||
|
||||
console.log('\n 作者名含邮箱的素材 ' + targets.length + ' 条')
|
||||
|
||||
const fixable = targets.filter((m) => m.user?.username && m.user.username.trim() !== '')
|
||||
const unfixable = targets.filter((m) => !m.user?.username || m.user.username.trim() === '')
|
||||
|
||||
console.log(' 可按归属账号回填 ' + fixable.length + ' 条')
|
||||
console.log(' 无法回填(账号缺失) ' + unfixable.length + ' 条')
|
||||
|
||||
const byType = new Map<string, number>()
|
||||
fixable.forEach((m) => {
|
||||
const k = m.materialType || '(无类型)'
|
||||
byType.set(k, (byType.get(k) || 0) + 1)
|
||||
})
|
||||
console.log('\n 按素材类型分布:')
|
||||
Array.from(byType.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.forEach(([t, c]) => console.log(' ' + t.padEnd(20, '.') + c))
|
||||
|
||||
const samples = fixable.slice(0, 8)
|
||||
console.log('\n 回填样本(前 8 条):')
|
||||
samples.forEach((m) =>
|
||||
console.log(
|
||||
' ' + (m.authorName ?? '').padEnd(34) + ' → ' + m.user!.username +
|
||||
(m.user!.archivedAt ? '(已离职)' : ''),
|
||||
),
|
||||
)
|
||||
|
||||
if (!apply) {
|
||||
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
|
||||
await prisma.$disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// 按目标用户名分组批量更新,避免逐条往返
|
||||
const groups = new Map<string, string[]>()
|
||||
fixable.forEach((m) => {
|
||||
const name = m.user!.username
|
||||
const list = groups.get(name) ?? []
|
||||
list.push(m.id)
|
||||
groups.set(name, list)
|
||||
})
|
||||
|
||||
let updated = 0
|
||||
for (const [username, ids] of groups) {
|
||||
const result = await prisma.material.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { authorName: username },
|
||||
})
|
||||
updated += result.count
|
||||
}
|
||||
|
||||
console.log('\n 已回填 ' + updated + ' 条')
|
||||
|
||||
const remaining = await prisma.material.count({
|
||||
where: { deletedAt: null, authorName: { contains: '@' } },
|
||||
})
|
||||
console.log(' 剩余作者名含邮箱的 ' + remaining + ' 条' + (remaining === unfixable.length ? '(均为无法回填的)' : ''))
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error('\n执行失败:', e)
|
||||
await prisma.$disconnect()
|
||||
process.exitCode = 1
|
||||
})
|
||||
67
scripts/custody-archived-user-materials.ts
Normal file
67
scripts/custody-archived-user-materials.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 离职人员素材部门托管
|
||||
*
|
||||
* 把已归档(离职)账号名下的素材,交由各自所属部门接管。
|
||||
*
|
||||
* 托管只改变"谁负责维护":userId 与 authorName 保持不变,
|
||||
* 首页素材卡片显示的作者仍然是本人——素材是谁做的,永远记在谁名下。
|
||||
*
|
||||
* 默认预演,不写任何数据。加 --apply 才实际执行。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/custody-archived-user-materials.ts # 预演
|
||||
* npx tsx scripts/custody-archived-user-materials.ts --apply # 实际执行
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { custodyMaterialsOfArchivedUsers } from '../lib/material-transfer'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply')
|
||||
|
||||
console.log('\n离职人员素材部门托管')
|
||||
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
|
||||
|
||||
const result = await custodyMaterialsOfArchivedUsers({ dryRun: !apply })
|
||||
|
||||
console.log('\n===== 汇总 =====')
|
||||
console.log(' 涉及离职账号 ' + result.accounts + ' 个')
|
||||
console.log(' 待托管素材 ' + result.totalMaterials + ' 条')
|
||||
if (apply) console.log(' 已完成托管 ' + result.custodied + ' 条')
|
||||
|
||||
if (result.details.length > 0) {
|
||||
console.log('\n===== 明细(按素材数排序)=====')
|
||||
console.log(' 离职账号 接管部门 素材数')
|
||||
console.log(' ' + '-'.repeat(52))
|
||||
result.details
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.forEach((d) => {
|
||||
console.log(
|
||||
' ' + d.username.padEnd(18) + (d.department ?? '未知').padEnd(20) + String(d.count).padStart(6)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (result.skipped.length > 0) {
|
||||
console.log('\n===== 无法自动托管(需人工指定部门)=====')
|
||||
result.skipped
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.forEach((s) => {
|
||||
console.log(' ' + s.username.padEnd(18) + String(s.count).padStart(5) + ' 条 ← ' + s.reason)
|
||||
})
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
|
||||
}
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error('\n执行失败:', e)
|
||||
await prisma.$disconnect()
|
||||
process.exitCode = 1
|
||||
})
|
||||
330
scripts/diagnose-people-architecture.ts
Normal file
330
scripts/diagnose-people-architecture.ts
Normal file
@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 人员架构地基改造 · 第 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()
|
||||
})
|
||||
150
scripts/probe-report-db.ts
Normal file
150
scripts/probe-report-db.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 上游报表库探查 · 花名册与组织架构相关表
|
||||
*
|
||||
* 纯只读:只查 INFORMATION_SCHEMA 与少量样本行,不写入、不建表、不改结构。
|
||||
*
|
||||
* 凭据一律从环境变量读取,脚本内不含任何账号密码。
|
||||
*
|
||||
* 用法(凭据在命令行传入,不落盘):
|
||||
* REPORT_DB_SERVER=... REPORT_DB_DATABASE=... REPORT_DB_USER=... REPORT_DB_PASSWORD=... \
|
||||
* npx tsx scripts/probe-report-db.ts
|
||||
*/
|
||||
|
||||
import mssql from 'mssql'
|
||||
|
||||
const required = ['REPORT_DB_SERVER', 'REPORT_DB_DATABASE', 'REPORT_DB_USER', 'REPORT_DB_PASSWORD']
|
||||
const missing = required.filter((k) => !process.env[k])
|
||||
if (missing.length > 0) {
|
||||
console.error('缺少环境变量:' + missing.join(', '))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config: mssql.config = {
|
||||
server: process.env.REPORT_DB_SERVER as string,
|
||||
database: process.env.REPORT_DB_DATABASE as string,
|
||||
user: process.env.REPORT_DB_USER as string,
|
||||
password: process.env.REPORT_DB_PASSWORD as string,
|
||||
port: Number(process.env.REPORT_DB_PORT || 1433),
|
||||
options: {
|
||||
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
|
||||
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
|
||||
},
|
||||
connectionTimeout: 20000,
|
||||
requestTimeout: 60000,
|
||||
pool: { max: 2, min: 0, idleTimeoutMillis: 10000 },
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = await mssql.connect(config)
|
||||
try {
|
||||
console.log('已连接:' + config.server + ' / ' + config.database)
|
||||
|
||||
// 一、rpt schema 下与人事、组织相关的表
|
||||
const tables = await pool.request().query(`
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = 'rpt'
|
||||
AND (
|
||||
TABLE_NAME LIKE '%hr[_]%' OR TABLE_NAME LIKE '%employee%' OR
|
||||
TABLE_NAME LIKE '%flower%' OR TABLE_NAME LIKE '%group%' OR
|
||||
TABLE_NAME LIKE '%dept%' OR TABLE_NAME LIKE '%staff%' OR
|
||||
TABLE_NAME LIKE '%org%' OR TABLE_NAME LIKE '%user%'
|
||||
)
|
||||
ORDER BY TABLE_NAME
|
||||
`)
|
||||
console.log('\n===== 一、人事/组织相关表(rpt schema)=====')
|
||||
tables.recordset.forEach((r: any) => console.log(' ' + r.TABLE_SCHEMA + '.' + r.TABLE_NAME))
|
||||
console.log(' 共 ' + tables.recordset.length + ' 张')
|
||||
|
||||
// 二、rpt schema 表总数,用于判断整体规模
|
||||
const total = await pool.request().query(`
|
||||
SELECT COUNT(*) AS c FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'rpt'
|
||||
`)
|
||||
console.log('\n (rpt schema 共有 ' + total.recordset[0].c + ' 张表)')
|
||||
|
||||
// 三、重点表的列结构
|
||||
const focus = [
|
||||
'hr_groups',
|
||||
'hr_employee_dimission',
|
||||
'hr_flowerName_book',
|
||||
'op_OperationGroup_Reflect',
|
||||
]
|
||||
for (const t of focus) {
|
||||
const cols = await pool.request().input('t', t).query(`
|
||||
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH AS LEN, IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'rpt' AND TABLE_NAME = @t
|
||||
ORDER BY ORDINAL_POSITION
|
||||
`)
|
||||
console.log('\n===== rpt.' + t + ' =====')
|
||||
if (cols.recordset.length === 0) {
|
||||
console.log(' ⚠️ 表不存在')
|
||||
continue
|
||||
}
|
||||
const cnt = await pool.request().query('SELECT COUNT(*) AS c FROM rpt.' + t)
|
||||
console.log(' 行数:' + cnt.recordset[0].c)
|
||||
cols.recordset.forEach((c: any) => {
|
||||
const len = c.LEN ? '(' + (c.LEN === -1 ? 'max' : c.LEN) + ')' : ''
|
||||
console.log(' ' + String(c.COLUMN_NAME).padEnd(28) + (c.DATA_TYPE + len).padEnd(18) + (c.IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL'))
|
||||
})
|
||||
}
|
||||
|
||||
// 四、组织树的根节点与层级结构
|
||||
console.log('\n===== 组织树结构分析(hr_groups)=====')
|
||||
const rootStats = await pool.request().query(`
|
||||
SELECT
|
||||
SUM(CASE WHEN parent_id IS NULL THEN 1 ELSE 0 END) AS null_parent,
|
||||
SUM(CASE WHEN parent_id = 0 THEN 1 ELSE 0 END) AS zero_parent,
|
||||
SUM(CASE WHEN parent_id = Id THEN 1 ELSE 0 END) AS self_parent,
|
||||
COUNT(*) AS total
|
||||
FROM rpt.hr_groups
|
||||
`)
|
||||
const rs = rootStats.recordset[0]
|
||||
console.log(' 部门总数 ' + rs.total)
|
||||
console.log(' parent_id 为 NULL ' + rs.null_parent)
|
||||
console.log(' parent_id 为 0 ' + rs.zero_parent)
|
||||
console.log(' parent_id 指向自己 ' + rs.self_parent)
|
||||
|
||||
const roots = await pool.request().query(`
|
||||
SELECT TOP 10 Id, name, parent_id FROM rpt.hr_groups
|
||||
WHERE parent_id IS NULL OR parent_id = 0 OR parent_id = Id
|
||||
ORDER BY Id
|
||||
`)
|
||||
console.log('\n 根节点样本:')
|
||||
roots.recordset.forEach((r: any) =>
|
||||
console.log(' Id=' + String(r.Id).padEnd(12) + 'parent=' + String(r.parent_id ?? 'NULL').padEnd(10) + r.name)
|
||||
)
|
||||
|
||||
// 孤儿节点:父部门在表中不存在
|
||||
const orphans = await pool.request().query(`
|
||||
SELECT COUNT(*) AS c FROM rpt.hr_groups g
|
||||
WHERE g.parent_id IS NOT NULL AND g.parent_id <> 0 AND g.parent_id <> g.Id
|
||||
AND NOT EXISTS (SELECT 1 FROM rpt.hr_groups p WHERE p.Id = g.parent_id)
|
||||
`)
|
||||
console.log('\n 孤儿节点(父部门不存在) ' + orphans.recordset[0].c)
|
||||
|
||||
// 五、在职状态口径
|
||||
console.log('\n===== 在职状态口径 =====')
|
||||
const active = await pool.request().query(`
|
||||
SELECT active, COUNT(*) AS c FROM rpt.hr_employee GROUP BY active
|
||||
`)
|
||||
active.recordset.forEach((r: any) =>
|
||||
console.log(' hr_employee.active = ' + String(r.active) + ' → ' + r.c + ' 人')
|
||||
)
|
||||
const status = await pool.request().query(`
|
||||
SELECT [sys01-employeeStatus] AS s, COUNT(*) AS c
|
||||
FROM rpt.hr_employee_register GROUP BY [sys01-employeeStatus] ORDER BY c DESC
|
||||
`)
|
||||
console.log('')
|
||||
status.recordset.forEach((r: any) =>
|
||||
console.log(' employeeStatus = ' + String(r.s ?? '(空)').padEnd(12) + '→ ' + r.c + ' 人')
|
||||
)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('探查失败:', e instanceof Error ? e.message : e)
|
||||
process.exitCode = 1
|
||||
})
|
||||
127
scripts/sync-organization.ts
Normal file
127
scripts/sync-organization.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 组织架构与在职状态同步
|
||||
*
|
||||
* 从上游 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
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user