/** @jest-environment node */ import { prisma } from '@/lib/prisma' import mssql from 'mssql' import { comparePerformanceSurveyEmployeeSyncPreview, previewPerformanceSurveyEmployeeSync, syncPerformanceSurveyEmployeeByIdentity, syncPerformanceSurveyEmployees, upsertPerformanceSurveyEmployeeProfileForSync, } from '@/lib/performance-survey-report-sync' jest.mock('@/lib/prisma', () => ({ prisma: { $transaction: jest.fn(), user: { findMany: jest.fn(), count: jest.fn(), }, performanceSurveyEmployeeProfile: { findMany: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, performanceSurveyDepartmentProfile: { upsert: jest.fn(), }, }, })) jest.mock('mssql', () => ({ __esModule: true, default: { connect: jest.fn(), Int: 'Int', NVarChar: jest.fn((length: number) => `NVarChar(${length})`), }, })) describe('performance survey employee sync preview', () => { const reportEnvKeys = [ 'REPORT_DB_SERVER', 'REPORT_DB_DATABASE', 'REPORT_DB_USER', 'REPORT_DB_PASSWORD', ] as const const originalReportEnv = Object.fromEntries( reportEnvKeys.map((key) => [key, process.env[key]]), ) beforeEach(() => { jest.clearAllMocks() for (const key of reportEnvKeys) delete process.env[key] }) afterAll(() => { for (const key of reportEnvKeys) { const value = originalReportEnv[key] if (value === undefined) delete process.env[key] else process.env[key] = value } }) test('只读预检会区分新增、业务更新、无变化和素材库兜底', () => { const identities = [ { userId: 'user-1', dingtalkUserId: 'ding-1', email: 'one@example.com', username: '甲' }, { userId: 'user-2', dingtalkUserId: 'ding-2', email: 'two@example.com', username: '乙' }, { userId: 'user-3', dingtalkUserId: 'ding-3', email: 'three@example.com', username: '丙' }, { userId: 'user-4', dingtalkUserId: 'ding-4', email: 'four@example.com', username: '丁' }, ] const rows = [ { dingtalkUserId: 'ding-1', dingEmail: 'one@example.com', contactName: '甲', departmentName: '品牌创意A部', }, { dingtalkUserId: 'ding-2', dingEmail: 'two@example.com', contactName: '乙', departmentName: '品牌创意B部', }, { dingtalkUserId: 'ding-3', dingEmail: 'three@example.com', contactName: '丙', departmentName: '综合支持部', }, ] const existingProfiles = [ { id: 'profile-1', dingtalkUserId: 'ding-1', dingEmail: 'one@example.com', jobNumber: null, flowerName: null, realName: '甲', displayName: '甲', departmentName: '品牌创意A部', departmentPath: '品牌创意A部', positionTitle: null, positionLevel: null, levelType: null, isLeadership: false, managerDingtalkUserId: null, managerJobNumber: null, managerName: null, employmentStatus: null, workPlace: null, sourcePayload: { source: 'JSReportReplica.rpt', materialUserId: 'user-1', materialUserEmail: 'one@example.com', }, }, { id: 'profile-2', dingtalkUserId: 'ding-2', dingEmail: 'two@example.com', jobNumber: null, flowerName: null, realName: '乙', displayName: '乙', departmentName: '旧部门', departmentPath: '旧部门', positionTitle: null, positionLevel: null, levelType: null, isLeadership: false, managerDingtalkUserId: null, managerJobNumber: null, managerName: null, employmentStatus: null, workPlace: null, sourcePayload: { source: 'JSReportReplica.rpt', materialUserId: 'user-2', materialUserEmail: 'two@example.com', }, }, { id: 'profile-4', dingtalkUserId: 'ding-4', dingEmail: 'four@example.com', jobNumber: null, flowerName: null, realName: '丁', displayName: '丁', departmentName: null, departmentPath: null, positionTitle: null, positionLevel: null, levelType: null, isLeadership: false, managerDingtalkUserId: null, managerJobNumber: null, managerName: null, employmentStatus: null, workPlace: null, sourcePayload: { source: 'material_user_fallback', materialUserId: 'user-4', materialUserEmail: 'four@example.com', }, }, ] const result = comparePerformanceSurveyEmployeeSyncPreview({ identities, rows, existingProfiles, }) expect(result).toMatchObject({ fetched: 3, matchedAccounts: 3, created: 1, updated: 1, unchanged: 2, fallback: 1, skipped: 0, plannedProfileWrites: 4, }) expect(result.changedFields).toEqual(expect.arrayContaining([ { field: 'departmentName', count: 1 }, { field: 'departmentPath', count: 1 }, ])) }) test('只读预检会把新增的兼任部门识别为业务更新', () => { const result = comparePerformanceSurveyEmployeeSyncPreview({ identities: [{ userId: 'user-qingyun', dingtalkUserId: '09053022115891', email: 'qingyun@example.com', username: '清云', }], rows: [{ dingtalkUserId: '09053022115891', dingEmail: 'qingyun@example.com', contactName: '清云', registerMainDeptId: '994456259', registerDeptIds: '1085358755|994456259', groupDepartmentName: '营销策划中心', groupDepartmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心', resolvedDepartments: [ { sourceDeptId: '994456259', departmentName: '营销策划中心', departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心', isPrimary: true, }, { sourceDeptId: '1085358755', departmentName: '品牌创意B部', departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意B部', isPrimary: false, }, ], }], existingProfiles: [{ id: 'profile-qingyun', dingtalkUserId: '09053022115891', dingEmail: 'qingyun@example.com', displayName: '清云', departmentName: '营销策划中心', departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心', isLeadership: false, isDingtalkAdmin: false, isDingtalkBoss: false, isDingtalkSenior: false, sourcePayload: { source: 'JSReportReplica.rpt', materialUserId: 'user-qingyun', materialUserEmail: 'qingyun@example.com', }, departmentMemberships: [{ sourceDeptId: '994456259', departmentName: '营销策划中心', departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心', isPrimary: true, }], }], }) expect(result).toMatchObject({ created: 0, updated: 1, unchanged: 0, }) expect(result.changedFields).toContainEqual({ field: 'departmentMemberships', count: 1, }) }) test('只读预检会纳入没有素材库账号的在职花名册人员', () => { const result = comparePerformanceSurveyEmployeeSyncPreview({ identities: [], rows: [{ dingtalkUserId: 'ding-xiangzhang', jobNumber: 'JS06002901', contactName: '香樟', realName: '马震宇', departmentName: '品牌设计部', positionTitle: '品牌设计师', positionLevel: 'P6', employmentStatus: '试用', }], existingProfiles: [], }) expect(result).toMatchObject({ fetched: 1, matchedAccounts: 0, created: 1, fallback: 0, plannedProfileWrites: 1, }) }) test('未配置上游时只返回兜底预检,且不会写入本地数据库', async () => { ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: 'user-1', email: 'one@example.com', username: '甲', realName: null, dingtalkUserId: 'ding-1', department: '综合支持部', }]) ;(prisma.user.count as jest.Mock).mockResolvedValue(1) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) const result = await previewPerformanceSurveyEmployeeSync({ limit: 3000 }) expect(result).toMatchObject({ mode: 'preview', dryRun: true, source: { type: 'material_user_fallback', upstreamQueried: false, }, now: { scannedAccounts: 1, databaseWritten: false, }, counts: { created: 1, fallback: 1, deactivated: null, }, deactivation: { status: 'unsupported', count: null, }, }) expect(prisma.performanceSurveyEmployeeProfile.create).not.toHaveBeenCalled() expect(prisma.performanceSurveyEmployeeProfile.update).not.toHaveBeenCalled() expect(prisma.$transaction).not.toHaveBeenCalled() }) test('管理员主动预检可只读访问上游,仍不会写入本地数据库', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' process.env.REPORT_DB_USER = 'readonly' process.env.REPORT_DB_PASSWORD = 'secret' ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: 'user-1', email: 'one@example.com', username: '甲', realName: null, dingtalkUserId: 'ding-1', department: '综合支持部', }]) ;(prisma.user.count as jest.Mock).mockResolvedValue(1) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) const pool = { request: jest.fn(() => { const inputs = new Map() const request: { input: jest.Mock query: jest.Mock } = { input: jest.fn(), query: jest.fn(), } request.input.mockImplementation((name: string, ...args: unknown[]) => { inputs.set(name, args[args.length - 1]) return request }) request.query.mockImplementation(async (query: string) => { if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { const table = inputs.get('table') return { recordset: table === 'hr_employee' ? ['userid', 'email', 'job_number', 'name', 'title', 'active'] .map((COLUMN_NAME) => ({ COLUMN_NAME })) : [], } } if (query.includes('[rpt].[hr_employee]')) { return { recordset: [{ dingtalkUserId: 'ding-1', dingEmail: 'one@example.com', jobNumber: 'JS001', contactName: '甲', contactTitle: '设计师', }], } } throw new Error(`未预期的查询:${query}`) }) return request }), close: jest.fn(), } ;(mssql.connect as jest.Mock).mockResolvedValue(pool) const result = await previewPerformanceSurveyEmployeeSync({ limit: 3000 }) expect(result).toMatchObject({ mode: 'preview', dryRun: true, source: { type: 'report_database', access: 'read_only', trigger: 'admin_manual_preview', upstreamQueried: true, }, now: { databaseWritten: false, }, counts: { fetched: 1, matchedAccounts: 1, created: 1, fallback: 0, deactivated: null, }, }) expect(mssql.connect).toHaveBeenCalledTimes(1) expect(pool.close).toHaveBeenCalledTimes(1) expect(prisma.performanceSurveyEmployeeProfile.create).not.toHaveBeenCalled() expect(prisma.performanceSurveyEmployeeProfile.update).not.toHaveBeenCalled() expect(prisma.$transaction).not.toHaveBeenCalled() }) test('整批正式同步会写入没有素材库账号的在职花名册人员', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' process.env.REPORT_DB_USER = 'readonly' process.env.REPORT_DB_PASSWORD = 'secret' ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-xiangzhang' }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ performanceSurveyEmployeeProfile: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: profileCreate, update: jest.fn(), }, performanceSurveyEmployeeDepartmentMembership: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn().mockResolvedValue({ count: 0 }), }, })) const pool = { request: jest.fn(() => { const inputs = new Map() const request: { input: jest.Mock query: jest.Mock } = { input: jest.fn(), query: jest.fn(), } request.input.mockImplementation((name: string, ...args: unknown[]) => { inputs.set(name, args[args.length - 1]) return request }) request.query.mockImplementation(async (query: string) => { if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { const table = inputs.get('table') return { recordset: table === 'hr_employee' ? ['userid', 'email', 'job_number', 'name', 'title', 'active'] .map((COLUMN_NAME) => ({ COLUMN_NAME })) : [], } } if (query.includes('[rpt].[hr_employee]')) { return { recordset: [{ dingtalkUserId: 'ding-xiangzhang', jobNumber: 'JS06002901', contactName: '香樟', contactTitle: '品牌设计师', }], } } throw new Error(`未预期的查询:${query}`) }) return request }), close: jest.fn(), } ;(mssql.connect as jest.Mock).mockResolvedValue(pool) await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({ scannedAccounts: 0, scannedProfiles: 1, upserted: 1, fallback: 0, failed: 0, }) expect(profileCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ dingtalkUserId: 'ding-xiangzhang', jobNumber: 'JS06002901', displayName: '香樟', positionTitle: '品牌设计师', }), }) expect(pool.close).toHaveBeenCalledTimes(1) }) test('整批正式同步会纳入只存在于花名表的新员工', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' process.env.REPORT_DB_USER = 'readonly' process.env.REPORT_DB_PASSWORD = 'secret' ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: 'material-user-shuqi', email: 'shuqi@example.com', username: '叔齐', realName: '叔齐', dingtalkUserId: '17871916566057990', department: '朴美运营', }]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-shuqi' }) const membershipCreateMany = jest.fn().mockResolvedValue({ count: 1 }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ performanceSurveyEmployeeProfile: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: profileCreate, update: jest.fn(), }, performanceSurveyEmployeeDepartmentMembership: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: membershipCreateMany, }, })) const pool = { request: jest.fn(() => { const inputs = new Map() const request: { input: jest.Mock; query: jest.Mock } = { input: jest.fn(), query: jest.fn(), } request.input.mockImplementation((name: string, ...args: unknown[]) => { inputs.set(name, args[args.length - 1]) return request }) request.query.mockImplementation(async (query: string) => { if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { const table = inputs.get('table') const columns = table === 'hr_employee' ? ['userid', 'job_number', 'name', 'active'] : table === 'hr_flowerName_book' ? ['flowerNameId', 'jobNum', 'name', 'flowerName', 'dept', 'deptId', 'job', 'jobLevel', 'empStatus', 'status', 'leaveDate'] : [] return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) } } if (query.includes(';WITH input_depts')) { return { recordset: [{ dept_id: 1049297345, path: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', }], } } if (query.includes('[rpt].[hr_employee]')) return { recordset: [] } if (query.includes('[rpt].[hr_flowerName_book]')) { expect(query).toContain('[flowerNameId]') return { recordset: [{ dingtalkUserId: '17871916566057990', registerJobNumber: 'JS26082002', flowerBookName: '黄鸿翔', flowerBookFlowerName: '叔齐', flowerBookDept: '["运营中心G","朴美项目部","运营一组"]', flowerBookDeptId: '[911666246,1007911425,1049297345]', flowerBookJob: '运营', flowerBookJobLevel: 'P4', flowerBookEmployeeStatus: '试用', }], } } throw new Error(`未预期的查询:${query}`) }) return request }), close: jest.fn(), } ;(mssql.connect as jest.Mock).mockResolvedValue(pool) await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({ scannedAccounts: 1, scannedProfiles: 1, upserted: 1, fallback: 0, failed: 0, }) expect(profileCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ dingtalkUserId: '17871916566057990', jobNumber: 'JS26082002', realName: '黄鸿翔', flowerName: '叔齐', displayName: '叔齐', departmentName: '运营一组', departmentPath: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', positionTitle: '运营', positionLevel: 'P4', sourcePayload: expect.objectContaining({ materialUserId: 'material-user-shuqi', }), }), }) expect(membershipCreateMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ employeeProfileId: 'profile-shuqi', sourceDeptId: '1049297345', isPrimary: true, })], }) }) test('单人刷新会按钉钉 ID 精确补查花名表独有员工', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' process.env.REPORT_DB_USER = 'readonly' process.env.REPORT_DB_PASSWORD = 'secret' ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-shuqi' }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ performanceSurveyEmployeeProfile: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: profileCreate, update: jest.fn(), }, performanceSurveyEmployeeDepartmentMembership: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn().mockResolvedValue({ count: 1 }), }, })) const pool = { request: jest.fn(() => { const inputs = new Map() const request: { input: jest.Mock; query: jest.Mock } = { input: jest.fn(), query: jest.fn(), } request.input.mockImplementation((name: string, ...args: unknown[]) => { inputs.set(name, args[args.length - 1]) return request }) request.query.mockImplementation(async (query: string) => { if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { const table = inputs.get('table') const columns = table === 'hr_employee' ? ['userid', 'job_number', 'name', 'active'] : table === 'hr_flowerName_book' ? ['flowerNameId', 'jobNum', 'name', 'flowerName', 'dept', 'deptId', 'job', 'jobLevel', 'empStatus', 'status', 'leaveDate'] : [] return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) } } if (query.includes(';WITH input_depts')) { return { recordset: [{ dept_id: 1049297345, path: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', }], } } if (query.includes('[rpt].[hr_employee] e')) return { recordset: [] } if (query.includes('[rpt].[hr_flowerName_book] f')) { expect(query).toContain('[flowerNameId] = @dingtalkUserId') expect(inputs.get('dingtalkUserId')).toBe('17871916566057990') return { recordset: [{ dingtalkUserId: '17871916566057990', registerJobNumber: 'JS26082002', flowerBookName: '黄鸿翔', flowerBookFlowerName: '叔齐', flowerBookDept: '["运营中心G","朴美项目部","运营一组"]', flowerBookDeptId: '[911666246,1007911425,1049297345]', flowerBookJob: '运营', flowerBookJobLevel: 'P4', }], } } throw new Error(`未预期的查询:${query}`) }) return request }), close: jest.fn(), } ;(mssql.connect as jest.Mock).mockResolvedValue(pool) await syncPerformanceSurveyEmployeeByIdentity({ userId: 'material-user-shuqi', dingtalkUserId: '17871916566057990', email: 'shuqi@example.com', username: '叔齐', }) expect(profileCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ dingtalkUserId: '17871916566057990', realName: '黄鸿翔', flowerName: '叔齐', departmentPath: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', sourcePayload: expect.objectContaining({ materialUserId: 'material-user-shuqi', }), }), }) }) test('单人正式同步会保存全部所属部门和钉钉身份字段,且不映射为系统权限', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' process.env.REPORT_DB_USER = 'readonly' process.env.REPORT_DB_PASSWORD = 'secret' ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-qingyun' }) const membershipDeleteMany = jest.fn().mockResolvedValue({ count: 0 }) const membershipCreateMany = jest.fn().mockResolvedValue({ count: 2 }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ performanceSurveyEmployeeProfile: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: profileCreate, update: jest.fn(), }, performanceSurveyEmployeeDepartmentMembership: { deleteMany: membershipDeleteMany, createMany: membershipCreateMany, }, })) const pool = { request: jest.fn(() => { const inputs = new Map() const request: { input: jest.Mock query: jest.Mock } = { input: jest.fn(), query: jest.fn(), } request.input.mockImplementation((name: string, ...args: unknown[]) => { inputs.set(name, args[args.length - 1]) return request }) request.query.mockImplementation(async (query: string) => { if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { const table = inputs.get('table') const columns = table === 'hr_employee' ? [ 'userid', 'email', 'job_number', 'name', 'title', 'active', 'dept_id_list', 'admin', 'boss', 'senior', ] : table === 'hr_employee_register' ? [ 'userid', 'sys00-name', 'sys00-flowerName', 'sys00-jobNumber', 'sys00-position', 'sys01-positionLevel', 'sys00-deptIds', 'sys00-mainDeptId', ] : table === 'hr_flowerName_book' ? ['jobNum', 'flowerName', 'deptId'] : [] return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) } } if (query.includes(';WITH input_depts')) { return { recordset: [ { dept_id: 994456259, path: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心', }, { dept_id: 1085358755, path: '江苏简尚品牌管理有限公司/品牌中心/品牌创意B部', }, ], } } if (query.includes('[rpt].[hr_employee] e')) { expect(query).toContain('AS "isDingtalkAdmin"') expect(query).toContain('AS "isDingtalkBoss"') expect(query).toContain('AS "isDingtalkSenior"') return { recordset: [{ dingtalkUserId: '09053022115891', dingEmail: 'qingyun@example.com', jobNumber: 'JS15030101', contactName: '清云', contactTitle: '营销策划主管', employeeDeptIdList: '994456259,1085358755', registerName: '清云', flowerName: '清云', registerJobNumber: 'JS15030101', positionTitle: '营销策划主管', positionLevel: 'M1', registerDeptIds: '1085358755|994456259', registerMainDeptId: '994456259', flowerBookFlowerName: '清云', flowerBookDeptId: '[994121400,994456259]', isDingtalkAdmin: true, isDingtalkBoss: false, isDingtalkSenior: true, }], } } throw new Error(`未预期的查询:${query}`) }) return request }), close: jest.fn(), } ;(mssql.connect as jest.Mock).mockResolvedValue(pool) await syncPerformanceSurveyEmployeeByIdentity({ userId: 'material-user-qingyun', dingtalkUserId: '09053022115891', email: 'qingyun@example.com', username: '清云', }) expect(profileCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ displayName: '清云', departmentName: '营销策划中心', departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心', positionLevel: 'M1', levelType: 'M', isLeadership: true, isDingtalkAdmin: true, isDingtalkBoss: false, isDingtalkSenior: true, }), }) expect(profileCreate.mock.calls[0][0].data).not.toHaveProperty('departmentMemberships') expect(membershipDeleteMany).toHaveBeenCalledWith({ where: { employeeProfileId: 'profile-qingyun' }, }) expect(membershipCreateMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ employeeProfileId: 'profile-qingyun', sourceDeptId: '994456259', departmentName: '营销策划中心', isPrimary: true, }), expect.objectContaining({ employeeProfileId: 'profile-qingyun', sourceDeptId: '1085358755', departmentName: '品牌创意B部', isPrimary: false, }), ]), }) expect(membershipCreateMany.mock.calls[0][0].data).toHaveLength(2) expect(pool.close).toHaveBeenCalledTimes(1) }) test('正式同步会合并钉钉 ID 与工号拆分出的空档案,再保存完整身份', async () => { const dingtalkProfile = { id: 'profile-dingtalk', dingtalkUserId: 'ding-huai-de', jobNumber: null, dingEmail: 'huai.de@example.com', sourcePayload: { source: 'JSReportReplica.rpt', materialUserId: 'material-user-huai-de', materialUserEmail: 'huai.de@example.com', }, createdAt: new Date('2026-07-01T00:00:00.000Z'), } const jobNumberProfile = { id: 'profile-job-number', dingtalkUserId: null, jobNumber: 'JS26070601', dingEmail: null, createdAt: new Date('2026-07-02T00:00:00.000Z'), } const profileDelete = jest.fn().mockResolvedValue(jobNumberProfile) const profileUpdate = jest.fn().mockResolvedValue({ id: dingtalkProfile.id, dingtalkUserId: 'ding-huai-de', jobNumber: 'JS26070601', displayName: '怀德', }) const zeroCount = jest.fn().mockResolvedValue(0) const membershipDeleteMany = jest.fn().mockResolvedValue({ count: 0 }) const membershipCreateMany = jest.fn().mockResolvedValue({ count: 1 }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ performanceSurveyEmployeeProfile: { findMany: jest.fn().mockResolvedValue([dingtalkProfile, jobNumberProfile]), findFirst: jest.fn(), create: jest.fn(), update: profileUpdate, delete: profileDelete, }, performanceSurveyAssignment: { count: zeroCount }, performanceSurveyTarget: { count: zeroCount }, performanceSurveyResponse: { count: zeroCount }, performanceSurveyWorkItem: { count: zeroCount }, performanceSurveyWorkItemScore: { count: zeroCount }, performanceSurveyNotification: { count: zeroCount }, performanceSurveyEmployeePositionAssignment: { count: zeroCount }, performanceSurvey: { count: zeroCount }, performanceSurveyEmployeeDepartmentMembership: { deleteMany: membershipDeleteMany, createMany: membershipCreateMany, }, })) await upsertPerformanceSurveyEmployeeProfileForSync({ dingtalkUserId: 'ding-huai-de', dingEmail: 'huai.de@example.com', jobNumber: 'JS26070601', flowerName: '怀德', realName: '吾正鹏', displayName: '怀德', departmentName: '京东组', departmentPath: '运营中心G/运营二部(HZ)/京东组', positionTitle: '运营', positionLevel: 'P4', levelType: 'P', isLeadership: false, isDingtalkAdmin: false, isDingtalkBoss: false, isDingtalkSenior: false, managerDingtalkUserId: null, managerJobNumber: null, managerName: null, employmentStatus: null, workPlace: null, sourcePayload: { source: 'JSReportReplica.rpt', syncedFields: { hasDingtalkUserId: true, hasDingEmail: true, jobNumber: 'JS26070601', displayName: '怀德', departmentName: '京东组', departmentPath: '运营中心G/运营二部(HZ)/京东组', primaryDeptId: '1055268872', departmentIds: ['1055268872'], employeeDeptIdList: '1055268872', registerDeptIds: '1055268872', registerMainDeptId: '1055268872', flowerBookDeptId: '1055268872', positionLevel: 'P4', levelType: 'P', flowerBookRole: null, isDingtalkAdmin: false, isDingtalkBoss: false, isDingtalkSenior: false, }, }, syncedAt: new Date(), departmentMemberships: [{ sourceDeptId: '1055268872', departmentName: '京东组', departmentPath: '运营中心G/运营二部(HZ)/京东组', isPrimary: true, }], }) expect(profileDelete).toHaveBeenCalledWith({ where: { id: 'profile-job-number' }, }) expect(profileUpdate).toHaveBeenCalledWith({ where: { id: 'profile-dingtalk' }, data: expect.objectContaining({ dingtalkUserId: 'ding-huai-de', jobNumber: 'JS26070601', flowerName: '怀德', realName: '吾正鹏', displayName: '怀德', sourcePayload: expect.objectContaining({ source: 'JSReportReplica.rpt', materialUserId: 'material-user-huai-de', materialUserEmail: 'huai.de@example.com', }), }), }) expect(membershipDeleteMany).toHaveBeenCalledWith({ where: { employeeProfileId: 'profile-dingtalk' }, }) expect(membershipCreateMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ employeeProfileId: 'profile-dingtalk', sourceDeptId: '1055268872', isPrimary: true, })], }) }) test('拆分档案都已承载考核数据时会中止自动合并,避免误改历史记录', async () => { const profileDelete = jest.fn() const profileUpdate = jest.fn() const zeroCount = jest.fn().mockResolvedValue(0) const referencedCount = jest.fn().mockResolvedValue(1) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ performanceSurveyEmployeeProfile: { findMany: jest.fn().mockResolvedValue([ { id: 'profile-dingtalk', dingtalkUserId: 'ding-conflict', jobNumber: null, createdAt: new Date('2026-07-01T00:00:00.000Z'), }, { id: 'profile-job-number', dingtalkUserId: null, jobNumber: 'JS-CONFLICT', createdAt: new Date('2026-07-02T00:00:00.000Z'), }, ]), findFirst: jest.fn(), create: jest.fn(), update: profileUpdate, delete: profileDelete, }, performanceSurveyAssignment: { count: referencedCount }, performanceSurveyTarget: { count: zeroCount }, performanceSurveyResponse: { count: zeroCount }, performanceSurveyWorkItem: { count: zeroCount }, performanceSurveyWorkItemScore: { count: zeroCount }, performanceSurveyNotification: { count: zeroCount }, performanceSurveyEmployeePositionAssignment: { count: zeroCount }, performanceSurvey: { count: zeroCount }, performanceSurveyEmployeeDepartmentMembership: { deleteMany: jest.fn(), createMany: jest.fn(), }, })) await expect(upsertPerformanceSurveyEmployeeProfileForSync({ dingtalkUserId: 'ding-conflict', dingEmail: null, jobNumber: 'JS-CONFLICT', flowerName: '冲突人员', realName: '冲突人员', displayName: '冲突人员', departmentName: null, departmentPath: null, positionTitle: null, positionLevel: null, levelType: null, isLeadership: false, isDingtalkAdmin: false, isDingtalkBoss: false, isDingtalkSenior: false, managerDingtalkUserId: null, managerJobNumber: null, managerName: null, employmentStatus: null, workPlace: null, sourcePayload: { source: 'JSReportReplica.rpt', syncedFields: { hasDingtalkUserId: true, hasDingEmail: false, jobNumber: 'JS-CONFLICT', displayName: '冲突人员', departmentName: null, departmentPath: null, primaryDeptId: null, departmentIds: [], employeeDeptIdList: null, registerDeptIds: null, registerMainDeptId: null, flowerBookDeptId: null, positionLevel: null, levelType: null, flowerBookRole: null, isDingtalkAdmin: false, isDingtalkBoss: false, isDingtalkSenior: false, }, }, syncedAt: new Date(), departmentMemberships: [], })).rejects.toThrow('多条已承载考核数据的档案') expect(profileDelete).not.toHaveBeenCalled() expect(profileUpdate).not.toHaveBeenCalled() }) })