diff --git a/lib/__tests__/performance-survey-service-policy.test.ts b/lib/__tests__/performance-survey-service-policy.test.ts index efac1ce..22843f0 100644 --- a/lib/__tests__/performance-survey-service-policy.test.ts +++ b/lib/__tests__/performance-survey-service-policy.test.ts @@ -80,6 +80,7 @@ jest.mock('@/lib/performance-survey-position-config', () => { jest.mock('@/lib/performance-survey-report-sync', () => ({ ensurePerformanceSurveyDailyEmployeeSyncStarted: jest.fn(), ensurePerformanceSurveyEmployeeProfileForUser: jest.fn(), + getActivePerformanceSurveyEmployeeProfileWhere: jest.fn(() => ({ employmentStatus: { not: 'inactive' } })), getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({ id: { not: '__hidden__' } })), previewPerformanceSurveyEmployeeSync: jest.fn(), syncPerformanceSurveyEmployees: jest.fn(), @@ -1223,6 +1224,10 @@ describe('performance survey service policies', () => { ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) .mockResolvedValueOnce([creator, target]) .mockResolvedValueOnce([creator]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{ + id: baseUser.id, + dingtalkUserId: baseUser.dingtalkUserId, + }]) ;(prisma.user.findFirst as jest.Mock).mockResolvedValue({ id: baseUser.id, dingtalkUserId: baseUser.dingtalkUserId, @@ -1448,7 +1453,16 @@ describe('performance survey service policies', () => { ]) }) - test('直属主管模板遇到人员档案缺少主管唯一值时明确拒绝创建', async () => { + test.each([ + ['缺少主组织', []], + [ + '存在多个主组织', + [ + { sourceDeptId: '1085808623' }, + { sourceDeptId: '1085808624' }, + ], + ], + ])('直属主管模板在人员档案缺少主管唯一值且%s时明确拒绝创建', async (_label, primaryMemberships) => { const target = { ...baseEmployee, id: 'employee-target', @@ -1470,6 +1484,8 @@ describe('performance survey service policies', () => { questions: [], scoreBands: [], }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce(primaryMemberships) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([target]) await expect(createPerformanceSurvey(session, { @@ -1478,33 +1494,36 @@ describe('performance survey service policies', () => { assessmentMonth: '2026-07', targetEmployeeIds: [target.id], })).rejects.toEqual(new PerformanceSurveyApiError( - '无法确定缺主管人员的直属主管,请先校准人员档案', + '无法确定缺主管人员所属的主组织,请先校准人员档案', 409, )) expect(prisma.$transaction).not.toHaveBeenCalled() }) - test('直属主管模板不按同部门 M 级人员猜测主管', async () => { + test('直属主管唯一值缺失时按主组织唯一 M 级人员解析主管', async () => { const target = { ...baseEmployee, - id: 'employee-yijiang', - displayName: '忆江', - departmentName: '综合支持部', - departmentPath: '集团A/品牌中心/综合支持部', - dingtalkUserId: 'ding-yijiang', + id: 'employee-xinyu', + displayName: '新雨', + departmentName: '品牌创意A部', + departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意A部', + dingtalkUserId: '1766367441840704', levelType: 'P', - positionLevel: 'P4', + positionLevel: 'P1', managerDingtalkUserId: null, + managerJobNumber: null, } const uniqueManager = { ...baseEmployee, - id: 'employee-wuxie', - displayName: '无邪', + id: 'employee-boyan', + displayName: '伯言', departmentName: target.departmentName, departmentPath: target.departmentPath, - dingtalkUserId: 'ding-wuxie', + dingtalkUserId: '09061617282237', levelType: 'M', - positionLevel: 'M1', + positionLevel: 'M2', + isLeadership: true, + sourcePayload: { materialUserId: 'user-boyan' }, } ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ @@ -1516,14 +1535,19 @@ describe('performance survey service policies', () => { questions: [], scoreBands: [], }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) .mockResolvedValueOnce([target]) .mockResolvedValueOnce([uniqueManager]) - .mockResolvedValueOnce([uniqueManager]) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ - id: 'user-wuxie', + ;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{ + id: 'user-boyan', dingtalkUserId: uniqueManager.dingtalkUserId, }]) + ;(prisma.user.findFirst as jest.Mock).mockResolvedValue({ + id: 'user-boyan', + dingtalkUserId: uniqueManager.dingtalkUserId, + }) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'work_cooperation', creatorEmployeeId: target.id, @@ -1539,6 +1563,197 @@ describe('performance survey service policies', () => { }], })) + await createPerformanceSurvey(session, { + templateCode: 'work_cooperation', + positionGroupCode: 'brand_designer', + assessmentMonth: '2026-07', + targetEmployeeIds: [target.id], + openNow: false, + }) + + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).toHaveBeenCalledWith({ + where: { + employeeProfileId: target.id, + isPrimary: true, + }, + select: { sourceDeptId: true }, + take: 2, + }) + const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] + expect(managerQuery.where.AND).toContainEqual({ + departmentMemberships: { + some: { + sourceDeptId: '1085808623', + isPrimary: true, + }, + }, + }) + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: { + AND: [ + { + archivedAt: null, + status: 'active', + isActive: true, + }, + { id: 'user-boyan' }, + ], + }, + select: { + id: true, + dingtalkUserId: true, + }, + take: 2, + }) + const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data + expect(assignmentRows).toEqual([ + expect.objectContaining({ + evaluatorEmployeeId: uniqueManager.id, + evaluatorDingtalkUserId: uniqueManager.dingtalkUserId, + evaluatorName: uniqueManager.displayName, + reviewerSource: 'direct_manager', + }), + ]) + }) + + test.each([ + ['没有有效账户', [], '被考核人的直属主管尚无可用账户,请先校准人员档案'], + [ + '账户钉钉身份与档案不一致', + [{ id: 'user-manager', dingtalkUserId: 'ding-account-manager' }], + '被考核人的直属主管钉钉身份不一致,请先校准人员档案', + ], + ])('主管唯一值已配置但%s时不回退组织推断', async (_label, activeAccounts, expectedMessage) => { + const target = { + ...baseEmployee, + id: 'employee-target', + displayName: '被考核人', + dingtalkUserId: 'ding-target', + managerDingtalkUserId: 'ding-declared-manager', + } + const manager = { + ...baseEmployee, + id: 'employee-manager', + displayName: '档案直属主管', + dingtalkUserId: 'ding-declared-manager', + sourcePayload: { materialUserId: 'user-manager' }, + } + ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) + ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ + code: 'work_cooperation', + name: '工作配合度满意度调研', + shortName: '工作配合度', + surveyType: 'person_period', + weight: 0.2, + questions: [], + scoreBands: [], + }) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([target]) + .mockResolvedValueOnce([manager]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce(activeAccounts) + + await expect(createPerformanceSurvey(session, { + templateCode: 'work_cooperation', + positionGroupCode: 'brand_designer', + assessmentMonth: '2026-07', + targetEmployeeIds: [target.id], + openNow: false, + })).rejects.toEqual(new PerformanceSurveyApiError(expectedMessage, 409)) + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test.each([ + ['没有M级主管', [], '新雨所属组织内没有可用的M级主管,请先校准人员档案'], + [ + '存在多名M级人员', + [ + { ...baseEmployee, id: 'employee-manager-1', displayName: '主管甲', levelType: 'M', positionLevel: 'M1' }, + { ...baseEmployee, id: 'employee-manager-2', displayName: '主管乙', levelType: 'M', positionLevel: 'M2' }, + ], + '新雨所属组织内存在多名M级人员,请明确配置直属主管', + ], + ])('直属主管唯一值缺失且主组织%s时拒绝创建', async (_label, managers, expectedMessage) => { + const target = { + ...baseEmployee, + id: 'employee-xinyu', + displayName: '新雨', + departmentName: '品牌创意A部', + departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意A部', + dingtalkUserId: '1766367441840704', + levelType: 'P', + positionLevel: 'P1', + managerDingtalkUserId: null, + managerJobNumber: null, + } + ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) + ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ + code: 'work_cooperation', + name: '工作配合度满意度调研', + shortName: '工作配合度', + surveyType: 'person_period', + weight: 0.2, + questions: [], + scoreBands: [], + }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([target]) + .mockResolvedValueOnce(managers) + + await expect(createPerformanceSurvey(session, { + templateCode: 'work_cooperation', + positionGroupCode: 'brand_designer', + assessmentMonth: '2026-07', + targetEmployeeIds: [target.id], + openNow: false, + })).rejects.toEqual(new PerformanceSurveyApiError(expectedMessage, 409)) + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test('直属主管唯一值缺失且主组织唯一 M 级人员无可用账户时拒绝创建', async () => { + const target = { + ...baseEmployee, + id: 'employee-xinyu', + displayName: '新雨', + departmentName: '品牌创意A部', + departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意A部', + dingtalkUserId: '1766367441840704', + levelType: 'P', + positionLevel: 'P1', + managerDingtalkUserId: null, + managerJobNumber: null, + } + const uniqueManager = { + ...baseEmployee, + id: 'employee-boyan', + displayName: '伯言', + departmentName: target.departmentName, + departmentPath: target.departmentPath, + dingtalkUserId: '09061617282237', + levelType: 'M', + positionLevel: 'M2', + isLeadership: true, + sourcePayload: { materialUserId: 'user-boyan' }, + } + ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) + ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ + code: 'work_cooperation', + name: '工作配合度满意度调研', + shortName: '工作配合度', + surveyType: 'person_period', + weight: 0.2, + questions: [], + scoreBands: [], + }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([target]) + .mockResolvedValueOnce([uniqueManager]) + await expect(createPerformanceSurvey(session, { templateCode: 'work_cooperation', positionGroupCode: 'brand_designer', @@ -1546,13 +1761,13 @@ describe('performance survey service policies', () => { targetEmployeeIds: [target.id], openNow: false, })).rejects.toEqual(new PerformanceSurveyApiError( - '无法确定忆江的直属主管,请先校准人员档案', + '新雨的直属主管尚无可用账户,请先校准人员档案', 409, )) expect(prisma.$transaction).not.toHaveBeenCalled() }) - test('人员姓名不再触发固定评分人规则,缺主管仍按档案错误处理', async () => { + test('人员姓名不触发固定评分人规则,缺主管时仍按组织规则处理', async () => { const yuanzhe = { ...baseEmployee, id: 'employee-yuanzhe', @@ -1574,7 +1789,11 @@ describe('performance survey service policies', () => { questions: [], scoreBands: [], }) - ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([yuanzhe]) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([yuanzhe]) + .mockResolvedValueOnce([]) await expect(createPerformanceSurvey(session, { templateCode: 'work_cooperation', @@ -1582,7 +1801,7 @@ describe('performance survey service policies', () => { assessmentMonth: '2026-07', targetEmployeeIds: [yuanzhe.id], })).rejects.toEqual(new PerformanceSurveyApiError( - '无法确定元泽的直属主管,请先校准人员档案', + '元泽所属组织内没有可用的M级主管,请先校准人员档案', 409, )) expect(prisma.$transaction).not.toHaveBeenCalled() @@ -1632,10 +1851,10 @@ describe('performance survey service policies', () => { .mockResolvedValueOnce([manager]) ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockReset() ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ - { id: 'user-1', dingtalkUserId: 'ding-yuanzhe' }, - { id: 'user-manager', dingtalkUserId: 'ding-manager' }, - ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'user-manager', + dingtalkUserId: 'ding-manager', + }]) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'visual_brand_tone', targets: [{ @@ -1725,10 +1944,10 @@ describe('performance survey service policies', () => { .mockResolvedValueOnce([target]) .mockResolvedValueOnce([manager]) ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ - { id: 'user-target', dingtalkUserId }, - { id: 'user-yuanzhe', dingtalkUserId: '27102756692071120919' }, - ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'user-manager', + dingtalkUserId: 'ding-declared-manager', + }]) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'visual_brand_tone', targets: [{ @@ -1812,11 +2031,10 @@ describe('performance survey service policies', () => { .mockResolvedValueOnce([target]) .mockResolvedValueOnce([guanshi]) ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ - { id: 'user-target', dingtalkUserId: 'ding-photographer' }, - { id: 'user-yuanzhe', dingtalkUserId: '27102756692071120919' }, - { id: 'user-guanshi', dingtalkUserId: '17642061233583361' }, - ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'user-guanshi', + dingtalkUserId: '17642061233583361', + }]) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'visual_brand_tone', targets: [{ diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index 1b2c5dd..b96e380 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -64,6 +64,7 @@ import { import { ensurePerformanceSurveyDailyEmployeeSyncStarted, ensurePerformanceSurveyEmployeeProfileForUser, + getActivePerformanceSurveyEmployeeProfileWhere, getPerformanceSurveyAccountProfileWhere, previewPerformanceSurveyEmployeeSync, syncPerformanceSurveyEmployees, @@ -4835,31 +4836,143 @@ async function resolveEmployeeEvaluator(employee: any) { } } +async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager: any) { + const sourcePayload = manager?.sourcePayload && typeof manager.sourcePayload === 'object' + ? manager.sourcePayload as Record + : {} + const linkedUserId = clean(sourcePayload.materialUserId) || clean(sourcePayload.userId) + const managerDingtalkIdentity = clean(manager?.dingtalkUserId) + const managerEmail = clean(manager?.dingEmail) + const accountIdentityWhere = linkedUserId + ? { id: linkedUserId } + : managerDingtalkIdentity + ? { dingtalkUserId: managerDingtalkIdentity } + : managerEmail + ? { email: { equals: managerEmail, mode: 'insensitive' as const } } + : null + if (!accountIdentityWhere) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少可用登录身份,请先校准人员档案`, 409) + } + + const activeAccounts = await prisma.user.findMany({ + where: { + AND: [ + { + archivedAt: null, + status: 'active', + isActive: true, + }, + accountIdentityWhere, + ], + }, + select: { + id: true, + dingtalkUserId: true, + }, + take: 2, + }) + if (activeAccounts.length !== 1) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管尚无可用账户,请先校准人员档案`, 409) + } + + const accountDingtalkUserId = clean(activeAccounts[0].dingtalkUserId) + if ( + managerDingtalkIdentity + && managerDingtalkIdentity !== accountDingtalkUserId + ) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管钉钉身份不一致,请先校准人员档案`, 409) + } + const resolvedDingtalkUserId = managerDingtalkIdentity || accountDingtalkUserId + if (!resolvedDingtalkUserId) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少钉钉唯一值,请先校准人员档案`, 409) + } + return { + ...manager, + dingtalkUserId: resolvedDingtalkUserId, + } +} + async function resolveDirectManagerForWorkflow(targetEmployee: any) { - const accountWhere = await getPerformanceSurveyAccountProfileWhere() const managerDingtalkUserId = clean(targetEmployee?.managerDingtalkUserId) const managerJobNumber = clean(targetEmployee?.managerJobNumber) - if (!managerDingtalkUserId && !managerJobNumber) { - throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}的直属主管,请先校准人员档案`, 409) + + // 上游已提供主管唯一值时只做精确匹配;值失效或冲突不能用组织推断掩盖。 + if (managerDingtalkUserId || managerJobNumber) { + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ + where: { + AND: [ + getActivePerformanceSurveyEmployeeProfileWhere(), + { + OR: [ + ...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []), + ...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []), + ], + }, + ], + }, + take: 2, + }) + if (managers.length !== 1) { + throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409) + } + return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } + + // 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 M 级人员。 + const targetEmployeeId = clean(targetEmployee?.id) + if (!targetEmployeeId) { + throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) + } + + const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ + where: { + employeeProfileId: targetEmployeeId, + isPrimary: true, + }, + select: { sourceDeptId: true }, + take: 2, + }) + const primarySourceDeptIds = Array.from(new Set( + primaryMemberships + .map((membership) => clean(membership.sourceDeptId)) + .filter((sourceDeptId): sourceDeptId is string => Boolean(sourceDeptId)), + )) + if (primarySourceDeptIds.length !== 1) { + throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) + } + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ - accountWhere, + getActivePerformanceSurveyEmployeeProfileWhere(), + { id: { not: targetEmployeeId } }, { OR: [ - ...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []), - ...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []), + { isLeadership: true }, + { levelType: { equals: 'M', mode: 'insensitive' } }, + { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, ], }, + { + departmentMemberships: { + some: { + sourceDeptId: primarySourceDeptIds[0], + isPrimary: true, + }, + }, + }, ], }, take: 2, }) - if (managers.length !== 1) { - throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409) + if (managers.length === 0) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409) } - return managers[0] + if (managers.length > 1) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内存在多名M级人员,请明确配置直属主管`, 409) + } + + return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } async function resolveConfiguredWorkflowEvaluators(input: {