289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import {
|
|
PERFORMANCE_SURVEY_POSITION_GROUP_SEEDS,
|
|
createPerformanceSurveyPositionGroupIdentity,
|
|
getAvailablePerformanceSurveyPositionTemplates,
|
|
matchesPerformanceSurveyPositionRule,
|
|
paginatePerformanceSurveyTemplates,
|
|
performanceSurveyMonthRangesOverlap,
|
|
resolvePerformanceSurveyPositionMembership,
|
|
validatePerformanceSurveyPositionPlanTemplateVersions,
|
|
validatePerformanceSurveyTemplateDefinition,
|
|
type PositionGroupRuntime,
|
|
} from '@/lib/performance-survey-position-config'
|
|
import { DEFAULT_PERFORMANCE_SURVEY_TEMPLATES } from '@/lib/performance-survey-templates'
|
|
|
|
function group(input: Partial<PositionGroupRuntime> & Pick<PositionGroupRuntime, 'id' | 'code' | 'name'>): PositionGroupRuntime {
|
|
return {
|
|
sortOrder: 0,
|
|
isActive: true,
|
|
isSystem: false,
|
|
archivedAt: null,
|
|
status: 'ready',
|
|
planId: 'plan-1',
|
|
planVersion: 1,
|
|
effectiveMonth: '2026-07',
|
|
rules: [],
|
|
templates: [],
|
|
...input,
|
|
}
|
|
}
|
|
|
|
describe('performance survey position configuration', () => {
|
|
test('新岗位内部标识由系统生成,不依赖管理员填写代码', () => {
|
|
const identity = createPerformanceSurveyPositionGroupIdentity()
|
|
expect(identity.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
expect(identity.code).toBe(`position_${identity.id.replace(/-/g, '')}`)
|
|
})
|
|
|
|
test('预置八个岗位组且代码稳定唯一', () => {
|
|
expect(PERFORMANCE_SURVEY_POSITION_GROUP_SEEDS).toHaveLength(8)
|
|
expect(new Set(PERFORMANCE_SURVEY_POSITION_GROUP_SEEDS.map((item) => item.code)).size).toBe(8)
|
|
})
|
|
|
|
test('已删除模板保留在历史岗位方案中,但不能用于新发起', () => {
|
|
const activeTemplate = {
|
|
...structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]),
|
|
templateVersionId: 'version-active',
|
|
templateArchivedAt: null,
|
|
}
|
|
const archivedTemplate = {
|
|
...structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[1]),
|
|
templateVersionId: 'version-archived',
|
|
templateArchivedAt: new Date('2026-07-01T00:00:00.000Z'),
|
|
}
|
|
const positionGroup = group({
|
|
id: 'history-group',
|
|
code: 'history-group',
|
|
name: '历史岗位',
|
|
templates: [activeTemplate, archivedTemplate],
|
|
})
|
|
|
|
expect(positionGroup.templates).toHaveLength(2)
|
|
expect(getAvailablePerformanceSurveyPositionTemplates(positionGroup)).toEqual([activeTemplate])
|
|
})
|
|
|
|
test('单条规则内部门和职位按 AND 匹配,部门路径支持子部门', () => {
|
|
const rule = { departmentPathPrefix: '集团/品牌中心', positionTitle: 'AI策划' }
|
|
expect(matchesPerformanceSurveyPositionRule({
|
|
departmentPath: '集团/品牌中心/品牌创意A部',
|
|
positionTitle: 'AI策划',
|
|
}, rule)).toBe(true)
|
|
expect(matchesPerformanceSurveyPositionRule({
|
|
departmentPath: '集团/综合中心',
|
|
positionTitle: 'AI策划',
|
|
}, rule)).toBe(false)
|
|
expect(matchesPerformanceSurveyPositionRule({
|
|
departmentPath: '集团/品牌中心/品牌创意A部',
|
|
positionTitle: '品牌设计师',
|
|
}, rule)).toBe(false)
|
|
})
|
|
|
|
test('同组多条规则按 OR 匹配,跨组重复命中明确返回冲突', () => {
|
|
const creative = group({
|
|
id: 'creative',
|
|
code: 'creative',
|
|
name: '创意',
|
|
rules: [
|
|
{ departmentPathPrefix: '集团/创意一部', positionTitle: 'AI策划' },
|
|
{ departmentPathPrefix: '集团/创意二部', positionTitle: 'AI策划' },
|
|
],
|
|
})
|
|
const duplicate = group({
|
|
id: 'duplicate',
|
|
code: 'duplicate',
|
|
name: '重复组',
|
|
rules: [{ positionTitle: 'AI策划' }],
|
|
})
|
|
const employee = { departmentPath: '集团/创意二部', positionTitle: 'AI策划' }
|
|
|
|
expect(resolvePerformanceSurveyPositionMembership(employee, [creative])).toMatchObject({
|
|
status: 'matched',
|
|
group: { code: 'creative' },
|
|
})
|
|
expect(resolvePerformanceSurveyPositionMembership(employee, [creative, duplicate])).toMatchObject({
|
|
status: 'conflict',
|
|
group: null,
|
|
})
|
|
expect(resolvePerformanceSurveyPositionMembership({
|
|
departmentPath: '集团/财务部',
|
|
positionTitle: '会计',
|
|
}, [creative])).toMatchObject({ status: 'unmatched', group: null })
|
|
})
|
|
|
|
test('兼任部门参与岗位匹配,跨部门命中不同岗位时明确返回冲突', () => {
|
|
const primaryGroup = group({
|
|
id: 'primary',
|
|
code: 'primary',
|
|
name: '营销策划',
|
|
rules: [{ departmentPathPrefix: '集团/营销策划中心', positionTitle: '营销策划主管' }],
|
|
})
|
|
const secondaryGroup = group({
|
|
id: 'secondary',
|
|
code: 'secondary',
|
|
name: '品牌创意B部',
|
|
rules: [{ departmentPathPrefix: '集团/品牌中心/品牌创意B部', positionTitle: '营销策划主管' }],
|
|
})
|
|
const employee = {
|
|
departmentPath: '集团/营销策划中心',
|
|
positionTitle: '营销策划主管',
|
|
departmentMemberships: [
|
|
{
|
|
departmentName: '营销策划中心',
|
|
departmentPath: '集团/营销策划中心',
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
departmentName: '品牌创意B部',
|
|
departmentPath: '集团/品牌中心/品牌创意B部',
|
|
isPrimary: false,
|
|
},
|
|
],
|
|
}
|
|
|
|
expect(matchesPerformanceSurveyPositionRule(employee, secondaryGroup.rules[0])).toBe(true)
|
|
expect(resolvePerformanceSurveyPositionMembership(employee, [secondaryGroup])).toMatchObject({
|
|
status: 'matched',
|
|
group: { code: 'secondary' },
|
|
})
|
|
expect(resolvePerformanceSurveyPositionMembership(employee, [primaryGroup, secondaryGroup])).toMatchObject({
|
|
status: 'conflict',
|
|
group: null,
|
|
matches: [
|
|
{ code: 'primary' },
|
|
{ code: 'secondary' },
|
|
],
|
|
})
|
|
})
|
|
|
|
test('人工岗位或排除配置优先于自动岗位规则', () => {
|
|
const automatic = group({
|
|
id: 'automatic',
|
|
code: 'automatic',
|
|
name: '自动岗位',
|
|
rules: [{ positionTitle: 'AI策划' }],
|
|
})
|
|
const manual = group({
|
|
id: 'manual',
|
|
code: 'manual',
|
|
name: '人工岗位',
|
|
rules: [],
|
|
})
|
|
const employee = { positionTitle: 'AI策划' }
|
|
|
|
expect(resolvePerformanceSurveyPositionMembership(employee, [automatic, manual], {
|
|
id: 'assignment-position',
|
|
assignmentType: 'position',
|
|
positionGroupId: manual.id,
|
|
})).toMatchObject({
|
|
status: 'matched',
|
|
group: { id: manual.id },
|
|
source: 'manual',
|
|
manualAssignmentId: 'assignment-position',
|
|
})
|
|
|
|
expect(resolvePerformanceSurveyPositionMembership(employee, [automatic, manual], {
|
|
id: 'assignment-excluded',
|
|
assignmentType: 'excluded',
|
|
})).toEqual({
|
|
status: 'excluded',
|
|
group: null,
|
|
matches: [],
|
|
source: 'manual',
|
|
manualAssignmentId: 'assignment-excluded',
|
|
})
|
|
})
|
|
|
|
test('人工岗位月份区间按闭区间判断重叠', () => {
|
|
expect(performanceSurveyMonthRangesOverlap(
|
|
{ startMonth: '2026-01', endMonth: '2026-03' },
|
|
{ startMonth: '2026-03', endMonth: '2026-05' },
|
|
)).toBe(true)
|
|
expect(performanceSurveyMonthRangesOverlap(
|
|
{ startMonth: '2026-01', endMonth: '2026-02' },
|
|
{ startMonth: '2026-03', endMonth: null },
|
|
)).toBe(false)
|
|
expect(performanceSurveyMonthRangesOverlap(
|
|
{ startMonth: '2026-03', endMonth: null },
|
|
{ startMonth: '2099-01', endMonth: '2099-12' },
|
|
)).toBe(true)
|
|
})
|
|
|
|
test('岗位方案拒绝重复和未发布模板,但不限制模板与岗位的生效月份先后', () => {
|
|
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
|
|
{ id: 'version-1', templateId: 'template-1', status: 'published', effectiveMonth: '2026-01' },
|
|
{ id: 'version-2', templateId: 'template-1', status: 'published', effectiveMonth: '2026-02' },
|
|
], '2026-03')).toThrow('同一模板只能选择一个版本')
|
|
|
|
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
|
|
{ id: 'version-1', templateId: 'template-1', status: 'draft', effectiveMonth: '2026-01' },
|
|
], '2026-03')).toThrow('只能选择已发布模板版本')
|
|
|
|
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
|
|
{ id: 'version-1', templateId: 'template-1', status: 'published', effectiveMonth: '2026-04' },
|
|
], '2026-03')).not.toThrow()
|
|
})
|
|
|
|
test('看板考核项固定每页最多四项并钳制越界页码', () => {
|
|
const items = Array.from({ length: 9 }, (_, index) => `item-${index + 1}`)
|
|
expect(paginatePerformanceSurveyTemplates(items, 1)).toEqual({
|
|
items: ['item-1', 'item-2', 'item-3', 'item-4'],
|
|
pager: { page: 1, pageSize: 4, totalPages: 3, totalItems: 9, start: 1, end: 4 },
|
|
})
|
|
expect(paginatePerformanceSurveyTemplates(items, 2)).toMatchObject({
|
|
items: ['item-5', 'item-6', 'item-7', 'item-8'],
|
|
pager: { page: 2, start: 5, end: 8 },
|
|
})
|
|
expect(paginatePerformanceSurveyTemplates(items, 99)).toMatchObject({
|
|
items: ['item-9'],
|
|
pager: { page: 3, start: 9, end: 9 },
|
|
})
|
|
expect(paginatePerformanceSurveyTemplates([], 3).pager).toEqual({
|
|
page: 1,
|
|
pageSize: 4,
|
|
totalPages: 1,
|
|
totalItems: 0,
|
|
start: 0,
|
|
end: 0,
|
|
})
|
|
})
|
|
|
|
test('一个完整考核项也允许发布,不要求凑满四项', () => {
|
|
const template = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0])
|
|
template.questions = [template.questions[0]]
|
|
template.maxTotal = Number(template.questions[0].maxScore || template.questionMaxScore || 20)
|
|
expect(() => validatePerformanceSurveyTemplateDefinition(template)).not.toThrow()
|
|
})
|
|
|
|
test('模板总分必须与评分项满分合计一致', () => {
|
|
const template = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0])
|
|
template.maxTotal = Number(template.maxTotal || 0) + 1
|
|
expect(() => validatePerformanceSurveyTemplateDefinition(template)).toThrow('与模板总分')
|
|
})
|
|
|
|
test('拒绝空题目、重复阶段顺序和不唯一的终审计分人', () => {
|
|
const empty = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0])
|
|
empty.questions = []
|
|
expect(() => validatePerformanceSurveyTemplateDefinition(empty)).toThrow('至少需要 1 个评分项')
|
|
|
|
const duplicateOrder = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0])
|
|
duplicateOrder.workflow!.stages.push({
|
|
key: 'another',
|
|
label: '第二阶段',
|
|
order: duplicateOrder.workflow!.stages[0].order,
|
|
reviewers: [{ source: 'self', label: '本人' }],
|
|
})
|
|
expect(() => validatePerformanceSurveyTemplateDefinition(duplicateOrder)).toThrow('评审阶段顺序重复')
|
|
|
|
const ambiguousFinal = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[3])
|
|
ambiguousFinal.workflow!.stages[1].reviewers.push({
|
|
source: 'fixed_employee',
|
|
label: '另一位终审人',
|
|
employeeId: 'employee-2',
|
|
})
|
|
ambiguousFinal.workflow!.stages[1].reviewers[1].employeeId = 'employee-1'
|
|
expect(() => validatePerformanceSurveyTemplateDefinition(ambiguousFinal)).toThrow('终审计分人员必须唯一')
|
|
})
|
|
})
|