From fd7fcd4413f7480b51e8e2b2fa323e4ad0a1fe3c Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 27 Jul 2026 14:04:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E8=80=83=E6=A0=B8?= =?UTF-8?q?=E8=B0=83=E7=A0=94=E9=87=8D=E5=A4=8D=E4=BA=BA=E5=91=98=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...mance-survey-employee-sync-preview.test.ts | 222 ++++++++++++++++++ lib/performance-survey-report-sync.ts | 91 ++++++- 2 files changed, 308 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/performance-survey-employee-sync-preview.test.ts b/lib/__tests__/performance-survey-employee-sync-preview.test.ts index e12c5ba3..54b64373 100644 --- a/lib/__tests__/performance-survey-employee-sync-preview.test.ts +++ b/lib/__tests__/performance-survey-employee-sync-preview.test.ts @@ -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() + }) }) diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index a6393841..d68982fd 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -1698,12 +1698,93 @@ async function findExistingEmployeeProfile(data: Partial) { - 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, +) { + 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, +) { 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)