From 7b0ee5efb453ebfcb659c46ace80ed7b4cae23c9 Mon Sep 17 00:00:00 2001 From: chenpei Date: Fri, 14 Aug 2026 09:53:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=BA=E5=91=98=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=88=B0=E9=92=89=E9=92=89=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E6=8E=A5=E5=85=A5=E4=B8=8A=E6=B8=B8=E7=BB=84?= =?UTF-8?q?=E7=BB=87=E6=9E=B6=E6=9E=84=E5=8A=A8=E6=80=81=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时 逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随 上游 HR 系统自动更新。 人员身份 - 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一 - users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号 - 移除钉钉登录的同名自动合并,同名冲突一律转人工核对 - 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权 组织架构 - 新增 OrganizationUnit,直接镜像上游 HR 部门树 - 不再从员工部门路径倒推,同名部门不会再被合并 - 上游已删除的部门下线而非物理删除 人员状态 - 新增规范化的 employmentState,取代对混杂原始值做关键词匹配 - 接入上游离职表,离职判定不再依赖中文关键词 - 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者 素材归属 - 归属变更纳入正式转移,记录最初上传者、转移流水与理由 - 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名 - 编辑素材的权限判定统一到 canManageMaterial - 修复任务-详情上传把邮箱误存为作者名的问题 Co-Authored-By: Claude Fable 5 --- .../upload/product-package/init/route.ts | 17 +- .../performance-survey-position-admin.test.ts | 4 +- .../performance-survey-service-policy.test.ts | 30 +- .../product-package-init-route.test.ts | 16 +- lib/dingtalk-login.ts | 172 ++--- lib/employee-directory.ts | 283 ++++++++ lib/material-authorization.ts | 55 ++ lib/material-transfer.ts | 348 +++++++++ lib/material-update.ts | 50 +- lib/organization-sync.ts | 659 ++++++++++++++++++ lib/performance-survey-report-sync.ts | 22 +- lib/performance-survey-service.ts | 24 +- .../migration.sql | 35 + .../migration.sql | 84 +++ .../migration.sql | 121 ++++ prisma/schema.prisma | 77 +- scripts/backfill-email-author-names.ts | 102 +++ scripts/custody-archived-user-materials.ts | 67 ++ scripts/diagnose-people-architecture.ts | 330 +++++++++ scripts/probe-report-db.ts | 150 ++++ scripts/sync-organization.ts | 127 ++++ 21 files changed, 2665 insertions(+), 108 deletions(-) create mode 100644 lib/employee-directory.ts create mode 100644 lib/material-transfer.ts create mode 100644 lib/organization-sync.ts create mode 100644 prisma/migrations/20260813_add_material_custody_department/migration.sql create mode 100644 prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql create mode 100644 prisma/migrations/20260813_add_user_employee_binding/migration.sql create mode 100644 scripts/backfill-email-author-names.ts create mode 100644 scripts/custody-archived-user-materials.ts create mode 100644 scripts/diagnose-people-architecture.ts create mode 100644 scripts/probe-report-db.ts create mode 100644 scripts/sync-organization.ts diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index 4b973bf8..cb29fb38 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -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, diff --git a/lib/__tests__/performance-survey-position-admin.test.ts b/lib/__tests__/performance-survey-position-admin.test.ts index 3405d056..652b4bbe 100644 --- a/lib/__tests__/performance-survey-position-admin.test.ts +++ b/lib/__tests__/performance-survey-position-admin.test.ts @@ -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') }) diff --git a/lib/__tests__/performance-survey-service-policy.test.ts b/lib/__tests__/performance-survey-service-policy.test.ts index fb386b9c..a02f5410 100644 --- a/lib/__tests__/performance-survey-service-policy.test.ts +++ b/lib/__tests__/performance-survey-service-policy.test.ts @@ -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', diff --git a/lib/__tests__/product-package-init-route.test.ts b/lib/__tests__/product-package-init-route.test.ts index 5d8eed9e..32dfe4f1 100644 --- a/lib/__tests__/product-package-init-route.test.ts +++ b/lib/__tests__/product-package-init-route.test.ts @@ -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 () => { diff --git a/lib/dingtalk-login.ts b/lib/dingtalk-login.ts index a121ab83..2d54cf25 100644 --- a/lib/dingtalk-login.ts +++ b/lib/dingtalk-login.ts @@ -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({ diff --git a/lib/employee-directory.ts b/lib/employee-directory.ts new file mode 100644 index 00000000..56d2d5a9 --- /dev/null +++ b/lib/employee-directory.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/lib/material-authorization.ts b/lib/material-authorization.ts index de6f79ef..97d49ca7 100644 --- a/lib/material-authorization.ts +++ b/lib/material-authorization.ts @@ -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 { + 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) } diff --git a/lib/material-transfer.ts b/lib/material-transfer.ts new file mode 100644 index 00000000..571471af --- /dev/null +++ b/lib/material-transfer.ts @@ -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 } }, + params: { + materialId: string + fromUserId: string + toUserId: string + operatorUserId: string + note?: string | null + } +): Promise { + 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 { + 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 { + 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 { + 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 } +} diff --git a/lib/material-update.ts b/lib/material-update.ts index 018f6dfe..c8e4947f 100644 --- a/lib/material-update.ts +++ b/lib/material-update.ts @@ -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({ diff --git a/lib/organization-sync.ts b/lib/organization-sync.ts new file mode 100644 index 00000000..8706a5b8 --- /dev/null +++ b/lib/organization-sync.ts @@ -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() + rows.forEach((r) => byId.set(r.Id, r)) + + const resolved = new Map() + 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() + 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 { + 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 => 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 { + 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() + 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() + 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 { + 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?.() +} diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index 4c0de0fb..98707532 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -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 } diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index 9f05cb0f..48f56f5f 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -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 { ensurePerformanceSurveyNotificationWorkerStarted() ensurePerformanceSurveyDailyEmployeeSyncStarted() + // 组织架构、在职状态与离职联动的每日同步,与人员档案同步一并懒启动 + ensureDailyOrganizationSyncStarted() const sessionUserId = (session?.user as any)?.id if (!sessionUserId) { diff --git a/prisma/migrations/20260813_add_material_custody_department/migration.sql b/prisma/migrations/20260813_add_material_custody_department/migration.sql new file mode 100644 index 00000000..d9527c2c --- /dev/null +++ b/prisma/migrations/20260813_add_material_custody_department/migration.sql @@ -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; diff --git a/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql b/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql new file mode 100644 index 00000000..1904f9ed --- /dev/null +++ b/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql @@ -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; diff --git a/prisma/migrations/20260813_add_user_employee_binding/migration.sql b/prisma/migrations/20260813_add_user_employee_binding/migration.sql new file mode 100644 index 00000000..4ed13686 --- /dev/null +++ b/prisma/migrations/20260813_add_user_employee_binding/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c6d3f88e..4fc22f91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/backfill-email-author-names.ts b/scripts/backfill-email-author-names.ts new file mode 100644 index 00000000..eb3ca711 --- /dev/null +++ b/scripts/backfill-email-author-names.ts @@ -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() + 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() + 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 +}) diff --git a/scripts/custody-archived-user-materials.ts b/scripts/custody-archived-user-materials.ts new file mode 100644 index 00000000..c32a73dd --- /dev/null +++ b/scripts/custody-archived-user-materials.ts @@ -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 +}) diff --git a/scripts/diagnose-people-architecture.ts b/scripts/diagnose-people-architecture.ts new file mode 100644 index 00000000..b90cae1e --- /dev/null +++ b/scripts/diagnose-people-architecture.ts @@ -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() + unmatched.forEach((p) => { + const key = norm(p.departmentName) || '(无部门)' + byDept.set(key, (byDept.get(key) || 0) + 1) + }) + console.log('\n 受影响人员的部门分布(前 15):') + Array.from(byDept.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .forEach(([dept, count]) => console.log(` ${dept.padEnd(30, '.')} ${count}`)) + + if (VERBOSE) { + console.log('\n 明细(--verbose):') + unmatched.slice(0, 50).forEach((p) => { + console.log(` ${norm(p.displayName)} | ${norm(p.departmentPath)}`) + }) + if (unmatched.length > 50) console.log(` ...另有 ${unmatched.length - 50} 人`) + } + } + + // ==================== 三、部门数据一致性 ==================== + section('三、部门数据一致性(双轨字段漂移)') + + const departments = await prisma.department.findMany({ + select: { id: true, name: true, parentId: true, status: true, managerId: true }, + }) + const deptNames = new Set(departments.map((d) => d.name)) + const deptById = new Map(departments.map((d) => [d.id, d.name])) + + const allUsers = await prisma.user.findMany({ + select: { + id: true, + department: true, + departmentId: true, + managedDepartment: true, + archivedAt: true, + }, + }) + const liveUsers = allUsers.filter((u) => !u.archivedAt) + + let noDeptId = 0 + let mismatch = 0 + let orphanDeptName = 0 + let orphanManaged = 0 + + liveUsers.forEach((u) => { + const deptStr = norm(u.department) + if (deptStr && !u.departmentId) noDeptId++ + if (u.departmentId) { + const refName = deptById.get(u.departmentId) + if (refName && deptStr && refName !== deptStr) mismatch++ + } + if (deptStr && !deptNames.has(deptStr)) orphanDeptName++ + const managed = norm(u.managedDepartment) + if (managed && !deptNames.has(managed)) orphanManaged++ + }) + + item('部门表总数', departments.length) + item('有部门字符串但无 departmentId', noDeptId, true) + item('departmentId 与部门字符串不一致', mismatch, true) + item('部门字符串指向不存在的部门', orphanDeptName, true) + item('★ managedDepartment 指向不存在的部门', orphanManaged, true) + console.log('\n → managedDepartment 是无校验字段,却是权限判定的第一优先级输入') + + // ==================== 四、部门权限表 ==================== + section('四、部门权限表(改名漂移存量)') + + const perms = await prisma.departmentMaterialPermission.findMany({ + select: { department: true, linkedToDepartment: true, materialType: true }, + }) + + const orphanPerms = perms.filter((p) => !deptNames.has(norm(p.department))) + const linkedPerms = perms.filter((p) => norm(p.linkedToDepartment)) + const orphanLinks = linkedPerms.filter((p) => !deptNames.has(norm(p.linkedToDepartment))) + + // 多跳关联链:A 关联到 B,而 B 自己也关联到 C + const linkMap = new Map() + linkedPerms.forEach((p) => { + linkMap.set(`${p.department}||${p.materialType}`, norm(p.linkedToDepartment)) + }) + let multiHop = 0 + linkMap.forEach((target, key) => { + const materialType = key.split('||')[1] + if (linkMap.has(`${target}||${materialType}`)) multiHop++ + }) + + item('权限记录总数', perms.length) + item('★ 指向已不存在部门的记录', orphanPerms.length, true) + item('设置了关联部门的记录', linkedPerms.length) + item('关联到已不存在部门的记录', orphanLinks.length, true) + item('多跳关联链(现仅支持一跳)', multiHop, true) + + if (orphanPerms.length > 0) { + const orphanNames = Array.from(new Set(orphanPerms.map((p) => norm(p.department)))) + console.log('\n 失效的部门名(这些权限当前静默不生效):') + orphanNames.slice(0, 20).forEach((n) => console.log(` ${n}`)) + if (orphanNames.length > 20) console.log(` ...另有 ${orphanNames.length - 20} 个`) + } + + // ==================== 五、两套组织对照 ==================== + section('五、两套组织体系对照') + + const profileDeptNames = new Set( + profiles.map((p) => norm(p.departmentName)).filter(Boolean) + ) + const overlap = Array.from(profileDeptNames).filter((n) => deptNames.has(n)) + + item('素材库自维护部门数', deptNames.size) + item('花名册出现的部门数(叶子)', profileDeptNames.size) + item('两边名称能对上的部门数', overlap.length) + item('仅存在于素材库的部门', deptNames.size - overlap.length, true) + item('仅存在于花名册的部门', profileDeptNames.size - overlap.length, true) + + // 部门负责人两条路径的不同步情况 + const managerDepts = departments.filter((d) => d.managerId) + const managedUsers = liveUsers.filter((u) => norm(u.managedDepartment)) + item('通过部门表设了负责人的部门', managerDepts.length) + item('通过 managedDepartment 授权的账号', managedUsers.length) + + console.log('\n → 这两条路径互不联动:经部门表设的负责人,managedDepartment 为空,') + console.log(' 权限会退回其本人部门,他负责的部门反而看不到') + + // ==================== 六、待人工确认清单 ==================== + section('六、待人工确认清单(钉钉登录已不再按姓名自动合并)') + + const bindings = await prisma.userEmployeeBinding.findMany({ + where: { status: 'ACTIVE' }, + select: { userId: true }, + }) + const boundUserIds = new Set(bindings.map((b) => b.userId)) + + const unboundActive = await prisma.user.findMany({ + where: { isActive: true, archivedAt: null }, + select: { + id: true, + username: true, + realName: true, + department: true, + dingtalkUserId: true, + }, + }) + + const pending = unboundActive.filter((u) => !boundUserIds.has(u.id)) + item('未绑定权威人员的活跃账号', pending.length, true) + + if (pending.length > 0) { + // 这些账号一旦有人用同名钉钉身份登录,会被挡下要求人工确认。 + // 提前把花名册里的同名候选找出来,便于管理员核对后补绑。 + const nameIndex = new Map() + profiles.forEach((p) => { + ;[norm(p.displayName), norm((p as any).realName)].forEach((n) => { + if (!n) return + const list = nameIndex.get(n) ?? [] + list.push(`${norm(p.displayName)}|${norm(p.departmentPath) || '无部门'}`) + nameIndex.set(n, list) + }) + }) + + console.log('\n 账号 / 所在部门 / 是否有钉钉ID / 花名册同名候选') + console.log(' ' + '-'.repeat(70)) + pending.forEach((u) => { + const uname = norm(u.username) + const candidates = Array.from( + new Set([...(nameIndex.get(uname) ?? []), ...(nameIndex.get(norm(u.realName)) ?? [])]) + ) + const hint = candidates.length > 0 ? candidates.join(' / ') : '花名册无同名' + console.log( + ` ${uname.padEnd(14)} ${(norm(u.department) || '无部门').padEnd(14)} ` + + `${u.dingtalkUserId ? '有' : '无'} ${hint}` + ) + }) + console.log( + '\n → 有同名候选的:核对无误后补绑,否则这些人首次用钉钉登录会被挡下\n' + + ' → 无钉钉ID的:只能用密码登录,不受影响' + ) + } + + section('盘点结束') + console.log(' 本次执行未写入任何数据。\n') +} + +main() + .catch((error) => { + console.error('\n盘点失败:', error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/scripts/probe-report-db.ts b/scripts/probe-report-db.ts new file mode 100644 index 00000000..2956a1d6 --- /dev/null +++ b/scripts/probe-report-db.ts @@ -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 +}) diff --git a/scripts/sync-organization.ts b/scripts/sync-organization.ts new file mode 100644 index 00000000..cc8d5570 --- /dev/null +++ b/scripts/sync-organization.ts @@ -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 +})