fix: 恢复直属主管组织解析规则

This commit is contained in:
chenpei 2026-07-31 14:07:17 +08:00
parent 39f99e35cd
commit 1ef0c81429
2 changed files with 373 additions and 42 deletions

View File

@ -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: [{

View File

@ -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<string, unknown>
: {}
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: {