gongxiangp2/lib/__tests__/performance-survey-employee-sync-preview.test.ts

1102 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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<string, unknown>()
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<string, unknown>()
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<string, unknown>()
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<string, unknown>()
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<string, unknown>()
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()
})
})