gongxiangp2/lib/__tests__/performance-survey-service-policy.test.ts

2322 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import type { Session } from 'next-auth'
import {
PerformanceSurveyApiError,
cancelPerformanceSurvey,
createPerformanceSurvey,
getPerformanceSurveyDetail,
getPerformanceSurveyDashboard,
getPerformanceSurveyDashboardDetail,
getPerformanceSurveyMe,
getPerformanceSurveyResults,
listPerformanceSurveys,
previewPerformanceSurveyEmployeeProfiles,
removePerformanceSurveyPublicDashboardAssessment,
searchPerformanceSurveyEmployees,
submitPerformanceSurveyResponse,
updatePerformanceSurvey,
} from '@/lib/performance-survey-service'
import { prisma } from '@/lib/prisma'
import {
ensurePerformanceSurveyEmployeeProfileForUser,
getPerformanceSurveyAccountProfileWhere,
previewPerformanceSurveyEmployeeSync,
} from '@/lib/performance-survey-report-sync'
import {
DEFAULT_PERFORMANCE_SURVEY_TEMPLATES,
getPerformanceSurveyTemplate,
listPerformanceSurveyTemplates,
} from '@/lib/performance-survey-templates'
import {
getPerformanceSurveyPositionGroupByCode,
getPerformanceSurveyPositionGroupForEmployee,
listPerformanceSurveyPositionGroups,
} from '@/lib/performance-survey-position-config'
import { queueSurveyCreatorNotification } from '@/lib/performance-survey-notifications'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
performanceSurvey: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
performanceSurveyAssignment: { findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn() },
performanceSurveyResponse: { findMany: jest.fn() },
performanceSurveyTarget: { findMany: jest.fn(), deleteMany: jest.fn(), count: jest.fn() },
performanceSurveyConfigAuditLog: { create: jest.fn() },
performanceSurveyEmployeeProfile: {
findFirst: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
},
performanceSurveyEmployeeDepartmentMembership: {
findMany: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('@/lib/performance-survey-templates', () => {
const actual = jest.requireActual('@/lib/performance-survey-templates')
return {
...actual,
getPerformanceSurveyTemplate: jest.fn(),
listPerformanceSurveyTemplates: jest.fn(),
}
})
jest.mock('@/lib/performance-survey-position-config', () => {
const actual = jest.requireActual('@/lib/performance-survey-position-config')
return {
...actual,
getPerformanceSurveyPositionGroupByCode: jest.fn(),
getPerformanceSurveyPositionGroupForEmployee: jest.fn(),
listPerformanceSurveyPositionGroups: jest.fn(),
}
})
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(),
}))
jest.mock('@/lib/performance-survey-notifications', () => ({
ensurePerformanceSurveyNotificationWorkerStarted: jest.fn(),
queueDuePerformanceSurveyReminders: jest.fn(),
queueSurveyAssignmentNotifications: jest.fn(),
queueSurveyCreatorNotification: jest.fn(),
queueSurveyResultNotifications: jest.fn(),
queueSurveyTargetConfirmationNotifications: jest.fn(),
recoverDuePerformanceSurveyNotifications: jest.fn(),
refreshPerformanceSurveyNotificationReadStatuses: jest.fn(),
retryPerformanceSurveyNotification: jest.fn(),
}))
jest.mock('@/lib/dingtalk-client', () => ({
getDingtalkPerformanceSurveyNotificationChannel: jest.fn(() => 'work_notice'),
getDingtalkRobotOtoConfigStatus: jest.fn(() => ({ configured: true })),
getDingtalkRobotOtoTestUserIds: jest.fn(() => []),
getDingtalkWorkNoticeConfigStatus: jest.fn(() => ({ configured: true })),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
uploadToS3: jest.fn(),
}))
const session = { user: { id: 'user-1' } } as Session
const baseUser = {
id: 'user-1',
username: 'tester',
email: 'tester@example.com',
role: 'USER',
department: '策划部',
managedDepartment: null,
isSuperAdmin: false,
dingtalkUserId: 'ding-user-1',
realName: '测试人员',
avatar: null,
}
const baseEmployee = {
id: 'employee-1',
displayName: '测试人员',
departmentName: '策划部',
departmentPath: '集团A/品牌中心/策划部',
levelType: 'P',
positionLevel: 'P5',
isLeadership: false,
}
function buildConfiguredPositionGroup() {
const templates = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES).map((template, index) => ({
...template,
templateVersionId: `template-version-${index + 1}`,
templateVersion: 1,
templateVersionStatus: 'published' as const,
effectiveMonth: '2026-07',
}))
const fixedReviewer = templates.find((template) => template.code === 'visual_brand_tone')
?.workflow?.stages.flatMap((stage) => stage.reviewers)
.find((reviewer) => reviewer.source === 'fixed_employee')
if (fixedReviewer) fixedReviewer.employeeId = 'employee-yuanzhe'
return {
id: 'position-group-brand',
code: 'brand_designer',
name: '品牌设计师',
sortOrder: 10,
isActive: true,
status: 'ready' as const,
planId: 'position-plan-1',
planVersion: 1,
effectiveMonth: '2026-07',
rules: [{ departmentPathPrefix: '集团A/品牌中心', positionTitle: null }],
templates,
}
}
function buildSurvey(overrides: Record<string, unknown> = {}) {
return {
id: 'survey-1',
title: '2026年6月 · 课程分享',
description: null,
surveyKind: 'person_period',
templateCode: 'course_share',
templateVersionId: 'template-version-1',
positionGroupId: 'position-group-brand',
positionGroupCode: 'brand_designer',
positionGroupSnapshot: { code: 'brand_designer', name: '品牌设计师' },
templateSnapshot: {
code: 'course_share',
name: '课程分享月度考核',
shortName: '课程分享',
questions: [],
},
status: 'draft',
creatorUserId: 'user-1',
creatorName: '测试人员',
creatorEmployeeId: 'employee-1',
departmentName: '策划部',
assessmentMonth: '2026-06',
dueAt: null,
openedAt: null,
closedAt: null,
createdAt: new Date('2026-06-01T00:00:00.000Z'),
targets: [{
id: 'target-1',
employeeProfileId: 'employee-1',
targetType: 'employee',
targetName: '测试人员',
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
assignments: [],
_count: { assignments: 0, responses: 0, targets: 1 },
...overrides,
}
}
const transactionClient = {
$queryRawUnsafe: jest.fn(),
performanceSurveyTarget: { findMany: jest.fn(), createMany: jest.fn(), deleteMany: jest.fn(), count: jest.fn() },
performanceSurveyConfigAuditLog: { create: jest.fn() },
performanceSurvey: { update: jest.fn(), create: jest.fn(), deleteMany: jest.fn() },
performanceSurveyAssignment: { createMany: jest.fn(), update: jest.fn() },
performanceSurveyResponse: { create: jest.fn() },
performanceSurveyNotification: { updateMany: jest.fn() },
}
describe('performance survey service policies', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(baseUser)
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockReset()
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(baseEmployee)
;(previewPerformanceSurveyEmployeeSync as jest.Mock).mockReset()
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey())
;(prisma.performanceSurvey.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyAssignment.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.performanceSurveyAssignment.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyAssignment.count as jest.Mock).mockResolvedValue(0)
;(prisma.performanceSurveyResponse.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurvey.update as jest.Mock).mockResolvedValue({})
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyEmployeeProfile.count as jest.Mock).mockResolvedValue(0)
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockReset()
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([])
;(prisma.user.findMany as jest.Mock).mockReset()
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
;(prisma.user.findFirst as jest.Mock).mockReset()
;(prisma.user.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.performanceSurvey.count as jest.Mock).mockResolvedValue(0)
const configuredGroup = buildConfiguredPositionGroup()
;(listPerformanceSurveyPositionGroups as jest.Mock).mockResolvedValue([configuredGroup])
;(getPerformanceSurveyPositionGroupByCode as jest.Mock).mockResolvedValue(configuredGroup)
;(getPerformanceSurveyPositionGroupForEmployee as jest.Mock).mockResolvedValue({
status: 'matched',
group: configuredGroup,
matches: [configuredGroup],
})
;(getPerformanceSurveyTemplate as jest.Mock).mockReset()
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'course_share',
name: '课程分享月度考核',
shortName: '课程分享',
description: '',
surveyType: 'person_period',
weight: 0.3,
questions: [],
scoreBands: [],
})
;(listPerformanceSurveyTemplates as jest.Mock).mockResolvedValue([
{
code: 'course_share',
name: '课程分享月度考核',
shortName: '课程分享',
description: '',
surveyType: 'person_period',
weight: 0.3,
questions: [],
scoreBands: [],
},
])
transactionClient.$queryRawUnsafe.mockImplementation(async (query: string) => (
query.includes('FOR UPDATE') ? [{ status: 'open', dueAt: null }] : [{ locked: '' }]
))
transactionClient.performanceSurveyTarget.findMany.mockResolvedValue([])
transactionClient.performanceSurveyTarget.createMany.mockResolvedValue({ count: 1 })
transactionClient.performanceSurveyTarget.deleteMany.mockResolvedValue({ count: 1 })
transactionClient.performanceSurveyTarget.count.mockResolvedValue(0)
transactionClient.performanceSurvey.deleteMany.mockResolvedValue({ count: 1 })
transactionClient.performanceSurveyConfigAuditLog.create.mockResolvedValue({})
transactionClient.performanceSurvey.update.mockResolvedValue({})
transactionClient.performanceSurvey.create.mockResolvedValue({})
transactionClient.performanceSurveyAssignment.createMany.mockResolvedValue({ count: 1 })
transactionClient.performanceSurveyAssignment.update.mockResolvedValue({})
transactionClient.performanceSurveyResponse.create.mockResolvedValue({ id: 'response-1' })
transactionClient.performanceSurveyNotification.updateMany.mockResolvedValue({ count: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(transactionClient))
})
test('普通用户不能执行人员同步预检', async () => {
await expect(previewPerformanceSurveyEmployeeProfiles(session, { limit: 100 }))
.rejects.toMatchObject({
message: '需要系统管理员权限',
status: 403,
})
expect(previewPerformanceSurveyEmployeeSync).not.toHaveBeenCalled()
})
test('素材库系统管理员但考核档案不是M级时不能执行人员同步预检', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
isSuperAdmin: true,
})
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
levelType: 'P',
positionLevel: 'P5',
isLeadership: false,
})
await expect(previewPerformanceSurveyEmployeeProfiles(session, { limit: 200 }))
.rejects.toMatchObject({
message: '需要系统管理员权限',
status: 403,
})
expect(previewPerformanceSurveyEmployeeSync).not.toHaveBeenCalled()
expect(ensurePerformanceSurveyEmployeeProfileForUser).not.toHaveBeenCalled()
})
test('M级人员在考核调研内可执行系统管理员的只读预检', async () => {
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(previewPerformanceSurveyEmployeeSync as jest.Mock).mockResolvedValue({
mode: 'preview',
dryRun: true,
})
await expect(previewPerformanceSurveyEmployeeProfiles(session, { limit: 200 }))
.resolves.toEqual({
mode: 'preview',
dryRun: true,
})
expect(previewPerformanceSurveyEmployeeSync).toHaveBeenCalledWith({ limit: 200 })
expect(ensurePerformanceSurveyEmployeeProfileForUser).not.toHaveBeenCalled()
})
test('人员同步预检超时会返回中文错误且强调未写入', async () => {
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(previewPerformanceSurveyEmployeeSync as jest.Mock).mockRejectedValue(
Object.assign(new Error('request timeout'), { code: 'ETIMEOUT' }),
)
await expect(previewPerformanceSurveyEmployeeProfiles(session, {}))
.rejects.toMatchObject({
message: expect.stringContaining('预检未写入任何数据'),
status: 504,
})
})
test('视觉模板统计只采用最终评分角色,不混入自评和主管评分', async () => {
;(listPerformanceSurveyTemplates as jest.Mock).mockResolvedValue([{
code: 'visual_brand_tone',
name: '品牌调性视觉评分',
shortName: '视觉评分',
description: '',
surveyType: 'visual_case',
weight: 1,
maxTotal: 120,
questions: [],
scoreBands: [],
}])
;(prisma.performanceSurveyResponse.findMany as jest.Mock).mockResolvedValue([
{
id: 'response-self',
survey: { templateCode: 'visual_brand_tone' },
assignment: { evaluatorSnapshot: { performanceSurveyWorkflowRole: 'self' } },
totalScore: 40,
createdAt: new Date('2026-07-19T00:00:00.000Z'),
scores: {},
respondentName: '自评人',
},
{
id: 'response-manager',
survey: { templateCode: 'visual_brand_tone' },
assignment: { evaluatorSnapshot: { performanceSurveyWorkflowRole: 'manager' } },
totalScore: 80,
createdAt: new Date('2026-07-19T00:01:00.000Z'),
scores: {},
respondentName: '主管',
},
{
id: 'response-final',
survey: { templateCode: 'visual_brand_tone' },
assignment: { evaluatorSnapshot: { performanceSurveyWorkflowRole: 'visual_final' } },
totalScore: 105,
createdAt: new Date('2026-07-19T00:02:00.000Z'),
scores: {},
respondentName: '元泽',
},
])
const result = await listPerformanceSurveys(session)
expect(result.templateStats[0].responseCount).toBe(1)
expect(result.templateStats[0].aggregate.achievementScore).toBe(105)
})
test('公共看板从当月调研目标反查人员且不再构造素材账户 JSON 权限条件', async () => {
const dashboardEmployee = {
...baseEmployee,
id: 'employee-public',
displayName: '公共看板人员',
}
;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([{
id: 'target-public',
surveyId: 'survey-public',
employeeProfileId: dashboardEmployee.id,
employeeProfile: dashboardEmployee,
createdAt: new Date('2026-07-01T00:00:00.000Z'),
survey: {
id: 'survey-public',
title: '2026年7月 · 课程分享',
templateCode: 'course_share',
status: 'closed',
assessmentMonth: '2026-07',
dueAt: null,
openedAt: null,
closedAt: new Date('2026-07-10T00:00:00.000Z'),
createdAt: new Date('2026-07-01T00:00:00.000Z'),
},
assignments: [],
responses: [],
_count: { workItems: 0 },
}])
const result = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
expect(getPerformanceSurveyAccountProfileWhere).not.toHaveBeenCalled()
expect(prisma.performanceSurveyEmployeeProfile.findMany).not.toHaveBeenCalled()
expect(prisma.performanceSurveyTarget.findMany).toHaveBeenCalledTimes(1)
const query = (prisma.performanceSurveyTarget.findMany as jest.Mock).mock.calls[0][0]
expect(query.where).toEqual(expect.objectContaining({
targetType: 'employee',
employeeProfileId: { not: null },
employeeProfile: { is: {} },
}))
expect(query.select.employeeProfile).toBe(true)
expect(result.public.rows).toHaveLength(1)
expect(result.public.rows[0]).toEqual(expect.objectContaining({
month: '2026-07',
hasData: true,
employee: expect.objectContaining({ id: dashboardEmployee.id }),
}))
expect(result.permissions.canDeletePublicDashboardAssessment).toBe(false)
})
test('只有无邪账号获得公共看板单项调研删除权限', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: 'wuxie',
})
const result = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
expect(result.permissions.canDeletePublicDashboardAssessment).toBe(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: '无邪',
})
const productionAccountResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
expect(productionAccountResult.permissions.canDeletePublicDashboardAssessment).toBe(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: 'qingyun',
})
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
const otherManagerResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
expect(otherManagerResult.permissions.canDeletePublicDashboardAssessment).toBe(false)
})
test('无邪可按人员、月份、岗位和考核项彻底删除调研并释放重复校验', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: '无邪',
realName: '无邪',
})
transactionClient.performanceSurveyTarget.findMany.mockResolvedValue([{
id: 'target-public',
targetName: '若兰',
surveyId: 'survey-public',
survey: {
id: 'survey-public',
title: '2026年7月 · 工作配合度',
templateCode: 'visual_brand_tone',
positionGroupCode: 'ai_planning_creative',
},
workItems: [],
}])
await expect(removePerformanceSurveyPublicDashboardAssessment(
session,
'employee-ruolan',
{
month: '2026-07',
positionGroupCode: 'ai_planning_creative',
templateCode: 'visual_brand_tone',
reason: '岗位归属错误',
},
)).resolves.toEqual(expect.objectContaining({
employeeProfileId: 'employee-ruolan',
employeeName: '若兰',
month: '2026-07',
positionGroupCode: 'ai_planning_creative',
templateCode: 'visual_brand_tone',
deletedTargetCount: 1,
deletedSurveyCount: 1,
}))
expect(transactionClient.performanceSurveyTarget.deleteMany).toHaveBeenCalledWith({
where: {
id: { in: ['target-public'] },
},
})
expect(transactionClient.performanceSurvey.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['survey-public'] } },
})
expect(transactionClient.performanceSurveyConfigAuditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'public_dashboard.assessment_deleted',
targetType: 'public_dashboard_assessment',
targetId: 'employee-ruolan',
actorUserId: 'user-1',
reason: '岗位归属错误',
}),
})
})
test('其他M级人员直接调用移除接口也会被拒绝', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: 'qingyun',
})
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
await expect(removePerformanceSurveyPublicDashboardAssessment(
session,
'employee-ruolan',
{
month: '2026-07',
positionGroupCode: 'ai_planning_creative',
templateCode: 'visual_brand_tone',
reason: '测试越权',
},
)).rejects.toMatchObject({
message: '只有无邪可以删除公共看板调研',
status: 403,
})
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('公共看板全部岗位会合并已生效岗位、考核项和人员数据', async () => {
const brandGroup = buildConfiguredPositionGroup()
const generalTemplate = {
...structuredClone(brandGroup.templates[0]),
code: 'general_visual_review',
name: '品牌调性视觉评分(无文案)',
shortName: '视觉评分(无文案)',
templateVersionId: 'template-version-general',
}
const generalGroup = {
...brandGroup,
id: 'position-group-general',
code: 'ai_planning_general',
name: 'AI策划-综合部',
templates: [generalTemplate],
}
;(listPerformanceSurveyPositionGroups as jest.Mock).mockResolvedValue([
brandGroup,
generalGroup,
])
const brandEmployee = {
...baseEmployee,
id: 'employee-brand',
displayName: '品牌人员',
}
const generalEmployee = {
...baseEmployee,
id: 'employee-general',
displayName: '综合部人员',
departmentName: '综合支持部',
}
;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([
{
id: 'target-brand',
surveyId: 'survey-brand',
employeeProfileId: brandEmployee.id,
employeeProfile: brandEmployee,
createdAt: new Date('2026-07-01T00:00:00.000Z'),
survey: {
id: 'survey-brand',
title: '品牌考核',
templateCode: brandGroup.templates[0].code,
positionGroupCode: brandGroup.code,
templateSnapshot: brandGroup.templates[0],
status: 'closed',
assessmentMonth: '2026-07',
dueAt: null,
openedAt: null,
closedAt: new Date('2026-07-10T00:00:00.000Z'),
createdAt: new Date('2026-07-01T00:00:00.000Z'),
},
assignments: [],
responses: [],
_count: { workItems: 0 },
},
{
id: 'target-general',
surveyId: 'survey-general',
employeeProfileId: generalEmployee.id,
employeeProfile: generalEmployee,
createdAt: new Date('2026-07-02T00:00:00.000Z'),
survey: {
id: 'survey-general',
title: '综合部考核',
templateCode: generalTemplate.code,
positionGroupCode: generalGroup.code,
templateSnapshot: generalTemplate,
status: 'open',
assessmentMonth: '2026-07',
dueAt: null,
openedAt: new Date('2026-07-02T00:00:00.000Z'),
closedAt: null,
createdAt: new Date('2026-07-02T00:00:00.000Z'),
},
assignments: [],
responses: [],
_count: { workItems: 0 },
},
])
const result = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
positionGroupCode: 'all',
}))
const query = (prisma.performanceSurveyTarget.findMany as jest.Mock).mock.calls[0][0]
expect(query.where.survey.AND).toContainEqual({
positionGroupCode: { in: ['brand_designer', 'ai_planning_general'] },
})
expect(result.filters.positionGroupCode).toBe('all')
expect(result.selectedPositionGroup).toBeNull()
expect(result.templatePager.totalItems).toBe(brandGroup.templates.length + 1)
expect(result.public.rows).toHaveLength(2)
expect(result.public.rows.map((row) => row.employee.id)).toEqual(expect.arrayContaining([
brandEmployee.id,
generalEmployee.id,
]))
const brandRow = result.public.rows.find((row) => row.employee.id === brandEmployee.id)
expect(brandRow).toBeDefined()
expect(brandRow!.cells[brandGroup.templates[0].code].positionGroupCode).toBe(
brandGroup.code,
)
})
test('月度看板使用调研创建时的模板快照,并直接读取历史实际得分而非旧 KPI 分', async () => {
const dashboardEmployee = {
...baseEmployee,
id: 'employee-history',
displayName: '历史评分人员',
}
;(listPerformanceSurveyTemplates as jest.Mock).mockResolvedValue([{
code: 'course_share',
name: '当前课程分享模板',
shortName: '课程分享',
description: '',
surveyType: 'person_period',
weight: 0.3,
maxTotal: 100,
questions: [],
scoreBands: [{ min: 77, max: 100, outMin: 60, outMax: 120 }],
}])
;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([{
id: 'target-history',
surveyId: 'survey-history',
employeeProfileId: dashboardEmployee.id,
employeeProfile: dashboardEmployee,
createdAt: new Date('2026-07-01T00:00:00.000Z'),
survey: {
id: 'survey-history',
title: '历史课程分享',
templateCode: 'course_share',
templateSnapshot: {
code: 'course_share',
name: '历史 20 分制课程分享',
description: '',
surveyType: 'person_period',
weight: 0.3,
maxTotal: 20,
questions: [],
scoreBands: [{ min: 0, max: 20, outMin: 0, outMax: 120 }],
},
status: 'closed',
assessmentMonth: '2026-07',
dueAt: null,
openedAt: null,
closedAt: new Date('2026-07-10T00:00:00.000Z'),
createdAt: new Date('2026-07-01T00:00:00.000Z'),
},
assignments: [{ id: 'assignment-history', status: 'submitted', submittedAt: new Date() }],
responses: [{
id: 'response-history',
totalScore: 18,
kpiScore: 108,
createdAt: new Date('2026-07-02T00:00:00.000Z'),
scores: {},
respondentName: '历史评价人',
assignment: { evaluatorSnapshot: {} },
}],
_count: { workItems: 0 },
}])
const result = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
const cell = result.public.rows[0].cells.course_share
expect(cell.score).toBe(18)
expect(cell.rawAverage).toBe(18)
expect(cell.effectiveAverage).toBe(18)
expect(cell.rawMaxTotal).toBe(20)
})
test('M级系统管理员查看公共看板时不再叠加领导组织范围', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M2',
isLeadership: true,
})
await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
department: 'deptPath:%E9%9B%86%E5%9B%A2A%2F%E5%93%81%E7%89%8C%E4%B8%AD%E5%BF%83',
employeeId: 'employee-public',
}))
const employeeWhere = (prisma.performanceSurveyTarget.findMany as jest.Mock).mock.calls[0][0]
.where.employeeProfile.is
expect(employeeWhere).toEqual({
AND: [
expect.objectContaining({ OR: expect.any(Array) }),
{ id: 'employee-public' },
],
})
expect(JSON.stringify(employeeWhere)).toContain('集团A/品牌中心')
expect(JSON.stringify(employeeWhere)).not.toContain('集团A/品牌中心/策划部')
expect(getPerformanceSurveyAccountProfileWhere).not.toHaveBeenCalled()
})
test('M级作为考核系统管理员可跨组织查看人员考核明细', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
id: 'employee-other',
displayName: '另一组织员工',
departmentName: '策划部',
departmentPath: '集团B/品牌中心/策划部',
})
const request = new URLSearchParams({
month: '2026-07',
employeeId: 'employee-other',
templateCode: 'course_share',
})
await expect(getPerformanceSurveyDashboardDetail(session, request)).resolves.toEqual(
expect.objectContaining({
employee: expect.objectContaining({ id: 'employee-other' }),
}),
)
expect(prisma.performanceSurveyEmployeeProfile.count).not.toHaveBeenCalled()
})
test('M级作为考核系统管理员时不依赖本人组织路径查看他人详情', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentPath: null,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
id: 'employee-other',
displayName: '其他员工',
departmentName: '策划部',
departmentPath: '集团B/品牌中心/策划部',
})
await expect(getPerformanceSurveyDashboardDetail(session, new URLSearchParams({
month: '2026-07',
employeeId: 'employee-other',
templateCode: 'course_share',
}))).resolves.toEqual(expect.objectContaining({
employee: expect.objectContaining({ id: 'employee-other' }),
}))
expect(prisma.performanceSurveyEmployeeProfile.count).not.toHaveBeenCalled()
})
test('考核明细查询排除已撤销调研', async () => {
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(baseEmployee)
;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([])
await getPerformanceSurveyDashboardDetail(session, new URLSearchParams({
month: '2026-07',
employeeId: 'employee-1',
templateCode: 'course_share',
}))
const surveyWhere = (prisma.performanceSurveyTarget.findMany as jest.Mock).mock.calls[0][0].where.survey
expect(surveyWhere.AND).toContainEqual({ status: { not: 'cancelled' } })
})
test('普通问卷评分已提交后,完成通知排队失败不会把提交结果反转为失败', async () => {
const openSurvey = buildSurvey({
status: 'open',
templateSnapshot: {
code: 'course_share',
name: '课程分享',
description: '',
surveyType: 'person_period',
weight: 0.3,
questions: [{ id: 'usefulness', title: '内容实用度', maxScore: 20 }],
scoreBands: [{ min: 0, max: 20, outMin: 0, outMax: 120 }],
},
})
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(openSurvey)
;(prisma.performanceSurveyAssignment.findFirst as jest.Mock).mockResolvedValue({
id: 'assignment-1',
targetId: 'target-1',
status: 'pending',
evaluatorSnapshot: {},
response: null,
target: { id: 'target-1' },
})
;(prisma.performanceSurveyAssignment.count as jest.Mock).mockResolvedValue(0)
;(queueSurveyCreatorNotification as jest.Mock).mockRejectedValueOnce(new Error('notification queue unavailable'))
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
await expect(submitPerformanceSurveyResponse(session, 'survey-1', {
targetId: 'target-1',
scores: { usefulness: 10 },
})).resolves.toEqual({ id: 'response-1', message: '提交成功' })
expect(transactionClient.performanceSurveyResponse.create).toHaveBeenCalledTimes(1)
expect(transactionClient.performanceSurveyAssignment.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'submitted' }),
}))
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('评分已保存,但后续通知排队失败'),
expect.any(Error),
)
consoleError.mockRestore()
})
test('M级作为考核系统管理员可管理其他人员发起的调研', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
creatorUserId: 'user-other',
}))
await expect(
updatePerformanceSurvey(session, 'survey-1', { title: '管理员修改' }),
).resolves.toEqual(expect.objectContaining({ id: 'survey-1' }))
expect(prisma.performanceSurvey.count).not.toHaveBeenCalled()
expect(transactionClient.performanceSurvey.update).toHaveBeenCalled()
})
test('发起人可为非旧代码的视觉模板草稿继续上传作品', async () => {
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'ai_work_review',
templateSnapshot: {
code: 'ai_work_review',
name: 'AI作品评审',
shortName: 'AI作品评审',
surveyType: 'visual_case',
requiredWorkItemCount: 3,
questions: [],
},
targets: [{
id: 'target-ai-designer',
employeeProfileId: 'employee-ai-designer',
targetType: 'employee',
targetName: 'AI设计师',
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
const detail = await getPerformanceSurveyDetail(session, 'survey-1')
expect(detail.targets).toEqual([
expect.objectContaining({
id: 'target-ai-designer',
canUploadWorkItems: false,
canCreateWorkItem: true,
requiredWorkItemCount: 3,
workItemUploadRemaining: 3,
}),
])
})
test('素材库管理员身份不会扩大个人调研详情到其他被考核人', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
})
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
creatorUserId: 'user-other',
targets: [{
id: 'target-other',
employeeProfileId: 'employee-other',
targetType: 'employee',
targetName: '其他员工',
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
await expect(getPerformanceSurveyDetail(session, 'survey-1')).rejects.toEqual(
new PerformanceSurveyApiError('调研不存在或无权限', 404),
)
expect(prisma.performanceSurvey.count).not.toHaveBeenCalled()
})
test('我发起的历史多目标调研结果只返回当前账户对应目标', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
})
const selfTarget = {
id: 'target-self',
employeeProfileId: 'employee-1',
targetType: 'employee',
targetName: '测试人员',
targetSnapshot: {},
responses: [],
workItems: [],
_count: { assignments: 3, responses: 2 },
}
const otherTarget = {
...selfTarget,
id: 'target-other',
employeeProfileId: 'employee-other',
targetName: '其他员工',
}
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
status: 'closed',
targets: [selfTarget, otherTarget],
}))
const result = await getPerformanceSurveyResults(session, 'survey-1')
expect(result.targets.map((target) => target.id)).toEqual(['target-self'])
expect(result.survey.counts.targets).toBe(1)
expect(result.survey.counts.assignments).toBe(3)
expect(result.survey.counts.responses).toBe(2)
})
test('我发起的历史多目标调研列表只返回当前账户目标计数', async () => {
;(prisma.performanceSurvey.findMany as jest.Mock).mockResolvedValue([{
...buildSurvey({ status: 'closed' }),
targets: [{
id: 'target-self',
_count: { assignments: 3, responses: 2 },
}],
_count: { targets: 6, assignments: 12, responses: 12 },
}])
const result = await listPerformanceSurveys(session)
expect(result.created).toHaveLength(1)
expect(result.created[0].personalTargetId).toBe('target-self')
expect(result.created[0].counts).toEqual({
targets: 1,
assignments: 3,
responses: 2,
})
})
test('M级可撤销权限范围内调研并保留撤销审计信息', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
status: 'open',
creatorUserId: 'user-other',
}))
;(prisma.performanceSurvey.count as jest.Mock).mockResolvedValue(1)
await cancelPerformanceSurvey(session, 'survey-1', { reason: '评分录入错误' })
expect(transactionClient.performanceSurvey.update).toHaveBeenCalledWith({
where: { id: 'survey-1' },
data: expect.objectContaining({
status: 'cancelled',
cancelledByUserId: 'user-1',
cancelledByName: '测试人员',
cancelReason: '评分录入错误',
cancelledAt: expect.any(Date),
}),
})
expect(transactionClient.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('FOR UPDATE'),
'survey-1',
)
expect(transactionClient.performanceSurveyNotification.updateMany).toHaveBeenCalledWith({
where: {
surveyId: 'survey-1',
status: { in: ['pending', 'retrying', 'processing'] },
},
data: expect.objectContaining({
status: 'skipped',
nextRetryAt: null,
completedAt: expect.any(Date),
lastError: '调研已撤销:评分录入错误',
}),
})
})
test('普通人员即使是发起人也不能撤销调研', async () => {
await expect(
cancelPerformanceSurvey(session, 'survey-1', { reason: '误操作' }),
).rejects.toEqual(new PerformanceSurveyApiError('无权限管理考核调研', 403))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('固定月度模板拒绝自定义标题覆盖', async () => {
await updatePerformanceSurvey(session, 'survey-1', { title: '自定义标题' })
expect(transactionClient.performanceSurvey.update).toHaveBeenCalledWith({
where: { id: 'survey-1' },
data: { title: '2026年6月 · 课程分享' },
})
expect(transactionClient.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('FOR UPDATE'),
'survey-1',
)
expect(transactionClient.$queryRawUnsafe.mock.invocationCallOrder[0]).toBeLessThan(
transactionClient.performanceSurvey.update.mock.invocationCallOrder[0],
)
})
test('修改月份时先加事务锁、排除当前调研,再同步月份和固定标题', async () => {
await updatePerformanceSurvey(session, 'survey-1', { assessmentMonth: '2026-07' })
expect(transactionClient.$queryRawUnsafe).toHaveBeenCalledTimes(3)
expect(transactionClient.$queryRawUnsafe.mock.calls.slice(1).map((call) => call[1])).toEqual([
'performance-survey-monthly:course_share:2026-06:employee-1',
'performance-survey-monthly:course_share:2026-07:employee-1',
])
expect(transactionClient.$queryRawUnsafe.mock.calls[0][0]).toContain('FOR UPDATE')
expect(transactionClient.$queryRawUnsafe.mock.calls[1][0]).toContain('::text AS locked')
expect(transactionClient.$queryRawUnsafe.mock.invocationCallOrder[0]).toBeLessThan(
transactionClient.$queryRawUnsafe.mock.invocationCallOrder[1],
)
const duplicateWhere = transactionClient.performanceSurveyTarget.findMany.mock.calls[0][0].where
expect(duplicateWhere.survey.AND).toContainEqual({ id: { not: 'survey-1' } })
expect(transactionClient.performanceSurvey.update).toHaveBeenCalledWith({
where: { id: 'survey-1' },
data: {
assessmentMonth: '2026-07',
title: '2026年7月 · 课程分享',
},
})
})
test('创建月度调研时在同一事务内先加锁和查重,再写入调研', async () => {
const evaluator = {
...baseEmployee,
id: 'employee-2',
displayName: '评价人员',
dingtalkUserId: null,
dingEmail: null,
}
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([baseEmployee])
.mockResolvedValueOnce([evaluator])
await createPerformanceSurvey(session, {
templateCode: 'course_share',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
openNow: false,
})
expect(transactionClient.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('::text AS locked'),
'performance-survey-monthly:course_share:2026-07:employee-1',
)
expect(transactionClient.performanceSurveyTarget.findMany).toHaveBeenCalledTimes(1)
expect(transactionClient.performanceSurvey.create).toHaveBeenCalledTimes(1)
expect(transactionClient.$queryRawUnsafe.mock.invocationCallOrder[0]).toBeLessThan(
transactionClient.performanceSurveyTarget.findMany.mock.invocationCallOrder[0],
)
expect(transactionClient.performanceSurveyTarget.findMany.mock.invocationCallOrder[0]).toBeLessThan(
transactionClient.performanceSurvey.create.mock.invocationCallOrder[0],
)
expect(getPerformanceSurveyPositionGroupByCode).not.toHaveBeenCalled()
expect(transactionClient.performanceSurvey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
positionGroupCode: 'brand_designer',
positionGroupId: 'position-group-brand',
}),
})
})
test('客户端不能用旧岗位参数覆盖被考核人的自动岗位', async () => {
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'ai_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
openNow: false,
})).rejects.toEqual(
new PerformanceSurveyApiError('考核岗位已根据被考核人的人员分配自动关联,请刷新页面后重试', 409),
)
expect(getPerformanceSurveyPositionGroupByCode).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('发起人不能手动选择自己作为评分人员', async () => {
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-1'],
openNow: false,
})).rejects.toEqual(
new PerformanceSurveyApiError('发起人不能作为评分人员,请选择其他人员', 409),
)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('评分人员搜索和部门批量加载都排除当前发起人', async () => {
await searchPerformanceSurveyEmployees(session, new URLSearchParams({
purpose: 'evaluator',
department: '集团A/品牌中心/策划部',
limit: '1000',
}))
const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
expect(query.where.AND).toContainEqual({ id: { not: 'employee-1' } })
})
test('人员搜索会命中兼任部门并返回全部部门及钉钉身份', async () => {
const employee = {
...baseEmployee,
displayName: '清云',
positionTitle: '营销策划主管',
positionLevel: 'M1',
levelType: 'M',
isLeadership: true,
isDingtalkAdmin: true,
isDingtalkBoss: false,
isDingtalkSenior: false,
departmentMemberships: [
{
id: 'membership-primary',
sourceDeptId: '994456259',
departmentName: '营销策划中心',
departmentPath: '集团/龙晨品牌中心/营销策划中心',
isPrimary: true,
syncedAt: new Date('2026-07-27T00:00:00.000Z'),
},
{
id: 'membership-secondary',
sourceDeptId: '1085358755',
departmentName: '品牌创意B部',
departmentPath: '集团/品牌中心/品牌创意B部',
isPrimary: false,
syncedAt: new Date('2026-07-27T00:00:00.000Z'),
},
],
}
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([employee])
const result = await searchPerformanceSurveyEmployees(session, new URLSearchParams({
search: '品牌创意B部',
purpose: 'evaluator',
}))
const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
expect(query.where.AND).toContainEqual({
OR: expect.arrayContaining([
{
departmentMemberships: {
some: {
OR: [
{ departmentName: { contains: '品牌创意B部', mode: 'insensitive' } },
{ departmentPath: { contains: '品牌创意B部', mode: 'insensitive' } },
],
},
},
},
]),
})
expect(result).toEqual([
expect.objectContaining({
displayName: '清云',
positionLevel: 'M1',
isDingtalkAdmin: true,
departmentMemberships: [
expect.objectContaining({ departmentName: '营销策划中心', isPrimary: true }),
expect.objectContaining({ departmentName: '品牌创意B部', isPrimary: false }),
],
}),
])
})
test('创建接口拒绝直接传入组织范围外的被考核人 ID', async () => {
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([baseEmployee])
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-outside'],
evaluatorEmployeeIds: ['employee-2'],
openNow: false,
})).rejects.toEqual(
new PerformanceSurveyApiError('部分被考核人不在当前组织权限范围内', 403),
)
const targetQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
expect(targetQuery.where.AND).toContainEqual({
OR: [
{ id: 'employee-1' },
{
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
},
],
})
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('缺少完整组织路径时不使用重名部门叶子回退扩大被考核人范围', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentPath: null,
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([{
...baseEmployee,
departmentPath: null,
}])
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-outside'],
evaluatorEmployeeIds: ['employee-2'],
openNow: false,
})).rejects.toThrow('部分被考核人不在当前组织权限范围内')
expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('创建资格严格按钉钉完整组织路径中的品牌中心或摄影中心判断', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentName: '品牌中心',
departmentPath: '集团A/其他中心/品牌中心同名小组',
})
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
})).rejects.toEqual(expect.objectContaining({ status: 403 }))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('考核档案M级即使不在指定组织也可发起,canCreate 与创建接口保持一致', async () => {
const adminEmployee = {
...baseEmployee,
departmentName: '视觉一部',
departmentPath: '集团A/其他中心/视觉一部',
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
}
const evaluator = {
...baseEmployee,
id: 'employee-2',
displayName: '评价人员',
dingtalkUserId: null,
}
const targetEmployee = {
...baseEmployee,
id: 'employee-target',
displayName: '被考核人员',
dingtalkUserId: null,
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(adminEmployee)
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({
permissions: expect.objectContaining({ canCreate: true }),
}))
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([targetEmployee])
.mockResolvedValueOnce([evaluator])
const creation = createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-target'],
evaluatorEmployeeIds: ['employee-2'],
})
await expect(creation).rejects.not.toEqual(expect.objectContaining({ status: 403 }))
expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalled()
})
test('M级人员在考核调研内按系统管理员授权', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({
permissions: expect.objectContaining({
isSystemAdmin: true,
isDepartmentAdmin: true,
isLeadership: true,
canCreate: true,
}),
}))
})
test.each(['商品企划部', '空间视觉部'])(
'%s不再由部门名称硬编码禁止发起,资格交给岗位匹配规则',
async (departmentName) => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentName,
departmentPath: `集团A/品牌中心/${departmentName}`,
})
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({
permissions: expect.objectContaining({ canCreate: true }),
}))
},
)
test('考核档案M级属于品牌中心未开放部门时仍保留全局发起能力', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentName: '商品企划部',
departmentPath: '集团A/品牌中心/商品企划部',
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({
permissions: expect.objectContaining({ canCreate: true }),
}))
})
test('综合支持部普通人员可发起考核调研', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentName: '综合支持部',
departmentPath: null,
})
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({
permissions: expect.objectContaining({ canCreate: true }),
}))
})
test('自动匹配的非自评人员如果是发起人则拒绝创建', async () => {
const creator = {
...baseEmployee,
dingtalkUserId: baseUser.dingtalkUserId,
departmentName: '品牌创意A部',
departmentPath: '集团A/品牌中心/品牌创意A部',
managerDingtalkUserId: 'ding-other-manager',
}
const target = {
...creator,
id: 'employee-target',
displayName: '其他被考核人',
dingtalkUserId: 'ding-target',
managerDingtalkUserId: creator.dingtalkUserId,
}
const creatorManager = {
...creator,
id: 'employee-creator-manager',
displayName: '发起人的主管',
dingtalkUserId: 'ding-other-manager',
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockReset()
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(creator)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'work_cooperation',
name: '工作配合度满意度调研',
shortName: '工作配合度',
surveyType: 'person_period',
weight: 0.2,
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
;(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,
})
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(
new PerformanceSurveyApiError('发起人不能作为评分人员,请选择其他人员', 409),
)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test.each([
{
label: '品牌创意A部P级人员',
employee: {
departmentName: '品牌创意A部',
departmentPath: '集团A/品牌中心/品牌创意A部',
dingtalkUserId: 'ding-p-employee',
levelType: 'P',
positionLevel: 'P6',
managerDingtalkUserId: 'ding-direct-manager',
},
evaluator: {
id: 'employee-direct-manager',
displayName: '直属主管',
dingtalkUserId: 'ding-direct-manager',
},
},
{
label: '综合支持部M级人员',
employee: {
departmentName: '综合支持部',
departmentPath: '集团A/品牌中心/综合支持部',
dingtalkUserId: 'ding-m-employee',
levelType: 'M',
positionLevel: 'M2',
managerDingtalkUserId: 'ding-other-manager',
},
evaluator: {
id: 'employee-other-manager',
displayName: '直属主管',
dingtalkUserId: 'ding-other-manager',
},
},
{
label: '观世(档案为P级)',
employee: {
departmentName: '摄影中心',
departmentPath: '集团A/摄影中心',
dingtalkUserId: '17642061233583361',
levelType: 'P',
positionLevel: 'P7',
managerDingtalkUserId: 'ding-other-manager',
},
evaluator: {
id: 'employee-other-manager',
displayName: '直属主管',
dingtalkUserId: 'ding-other-manager',
},
},
])('$label发起工作配合度时按模板读取人员档案中的直属主管', async ({ employee, evaluator }) => {
const target = {
...baseEmployee,
...employee,
id: 'employee-target',
displayName: '被考核人',
}
const evaluatorProfile = {
...baseEmployee,
...evaluator,
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
}
;(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).mockReset()
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([evaluatorProfile])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-evaluator',
dingtalkUserId: evaluator.dingtalkUserId,
}])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'work_cooperation',
creatorEmployeeId: target.id,
targets: [{
id: 'target-work-cooperation',
employeeProfileId: target.id,
targetType: 'employee',
targetName: target.displayName,
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
await createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})
const evaluatorQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0]
expect(evaluatorQuery.where.AND[1]).toEqual({ dingtalkUserId: evaluator.dingtalkUserId })
const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data
expect(assignmentRows).toEqual([
expect.objectContaining({
evaluatorEmployeeId: evaluator.id,
evaluatorDingtalkUserId: evaluator.dingtalkUserId,
evaluatorName: evaluator.displayName,
workflowStageKey: 'manager_review',
reviewerSource: 'direct_manager',
}),
])
})
test.each([
['指定人员', { evaluatorEmployeeIds: ['employee-manual-evaluator'] }],
['指定部门', { evaluatorDepartmentNames: ['集团A/品牌中心/评审组'] }],
])('模板配置为发起时选择人员后支持%s评分人', async (_label, evaluatorInput) => {
const target = {
...baseEmployee,
id: 'employee-target',
displayName: '被考核人',
departmentName: '品牌创意A部',
departmentPath: '集团A/品牌中心/品牌创意A部',
dingtalkUserId: 'ding-target',
levelType: 'P',
positionLevel: 'P6',
managerDingtalkUserId: null,
}
const manualEvaluator = {
...baseEmployee,
id: 'employee-manual-evaluator',
displayName: '手动评分人',
dingtalkUserId: 'ding-manual-evaluator',
}
const manualGroup = buildConfiguredPositionGroup()
const workTemplate = manualGroup.templates.find((template) => template.code === 'work_cooperation')!
workTemplate.workflow = {
stages: [{
key: 'manual_review',
label: '人工评价',
order: 0,
reviewers: [{ source: 'manual_selection', label: '发起时选择人员' }],
}],
scoring: { mode: 'average_reviewers', stageKeys: ['manual_review'] },
}
;(getPerformanceSurveyPositionGroupByCode as jest.Mock).mockResolvedValue(manualGroup)
;(getPerformanceSurveyPositionGroupForEmployee as jest.Mock).mockResolvedValue({
status: 'matched',
group: manualGroup,
matches: [manualGroup],
})
;(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).mockReset()
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([manualEvaluator])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-manual-evaluator',
dingtalkUserId: manualEvaluator.dingtalkUserId,
email: null,
}])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'work_cooperation',
creatorEmployeeId: target.id,
targets: [{
id: 'target-work-cooperation',
employeeProfileId: target.id,
targetType: 'employee',
targetName: target.displayName,
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
await createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
...evaluatorInput,
})
const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data
expect(assignmentRows).toEqual([
expect.objectContaining({
evaluatorEmployeeId: manualEvaluator.id,
evaluatorDingtalkUserId: manualEvaluator.dingtalkUserId,
evaluatorName: manualEvaluator.displayName,
}),
])
})
test.each([
['缺少主组织', []],
[
'存在多个主组织',
[
{ sourceDeptId: '1085808623' },
{ sourceDeptId: '1085808624' },
],
],
])('直属主管模板在人员档案缺少主管唯一值且%s时明确拒绝创建', async (_label, primaryMemberships) => {
const target = {
...baseEmployee,
id: 'employee-target',
displayName: '缺主管人员',
departmentName: '品牌创意B部',
departmentPath: '集团A/品牌中心/品牌创意B部',
dingtalkUserId: 'ding-p-no-manager',
levelType: 'P',
positionLevel: 'P5',
managerDingtalkUserId: 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(primaryMemberships)
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([target])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
})).rejects.toEqual(new PerformanceSurveyApiError(
'无法确定缺主管人员所属的主组织,请先校准人员档案',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('直属主管唯一值缺失时按主组织唯一 M 级人员解析主管', async () => {
const target = {
...baseEmployee,
id: 'employee-yijiang',
displayName: '忆江',
departmentName: '综合支持部',
departmentPath: '集团A/品牌中心/综合支持部',
dingtalkUserId: 'ding-yijiang',
levelType: 'P',
positionLevel: 'P4',
managerDingtalkUserId: null,
managerJobNumber: null,
}
const uniqueManager = {
...baseEmployee,
id: 'employee-wuxie',
displayName: '无邪',
departmentName: target.departmentName,
departmentPath: target.departmentPath,
dingtalkUserId: 'ding-wuxie',
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
sourcePayload: { materialUserId: 'user-wuxie' },
}
;(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])
;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{
id: 'user-wuxie',
dingtalkUserId: uniqueManager.dingtalkUserId,
}])
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({
id: 'user-wuxie',
dingtalkUserId: uniqueManager.dingtalkUserId,
})
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'work_cooperation',
creatorEmployeeId: target.id,
targets: [{
id: 'target-work-cooperation',
employeeProfileId: target.id,
targetType: 'employee',
targetName: target.displayName,
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
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,
},
},
})
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('主管唯一值已配置但账户不可用时不回退组织推断', async () => {
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([])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(new PerformanceSurveyApiError(
'被考核人的直属主管尚无可用账户,请先校准人员档案',
409,
))
expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test.each([
{
label: '钉钉身份匹配但工号冲突',
},
{
label: '工号匹配但钉钉身份冲突',
},
])('主管双标识存在时,$label必须拒绝匹配', async () => {
const target = {
...baseEmployee,
id: 'employee-target',
displayName: '被考核人',
dingtalkUserId: 'ding-target',
managerDingtalkUserId: 'ding-declared-manager',
managerJobNumber: 'JOB-DECLARED',
}
;(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([])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(new PerformanceSurveyApiError(
'无法唯一匹配被考核人的直属主管',
409,
))
const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0]
expect(managerQuery.where.AND).toContainEqual({
dingtalkUserId: target.managerDingtalkUserId,
jobNumber: target.managerJobNumber,
})
expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('主管唯一值缺失但主组织存在多名 M 级人员时拒绝猜测', async () => {
const target = {
...baseEmployee,
id: 'employee-target',
displayName: '被考核人',
dingtalkUserId: 'ding-target',
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([
{ ...baseEmployee, id: 'manager-1', levelType: 'M', positionLevel: 'M1' },
{ ...baseEmployee, id: 'manager-2', levelType: 'M', positionLevel: 'M2' },
])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(new PerformanceSurveyApiError(
'被考核人所属组织内存在多名M级人员,请明确配置直属主管',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('人员姓名不触发固定评分人规则,缺主管时仍按组织规则处理', async () => {
const yuanzhe = {
...baseEmployee,
id: 'employee-yuanzhe',
displayName: '元泽',
flowerName: '元泽',
departmentName: '品牌创意A部',
departmentPath: '集团A/品牌中心/品牌创意A部',
dingtalkUserId: '27102756692071120919',
levelType: 'M',
positionLevel: 'M3',
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(yuanzhe)
;(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([yuanzhe])
.mockResolvedValueOnce([])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [yuanzhe.id],
})).rejects.toEqual(new PerformanceSurveyApiError(
'元泽所属组织内没有可用的M级主管,请先校准人员档案',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('同一人员在不同阶段承担不同角色时保留独立任务并按阶段开放', async () => {
const yuanzhe = {
...baseEmployee,
id: 'employee-yuanzhe',
displayName: '元泽',
flowerName: '元泽',
realName: '元泽',
departmentName: '综合支持部',
departmentPath: '集团A/品牌中心/综合支持部',
dingtalkUserId: 'ding-yuanzhe',
managerDingtalkUserId: 'ding-manager',
}
const manager = {
...baseEmployee,
id: 'employee-manager',
displayName: '直属主管',
dingtalkUserId: 'ding-manager',
levelType: 'M',
positionLevel: 'M2',
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(yuanzhe)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'visual_brand_tone',
name: '品牌调性视觉评分',
shortName: '视觉评分',
surveyType: 'visual_case',
weight: 1,
questionMaxScore: 8,
maxTotal: 120,
questions: [
{ id: 'brandUnderstanding', title: '品牌理解', maxScore: 8 },
{ id: 'visualStyle', title: '视觉风格', maxScore: 8 },
{ id: 'copyExpression', title: '文案表达', maxScore: 8 },
{ id: 'visualCopyFit', title: '图文协同', maxScore: 8 },
{ id: 'creativeHighlight', title: '创意亮点', maxScore: 8 },
],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([yuanzhe])
.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-manager',
dingtalkUserId: 'ding-manager',
}])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'visual_brand_tone',
targets: [{
id: 'target-yuanzhe',
employeeProfileId: 'employee-yuanzhe',
targetType: 'employee',
targetName: '元泽',
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
await createPerformanceSurvey(session, {
templateCode: 'visual_brand_tone',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-yuanzhe'],
openNow: true,
})
const targetQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
expect(targetQuery.where.AND).toContainEqual({ id: { in: ['employee-yuanzhe'] } })
const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data
expect(transactionClient.performanceSurvey.create.mock.calls[0][0].data.templateVersionId).toBe('template-version-4')
expect(assignmentRows).toHaveLength(3)
expect(assignmentRows).toEqual(expect.arrayContaining([
expect.objectContaining({
evaluatorEmployeeId: 'employee-yuanzhe',
workflowStageKey: 'self_review',
reviewerSource: 'self',
releasedAt: expect.any(Date),
}),
expect.objectContaining({
evaluatorEmployeeId: 'employee-manager',
workflowStageKey: 'final_review',
reviewerSource: 'direct_manager',
releasedAt: null,
}),
expect.objectContaining({
evaluatorEmployeeId: 'employee-yuanzhe',
workflowStageKey: 'final_review',
reviewerSource: 'fixed_employee',
releasedAt: null,
}),
]))
})
test.each([
['无邪', '09061817188066'],
['伯言', '09061617282237'],
['清云', '09053022115891'],
])('%s的视觉评分按人员档案取直属主管,并按模板取固定终审人', async (displayName, dingtalkUserId) => {
const target = {
...baseEmployee,
id: `employee-${displayName}`,
displayName,
flowerName: displayName,
dingtalkUserId,
managerDingtalkUserId: 'ding-declared-manager',
}
const manager = {
...baseEmployee,
id: 'employee-declared-manager',
displayName: '档案直属主管',
dingtalkUserId: 'ding-declared-manager',
}
const yuanzhe = {
...baseEmployee,
id: 'employee-yuanzhe',
displayName: '元泽',
flowerName: '元泽',
dingtalkUserId: '27102756692071120919',
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'visual_brand_tone',
name: '品牌调性视觉评分',
shortName: '视觉评分',
surveyType: 'visual_case',
weight: 1,
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([manager])
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe)
;(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: [{
id: 'target-person',
employeeProfileId: target.id,
targetType: 'employee',
targetName: displayName,
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
await createPerformanceSurvey(session, {
templateCode: 'visual_brand_tone',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
openNow: false,
})
expect(prisma.performanceSurveyEmployeeProfile.findFirst).toHaveBeenCalledWith({
where: {
AND: [
{ id: { not: '__hidden__' } },
{ id: 'employee-yuanzhe' },
],
},
})
const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data
expect(assignmentRows).toEqual(expect.arrayContaining([
expect.objectContaining({
evaluatorEmployeeId: 'employee-yuanzhe',
workflowStageKey: 'final_review',
reviewerSource: 'fixed_employee',
}),
expect.objectContaining({
evaluatorEmployeeId: 'employee-declared-manager',
workflowStageKey: 'final_review',
reviewerSource: 'direct_manager',
}),
]))
})
test.each([
'集团A/摄影中心/摄影一部',
'摄影中心/摄影一部',
])('部门路径不再覆盖视觉评分的档案直属主管:%s', async (departmentPath) => {
const target = {
...baseEmployee,
id: 'employee-photographer',
displayName: '摄影师',
dingtalkUserId: 'ding-photographer',
departmentName: '摄影一部',
departmentPath,
managerDingtalkUserId: '17642061233583361',
}
const yuanzhe = {
...baseEmployee,
id: 'employee-yuanzhe',
displayName: '元泽',
dingtalkUserId: '27102756692071120919',
}
const guanshi = {
...baseEmployee,
id: 'employee-guanshi',
displayName: '观世',
dingtalkUserId: '17642061233583361',
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'visual_brand_tone',
name: '品牌调性视觉评分',
shortName: '视觉评分',
surveyType: 'visual_case',
weight: 1,
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([guanshi])
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe)
;(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: [{
id: 'target-photographer',
employeeProfileId: target.id,
targetType: 'employee',
targetName: '摄影师',
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
await createPerformanceSurvey(session, {
templateCode: 'visual_brand_tone',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
openNow: false,
})
const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0]
expect(managerQuery.where.AND[1]).toEqual({ dingtalkUserId: '17642061233583361' })
const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data
expect(assignmentRows).toEqual(expect.arrayContaining([
expect.objectContaining({
evaluatorEmployeeId: 'employee-guanshi',
workflowStageKey: 'final_review',
reviewerSource: 'direct_manager',
}),
expect.objectContaining({
evaluatorEmployeeId: 'employee-yuanzhe',
workflowStageKey: 'final_review',
reviewerSource: 'fixed_employee',
}),
]))
})
test('修改月份命中另一条同人同月同模板调研时拒绝更新', async () => {
transactionClient.performanceSurveyTarget.findMany.mockResolvedValue([{
targetName: '测试人员',
employeeProfile: null,
survey: { id: 'survey-2' },
}])
await expect(
updatePerformanceSurvey(session, 'survey-1', { assessmentMonth: '2026-07' }),
).rejects.toThrow('2026-07 已存在该模板考核:测试人员')
expect(transactionClient.performanceSurvey.update).not.toHaveBeenCalled()
})
})