gongxiangp2/lib/__tests__/performance-survey-position-admin.test.ts
chenpei 7b0ee5efb4 feat: 人员架构统一到钉钉唯一值,接入上游组织架构动态同步
素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时
逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随
上游 HR 系统自动更新。

人员身份
- 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一
- users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号
- 移除钉钉登录的同名自动合并,同名冲突一律转人工核对
- 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权

组织架构
- 新增 OrganizationUnit,直接镜像上游 HR 部门树
- 不再从员工部门路径倒推,同名部门不会再被合并
- 上游已删除的部门下线而非物理删除

人员状态
- 新增规范化的 employmentState,取代对混杂原始值做关键词匹配
- 接入上游离职表,离职判定不再依赖中文关键词
- 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者

素材归属
- 归属变更纳入正式转移,记录最初上传者、转移流水与理由
- 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名
- 编辑素材的权限判定统一到 canManageMaterial
- 修复任务-详情上传把邮箱误存为作者名的问题

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:53:48 +08:00

620 lines
21 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 { 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,
},
])
})
})