fix: 合并考核调研重复人员身份
This commit is contained in:
parent
c35ab8c663
commit
fd7fcd4413
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user