/** @jest-environment node */ import { prisma } from '@/lib/prisma' import { assertPerformanceSurveyPositionPlanPreviewMatches, copyPerformanceSurveyTemplateVersion, createPerformanceSurveyManualPositionAssignments, getPerformanceSurveyPositionAdminConfig, listPerformanceSurveyManualPositionAssignments, listPerformanceSurveyPositionGroups, removePerformanceSurveyTemplate, savePerformanceSurveyPositionGroupDraft, savePerformanceSurveyTemplateDraft, } from '@/lib/performance-survey-position-config' import { DEFAULT_PERFORMANCE_SURVEY_TEMPLATES } from '@/lib/performance-survey-templates' jest.mock('@/lib/prisma', () => ({ prisma: { performanceSurveyPositionGroup: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn(), }, performanceSurveyPositionRule: { deleteMany: jest.fn(), createMany: jest.fn(), update: jest.fn(), }, performanceSurveyTemplateVersion: { findUnique: jest.fn(), findMany: jest.fn(), }, performanceSurveyTemplate: { create: jest.fn(), findUnique: jest.fn(), findMany: jest.fn(), }, performanceSurveyPositionPlanItem: { count: jest.fn(), }, performanceSurvey: { count: jest.fn(), }, performanceSurveyEmployeeProfile: { findMany: jest.fn(), }, performanceSurveyEmployeePositionAssignment: { findMany: jest.fn(), }, $transaction: jest.fn(), }, })) describe('performance survey position admin', () => { beforeEach(() => { jest.clearAllMocks() }) test('保存岗位组草稿只更新方案草稿,不改线上岗位组和匹配规则', async () => { const liveGroup = { id: 'group-1', code: 'brand_designer', name: '线上岗位名', sortOrder: 10, isActive: true, isSystem: true, archivedAt: null, } const existingDraft = { id: 'draft-1', positionGroupId: liveGroup.id, version: 2, status: 'draft', effectiveMonth: '2026-08', matchRules: [{ positionTitle: '线上规则' }], groupConfig: { name: liveGroup.name, sortOrder: liveGroup.sortOrder, isActive: liveGroup.isActive, }, items: [], } const updatedDraft = { ...existingDraft, matchRules: [{ positionTitle: '草稿规则' }], groupConfig: { name: '草稿岗位名', sortOrder: 20, isActive: false, }, } const tx = { performanceSurveyPositionPlan: { findFirst: jest.fn().mockResolvedValue(existingDraft), update: jest.fn().mockResolvedValue(updatedDraft), findUnique: jest.fn().mockResolvedValue(updatedDraft), }, performanceSurveyPositionPlanItem: { deleteMany: jest.fn(), createMany: jest.fn(), }, performanceSurveyPositionGroup: { findUnique: jest.fn().mockResolvedValue(liveGroup), update: jest.fn(), }, performanceSurveyPositionRule: { deleteMany: jest.fn(), createMany: jest.fn(), update: jest.fn(), }, performanceSurveyConfigAuditLog: { create: jest.fn().mockResolvedValue({ id: 'audit-1' }), }, $queryRawUnsafe: jest.fn().mockResolvedValue([{ locked: '1' }]), } ;(prisma.performanceSurveyPositionGroup.findUnique as jest.Mock).mockResolvedValue(liveGroup) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) await expect(savePerformanceSurveyPositionGroupDraft({ groupId: liveGroup.id, name: '草稿岗位名', sortOrder: 20, isActive: false, rules: [{ positionTitle: '草稿规则' }], effectiveMonth: '2026-08', reason: '配置调整', actorUserId: 'admin-1', actorName: '管理员', })).resolves.toMatchObject({ groupId: liveGroup.id, draftPlanId: existingDraft.id, version: existingDraft.version, }) expect(tx.performanceSurveyPositionPlan.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: existingDraft.id }, data: expect.objectContaining({ matchRules: [{ positionTitle: '草稿规则', departmentPathPrefix: null }], groupConfig: { name: '草稿岗位名', sortOrder: 20, isActive: false, }, }), })) expect(tx.performanceSurveyConfigAuditLog.create).toHaveBeenCalledTimes(1) expect(prisma.performanceSurveyPositionGroup.update).not.toHaveBeenCalled() expect(prisma.performanceSurveyPositionRule.deleteMany).not.toHaveBeenCalled() expect(prisma.performanceSurveyPositionRule.createMany).not.toHaveBeenCalled() expect(tx.performanceSurveyPositionGroup.update).not.toHaveBeenCalled() expect(tx.performanceSurveyPositionRule.deleteMany).not.toHaveBeenCalled() expect(tx.performanceSurveyPositionRule.createMany).not.toHaveBeenCalled() expect(tx.$queryRawUnsafe).toHaveBeenCalledWith( expect.stringContaining('pg_advisory_xact_lock'), `performance-survey-position-group:${liveGroup.id}`, ) }) test('发布只能使用发布前检查确认过的同一份草稿', () => { const draft = { id: 'draft-1', version: 3, updatedAt: new Date('2026-07-23T10:00:00.000Z'), } expect(() => assertPerformanceSurveyPositionPlanPreviewMatches(draft, { draftPlanId: draft.id, draftVersion: draft.version, draftUpdatedAt: '2026-07-23T10:00:00.000Z', })).not.toThrow() expect(() => assertPerformanceSurveyPositionPlanPreviewMatches(draft, { draftPlanId: draft.id, draftVersion: draft.version, draftUpdatedAt: '2026-07-23T10:00:01.000Z', })).toThrow('岗位方案草稿已发生变化,请重新执行发布前检查') }) test('人员手动分配原因可以留空', async () => { const assignment = { id: 'assignment-1', employeeProfileId: 'employee-1', assignmentType: 'position', positionGroupId: 'group-1', startMonth: '2026-07', endMonth: null, reason: '', status: 'active', } const tx = { $queryRawUnsafe: jest.fn().mockResolvedValue([{ locked: '1' }]), performanceSurveyEmployeePositionAssignment: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue(assignment), }, performanceSurveyConfigAuditLog: { create: jest.fn().mockResolvedValue({ id: 'audit-1' }), }, } ;(prisma.performanceSurveyPositionGroup.findUnique as jest.Mock).mockResolvedValue({ id: 'group-1', archivedAt: null, }) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{ id: 'employee-1', displayName: '张三', }]) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) await expect(createPerformanceSurveyManualPositionAssignments({ employeeIds: ['employee-1'], positionGroupId: 'group-1', assignmentType: 'position', effectiveFromMonth: '2026-07', effectiveToMonth: null, reason: '', actorUserId: 'admin-1', actorName: '管理员', })).resolves.toMatchObject({ count: 1 }) expect(tx.performanceSurveyEmployeePositionAssignment.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reason: '' }), }) }) test('管理员人员分配可搜索没有素材库账号的在职花名册人员', async () => { ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{ id: 'employee-xiangzhang', displayName: '香樟', flowerName: '香樟', realName: '马震宇', jobNumber: 'JS06002901', departmentName: '品牌设计部', departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌设计部', positionTitle: '品牌设计师', positionLevel: 'P6', levelType: 'P', isLeadership: false, isDingtalkAdmin: false, isDingtalkBoss: false, isDingtalkSenior: false, departmentMemberships: [], }]) ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.performanceSurveyEmployeePositionAssignment.findMany as jest.Mock).mockResolvedValue([]) const params = new URLSearchParams({ month: '2026-07', search: '香樟', }) await expect(listPerformanceSurveyManualPositionAssignments(params)).resolves.toMatchObject({ total: 1, items: [{ employeeId: 'employee-xiangzhang', displayName: '香樟', }], }) const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0] // 用在职状态过滤,而不是按"有没有素材库账号"过滤。 // 判定字段已从 employmentStatus(上游原样值,口径混杂)改为规范化的 employmentState。 expect(JSON.stringify(query.where)).toContain('employmentState') expect(JSON.stringify(query.where)).not.toContain('materialUserId') expect(JSON.stringify(query.where)).not.toContain('materialUserEmail') }) test('模板管理配置只查询模板,不扫描岗位和人员档案', async () => { ;(prisma.performanceSurveyTemplate.findMany as jest.Mock).mockResolvedValue([{ id: 'template-1', code: 'visual_review', name: '视觉评分', description: '视觉作品评分', surveyType: 'visual_case', isActive: true, archivedAt: null, archiveReason: null, versions: [{ id: 'version-1', version: 1, status: 'published', effectiveMonth: '2026-07', definition: structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]), publishedAt: new Date('2026-07-01T00:00:00.000Z'), createdByUserId: 'admin-1', createdByName: '管理员', createdAt: new Date('2026-07-01T00:00:00.000Z'), updatedAt: new Date('2026-07-01T00:00:00.000Z'), }], }]) await expect(getPerformanceSurveyPositionAdminConfig('2026-07', 'templates')).resolves.toMatchObject({ month: '2026-07', groups: [], templates: [{ id: 'template-1', code: 'visual_review' }], issueCounts: { unmatched: 0, conflicts: 0, excluded: 0 }, }) expect(prisma.performanceSurveyTemplate.findMany).toHaveBeenCalledTimes(1) expect(prisma.performanceSurveyPositionGroup.findMany).not.toHaveBeenCalled() expect(prisma.performanceSurveyEmployeeProfile.findMany).not.toHaveBeenCalled() }) test('岗位管理配置返回每个岗位的具体人员名单', async () => { ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValue([{ id: 'group-creative', code: 'ai_planning_creative', name: 'AI策划-创意部', sortOrder: 10, isActive: true, isSystem: true, archivedAt: null, archiveReason: null, rules: [], plans: [], _count: { surveys: 1, manualAssignments: 1 }, }]) ;(prisma.performanceSurveyTemplate.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{ id: 'employee-ruolan', displayName: '若兰', departmentName: '品牌创意B部', departmentPath: '集团/品牌中心/品牌创意B部', positionTitle: '营销策划主管', departmentMemberships: [], }]) ;(prisma.performanceSurveyEmployeePositionAssignment.findMany as jest.Mock).mockResolvedValue([{ id: 'assignment-ruolan', employeeProfileId: 'employee-ruolan', assignmentType: 'position', positionGroupId: 'group-creative', status: 'active', startMonth: '2026-07', endMonth: null, createdAt: new Date('2026-07-01T00:00:00.000Z'), }]) await expect(getPerformanceSurveyPositionAdminConfig('2026-07', 'positions')).resolves.toMatchObject({ groups: [{ id: 'group-creative', memberCount: 1, members: [{ id: 'employee-ruolan', displayName: '若兰', departmentName: '品牌创意B部', positionTitle: '营销策划主管', }], }], }) }) test('复制模板会创建独立模板和完整草稿版本', async () => { const definition = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]) ;(prisma.performanceSurveyTemplateVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'version-1', templateId: 'template-1', version: 2, status: 'published', definition, template: { id: 'template-1', code: definition.code, name: definition.name, archivedAt: null, }, }) const tx = { performanceSurveyTemplate: { create: jest.fn().mockImplementation(async ({ data }) => ({ id: 'template-copy', ...data })), }, performanceSurveyTemplateVersion: { create: jest.fn().mockImplementation(async ({ data }) => ({ id: 'version-copy', ...data })), }, performanceSurveyConfigAuditLog: { create: jest.fn().mockResolvedValue({ id: 'audit-copy' }), }, } ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) await expect(copyPerformanceSurveyTemplateVersion({ versionId: 'version-1', actorUserId: 'admin-1', actorName: '管理员', })).resolves.toMatchObject({ id: 'version-copy', templateId: 'template-copy', name: `${definition.name}(副本)`, }) const templateData = tx.performanceSurveyTemplate.create.mock.calls[0][0].data const versionData = tx.performanceSurveyTemplateVersion.create.mock.calls[0][0].data expect(templateData.code).toMatch(/^template_copy_[a-f0-9]{32}$/) expect(templateData.name).toBe(`${definition.name}(副本)`) expect(versionData).toMatchObject({ templateId: 'template-copy', version: 1, status: 'draft', definition: expect.objectContaining({ code: templateData.code, name: `${definition.name}(副本)`, shortName: `${definition.name}(副本)`, questions: definition.questions, workflow: definition.workflow, }), }) expect(tx.performanceSurveyConfigAuditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'template.copied', targetId: 'template-copy', }), }) }) test('修改已发布模板直接更新原记录并同步模板资料', async () => { const definition = { ...structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]), name: '更新后的模板名称', shortName: '更新后的模板名称', } const existing = { id: 'version-1', templateId: 'template-1', version: 2, status: 'published', definition: structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]), updatedAt: new Date('2026-07-29T08:00:00.000Z'), template: { id: 'template-1', code: definition.code, name: '原模板名称', archivedAt: null, }, } const saved = { ...existing, definition, updatedAt: new Date('2026-07-29T09:00:00.000Z'), } const tx = { performanceSurveyTemplateVersion: { findUnique: jest.fn() .mockResolvedValueOnce(existing) .mockResolvedValueOnce(saved), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, performanceSurveyTemplate: { update: jest.fn().mockResolvedValue({ id: 'template-1' }), }, performanceSurveyConfigAuditLog: { create: jest.fn().mockResolvedValue({ id: 'audit-update' }), }, $queryRawUnsafe: jest.fn().mockResolvedValue([{ locked: '1' }]), } ;(prisma.performanceSurveyTemplateVersion.findUnique as jest.Mock).mockResolvedValue(existing) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) await expect(savePerformanceSurveyTemplateDraft({ versionId: existing.id, definition, actorUserId: 'admin-1', actorName: '管理员', })).resolves.toMatchObject({ id: existing.id, status: 'published', definition: expect.objectContaining({ name: '更新后的模板名称' }), }) expect(tx.performanceSurveyTemplateVersion.updateMany).toHaveBeenCalledWith({ where: { id: existing.id, status: 'published', updatedAt: existing.updatedAt, }, data: { definition, createdByUserId: 'admin-1', createdByName: '管理员', }, }) expect(tx.performanceSurveyTemplate.update).toHaveBeenCalledWith({ where: { id: 'template-1' }, data: expect.objectContaining({ name: '更新后的模板名称' }), }) expect(tx.performanceSurveyConfigAuditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'template.version_updated' }), }) }) test('当前或未来岗位方案仍在使用模板时拒绝删除', async () => { ;(prisma.performanceSurveyTemplate.findUnique as jest.Mock).mockResolvedValue({ id: 'template-1', code: 'visual_review', versions: [{ id: 'version-1', status: 'published' }], }) ;(prisma.performanceSurveyPositionPlanItem.count as jest.Mock).mockResolvedValue(1) ;(prisma.performanceSurvey.count as jest.Mock).mockResolvedValue(2) ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValue([{ name: '品牌设计师', plans: [{ id: 'plan-1', status: 'published', effectiveMonth: '2026-07', version: 1, items: [{ id: 'item-1' }], }], }]) await expect(removePerformanceSurveyTemplate({ templateId: 'template-1', actorUserId: 'admin-1', actorName: '管理员', reason: '不再使用', })).rejects.toThrow('请先从岗位考核项中移除并发布新方案') expect(prisma.$transaction).not.toHaveBeenCalled() }) test('历史查询保留已删除岗位和模板,常规查询默认排除已删除岗位', async () => { const definition = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]) ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValueOnce([{ id: 'group-archived', code: 'archived-group', name: '已删除岗位', sortOrder: 10, isActive: true, isSystem: false, archivedAt: new Date('2026-07-01T00:00:00.000Z'), plans: [{ id: 'plan-history', status: 'published', effectiveMonth: '2026-06', version: 1, groupConfig: { name: '已删除岗位', sortOrder: 10, isActive: true, }, matchRules: [], items: [{ id: 'item-history', isEnabled: true, sortOrder: 10, templateVersion: { id: 'version-history', version: 1, status: 'published', effectiveMonth: '2026-01', definition, template: { archivedAt: new Date('2026-07-01T00:00:00.000Z'), }, }, }], }], }]) await expect(listPerformanceSurveyPositionGroups('2026-06', { includeArchived: true, })).resolves.toMatchObject([{ id: 'group-archived', status: 'ready', templates: [{ templateVersionId: 'version-history', templateArchivedAt: new Date('2026-07-01T00:00:00.000Z'), }], }]) expect(prisma.performanceSurveyPositionGroup.findMany).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: undefined }), ) ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValueOnce([]) await expect(listPerformanceSurveyPositionGroups('2026-07')).resolves.toEqual([]) expect(prisma.performanceSurveyPositionGroup.findMany).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: { archivedAt: null } }), ) }) test('包含历史岗位时,已删除岗位不会在删除月份及之后重新激活', async () => { const archivedGroup = { id: 'group-archived', code: 'archived-group', name: '旧岗位', sortOrder: 10, isActive: true, isSystem: false, // 北京时间 7 月 1 日 00:30,UTC 仍是 6 月;业务月份必须按北京时间判断。 archivedAt: new Date('2026-06-30T16:30:00.000Z'), plans: [], } const activeGroup = { id: 'group-active', code: 'active-group', name: '新岗位', sortOrder: 20, isActive: true, isSystem: false, archivedAt: null, plans: [], } ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock) .mockResolvedValue([archivedGroup, activeGroup]) await expect(listPerformanceSurveyPositionGroups('2026-07', { includeArchived: true, })).resolves.toMatchObject([ { id: 'group-archived', isActive: false, status: 'disabled', }, { id: 'group-active', isActive: true, }, ]) }) })