fix: 合并考核调研重复人员身份

This commit is contained in:
chenpei 2026-07-27 14:04:23 +08:00
parent c35ab8c663
commit fd7fcd4413
2 changed files with 308 additions and 5 deletions

View File

@ -6,6 +6,7 @@ import {
comparePerformanceSurveyEmployeeSyncPreview,
previewPerformanceSurveyEmployeeSync,
syncPerformanceSurveyEmployeeByIdentity,
upsertPerformanceSurveyEmployeeProfileForSync,
} from '@/lib/performance-survey-report-sync'
jest.mock('@/lib/prisma', () => ({
@ -398,6 +399,8 @@ describe('performance survey employee sync preview', () => {
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(),
},
@ -545,4 +548,223 @@ describe('performance survey employee sync preview', () => {
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',
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: '怀德',
}),
})
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()
})
})

View File

@ -1698,12 +1698,93 @@ async function findExistingEmployeeProfile(data: Partial<ReturnType<typeof norma
return prisma.performanceSurveyEmployeeProfile.findFirst({ where: { OR: or } })
}
async function upsertEmployeeProfile(data: ReturnType<typeof normalizeEmployeeRow>) {
const existing = await findExistingEmployeeProfile(data)
async function countEmployeeProfileBusinessReferences(tx: any, employeeProfileId: string) {
const counts = await Promise.all([
tx.performanceSurveyAssignment.count({ where: { evaluatorEmployeeId: employeeProfileId } }),
tx.performanceSurveyTarget.count({ where: { employeeProfileId } }),
tx.performanceSurveyResponse.count({ where: { respondentEmployeeId: employeeProfileId } }),
tx.performanceSurveyWorkItem.count({ where: { submitterEmployeeId: employeeProfileId } }),
tx.performanceSurveyWorkItemScore.count({ where: { evaluatorEmployeeId: employeeProfileId } }),
tx.performanceSurveyNotification.count({ where: { recipientEmployeeId: employeeProfileId } }),
tx.performanceSurveyEmployeePositionAssignment.count({ where: { employeeProfileId } }),
tx.performanceSurvey.count({ where: { creatorEmployeeId: employeeProfileId } }),
])
return counts.reduce((total: number, count: number) => total + count, 0)
}
async function resolveEmployeeProfileIdentityInTransaction(
tx: any,
data: ReturnType<typeof normalizeEmployeeRow>,
) {
const strongIdentityConditions = [
data.dingtalkUserId ? { dingtalkUserId: data.dingtalkUserId } : null,
data.jobNumber ? { jobNumber: data.jobNumber } : null,
].filter(Boolean) as any[]
let matches = strongIdentityConditions.length > 0
? await tx.performanceSurveyEmployeeProfile.findMany({
where: { OR: strongIdentityConditions },
orderBy: { createdAt: 'asc' },
})
: []
if (matches.length === 0 && data.dingEmail) {
const emailMatch = await tx.performanceSurveyEmployeeProfile.findFirst({
where: { dingEmail: data.dingEmail },
orderBy: { createdAt: 'asc' },
})
matches = emailMatch ? [emailMatch] : []
}
if (matches.length <= 1) return matches[0] || null
const matchesWithReferences = await Promise.all(
matches.map(async (profile: any) => ({
profile,
businessReferenceCount: await countEmployeeProfileBusinessReferences(tx, profile.id),
})),
)
const referencedMatches = matchesWithReferences.filter((item) => item.businessReferenceCount > 0)
if (referencedMatches.length > 1) {
throw new Error(
`人员身份冲突:钉钉账号与工号分别命中了多条已承载考核数据的档案(${referencedMatches
.map((item) => item.profile.id)
.join(', ')}),为避免错误合并已中止同步`,
)
}
const canonical = referencedMatches[0]?.profile
|| matches.find((profile: any) => (
data.dingtalkUserId && profile.dingtalkUserId === data.dingtalkUserId
))
|| matches.find((profile: any) => (
data.jobNumber && profile.jobNumber === data.jobNumber
))
|| matches[0]
for (const item of matchesWithReferences) {
if (item.profile.id === canonical.id) continue
if (item.businessReferenceCount > 0) {
throw new Error(`人员身份冲突:档案 ${item.profile.id} 已承载考核数据,无法自动合并`)
}
await tx.performanceSurveyEmployeeProfile.delete({
where: { id: item.profile.id },
})
}
return canonical
}
export async function upsertPerformanceSurveyEmployeeProfileForSync(
data: ReturnType<typeof normalizeEmployeeRow>,
) {
const { departmentMemberships, ...profileData } = data
const profileId = existing?.id || randomUUID()
return prisma.$transaction(async (tx) => {
const existing = await resolveEmployeeProfileIdentityInTransaction(tx, data)
const profileId = existing?.id || randomUUID()
const profile = existing
? await tx.performanceSurveyEmployeeProfile.update({
where: { id: existing.id },
@ -1879,7 +1960,7 @@ async function syncPerformanceSurveyEmployeeByIdentityWithContext(
const normalized = await fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity)
if (!normalized) return null
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) return null
return upsertEmployeeProfile(normalized)
return upsertPerformanceSurveyEmployeeProfileForSync(normalized)
}
async function upsertFallbackEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
@ -2248,7 +2329,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
}
try {
await upsertEmployeeProfile(normalized)
await upsertPerformanceSurveyEmployeeProfileForSync(normalized)
upserted += 1
syncedProfileKeys.add(profileKey)
matchedIdentityIndexes.add(index)