567 lines
22 KiB
TypeScript
567 lines
22 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
||
|
||
export type PerformanceSurveyQuestion = {
|
||
id: string
|
||
title: string
|
||
description?: string
|
||
maxScore?: number
|
||
}
|
||
|
||
export type PerformanceSurveyQuestionScoreDetail = {
|
||
id: string
|
||
title: string
|
||
score: number | null
|
||
maxScore: number
|
||
}
|
||
|
||
export type PerformanceSurveyScoreBand = {
|
||
min: number
|
||
max: number
|
||
score?: number
|
||
outMin?: number
|
||
outMax?: number
|
||
label?: string
|
||
}
|
||
|
||
export type PerformanceSurveyAggregationStrategy = 'trim_extremes' | 'direct_average'
|
||
|
||
export type PerformanceSurveyReviewerSource =
|
||
| 'self'
|
||
| 'direct_manager'
|
||
| 'fixed_employee'
|
||
| 'manual_selection'
|
||
|
||
export type PerformanceSurveyWorkflowReviewer = {
|
||
source: PerformanceSurveyReviewerSource
|
||
label: string
|
||
employeeId?: string
|
||
employeeName?: string
|
||
}
|
||
|
||
export type PerformanceSurveyWorkflowStage = {
|
||
key: string
|
||
label: string
|
||
order: number
|
||
reviewers: PerformanceSurveyWorkflowReviewer[]
|
||
}
|
||
|
||
export type PerformanceSurveyWorkflowScoring =
|
||
| {
|
||
mode: 'final_reviewer'
|
||
stageKey: string
|
||
reviewerSource: PerformanceSurveyReviewerSource
|
||
}
|
||
| {
|
||
mode: 'average_reviewers'
|
||
stageKeys?: string[]
|
||
}
|
||
|
||
export type PerformanceSurveyWorkflowDefinition = {
|
||
stages: PerformanceSurveyWorkflowStage[]
|
||
scoring: PerformanceSurveyWorkflowScoring
|
||
}
|
||
|
||
export type PerformanceSurveyTemplateShape = {
|
||
templateVersionId?: string
|
||
templateVersion?: number
|
||
templateVersionStatus?: 'draft' | 'published' | 'archived'
|
||
effectiveMonth?: string | null
|
||
code: string
|
||
templateVariant?: PerformanceSurveyTemplateVariant
|
||
templateVariantName?: string
|
||
templateVariantVersion?: number
|
||
templateOptionKey?: string
|
||
name: string
|
||
shortName?: string
|
||
description: string
|
||
surveyType: string
|
||
ownerRoleName?: string
|
||
itemName?: string
|
||
itemNamePlaceholder?: string
|
||
itemNoteName?: string
|
||
itemNotePlaceholder?: string
|
||
defaultCategory?: string
|
||
respondentNameLabel?: string
|
||
respondentDepartmentLabel?: string
|
||
respondentNameFallback?: string
|
||
dataSource?: string
|
||
weight: number
|
||
weightLabel?: string
|
||
aggregationStrategy?: PerformanceSurveyAggregationStrategy
|
||
maxTotal?: number
|
||
questionMaxScore?: number
|
||
requiredWorkItemCount?: number
|
||
workItemName?: string
|
||
workItemImageRequired?: boolean
|
||
achievementLabel?: string
|
||
achievementSuffix?: string
|
||
trimNote?: string
|
||
trimmedNote?: string
|
||
belowBand?: string
|
||
questions: PerformanceSurveyQuestion[]
|
||
scoreBands: PerformanceSurveyScoreBand[]
|
||
workflow?: PerformanceSurveyWorkflowDefinition
|
||
}
|
||
|
||
export const PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS = {
|
||
WITH_COPY: 'with_copy',
|
||
WITHOUT_COPY: 'without_copy',
|
||
} as const
|
||
|
||
export type PerformanceSurveyTemplateVariant =
|
||
typeof PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS[keyof typeof PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS]
|
||
|
||
export const DEFAULT_PERFORMANCE_SURVEY_TEMPLATES: PerformanceSurveyTemplateShape[] = [
|
||
{
|
||
code: 'course_share',
|
||
name: '课程分享满意度调研',
|
||
shortName: '课程分享',
|
||
description: '适用于课程培训、经验分享、内部活动后的满意度与效果反馈。',
|
||
surveyType: 'person_period',
|
||
ownerRoleName: '被考核人',
|
||
itemName: '课程',
|
||
itemNamePlaceholder: '例如:5 月项目复盘分享',
|
||
itemNoteName: '课程备注',
|
||
itemNotePlaceholder: '可填写分享主题、项目背景或适用对象',
|
||
defaultCategory: '知识分享/项目复盘分享',
|
||
respondentNameLabel: '评价人',
|
||
respondentDepartmentLabel: '所属部门',
|
||
respondentNameFallback: '匿名学员',
|
||
dataSource: '现场学员评',
|
||
weight: 0.3,
|
||
weightLabel: '30%',
|
||
aggregationStrategy: 'trim_extremes',
|
||
maxTotal: 100,
|
||
achievementLabel: '满意度',
|
||
achievementSuffix: '%',
|
||
trimNote: '样本少于 3 份,暂不剔除最高分和最低分',
|
||
trimmedNote: '已剔除 1 个最高总分和 1 个最低总分',
|
||
belowBand: '满意度不足 77%,此项考核为 0',
|
||
questions: [
|
||
{ id: 'usefulness', title: '内容实用度', description: '听完能用上吗?' },
|
||
{ id: 'insight', title: '见解深度', description: '有没有独到洞察?' },
|
||
{ id: 'clarity', title: '结构清晰度', description: '听得懂、跟得上吗?' },
|
||
{ id: 'caseQuality', title: '案例质量', description: '有真实例支撑吗?' },
|
||
{ id: 'interaction', title: '互动参与感', description: '有参与感还是全程被动?' },
|
||
],
|
||
scoreBands: [
|
||
{ min: 95, max: 100, outMin: 100, outMax: 120, label: '满意度 95% 以上,对应 120-100 分' },
|
||
{ min: 89, max: 94, outMin: 85, outMax: 100, label: '满意度 94%-89%,对应 100-85 分' },
|
||
{ min: 81, max: 88, outMin: 70, outMax: 84, label: '满意度 88%-81%,对应 84-70 分' },
|
||
{ min: 77, max: 80, outMin: 60, outMax: 69, label: '满意度 80%-77%,对应 69-60 分' },
|
||
],
|
||
workflow: {
|
||
stages: [{
|
||
key: 'review',
|
||
label: '学员评价',
|
||
order: 0,
|
||
reviewers: [{ source: 'manual_selection', label: '评价人' }],
|
||
}],
|
||
scoring: { mode: 'average_reviewers', stageKeys: ['review'] },
|
||
},
|
||
},
|
||
{
|
||
code: 'cross_department',
|
||
name: '跨部门协同满意度调研',
|
||
shortName: '跨部门协同',
|
||
description: '适用于跨部门项目、流程配合、协同响应等人员周期考核。',
|
||
surveyType: 'person_period',
|
||
ownerRoleName: '负责人',
|
||
itemName: '协同事项',
|
||
itemNamePlaceholder: '例如:新品上市跨部门协同',
|
||
itemNoteName: '协同说明',
|
||
itemNotePlaceholder: '可填写协同背景、参与部门、交付目标或周期',
|
||
defaultCategory: '跨部门协同',
|
||
respondentNameLabel: '评价人',
|
||
respondentDepartmentLabel: '所属部门',
|
||
respondentNameFallback: '匿名评价人',
|
||
dataSource: '协作满意度问卷评分',
|
||
weight: 0.2,
|
||
weightLabel: '20%',
|
||
aggregationStrategy: 'trim_extremes',
|
||
maxTotal: 120,
|
||
achievementLabel: '协作得分',
|
||
achievementSuffix: '',
|
||
trimNote: '样本少于 3 份,暂不剔除最高分和最低分',
|
||
trimmedNote: '已剔除 1 个最高总分和 1 个最低总分',
|
||
belowBand: '低于 60 分,此项考核为 0',
|
||
questions: [
|
||
{ id: 'requirementClarity', title: '需求清晰度', description: '目标、需求与验收口径是否清晰?' },
|
||
{ id: 'deliveryStandard', title: '交付规范性', description: '交付物是否规范、完整、可复用?' },
|
||
{ id: 'responseSpeed', title: '响应时效性', description: '响应是否及时,关键节点是否不拖延?' },
|
||
{ id: 'collaborationFit', title: '协作配合度', description: '跨部门配合是否主动、顺畅?' },
|
||
{ id: 'communicationEffect', title: '沟通有效性', description: '信息传递是否准确、减少反复?' },
|
||
{ id: 'processControl', title: '过程可控性', description: '过程是否有预警、有节奏、有闭环?' },
|
||
],
|
||
scoreBands: [
|
||
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价,对应 120-100 分' },
|
||
{ min: 85, max: 99.9, outMin: 85, outMax: 100, label: '满意,基本达到标准,对应 100-85 分' },
|
||
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间,对应 84-70 分' },
|
||
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间,对应 69-60 分' },
|
||
],
|
||
workflow: {
|
||
stages: [{
|
||
key: 'review',
|
||
label: '协同评价',
|
||
order: 0,
|
||
reviewers: [{ source: 'manual_selection', label: '评价人' }],
|
||
}],
|
||
scoring: { mode: 'average_reviewers', stageKeys: ['review'] },
|
||
},
|
||
},
|
||
{
|
||
code: 'work_cooperation',
|
||
name: '工作配合度满意度调研',
|
||
shortName: '工作配合度',
|
||
description: '适用于常规工作协作、服务支持、团队内互评。',
|
||
surveyType: 'person_period',
|
||
ownerRoleName: '被考核成员',
|
||
itemName: '考核周期',
|
||
itemNamePlaceholder: '例如:6 月工作配合度考核',
|
||
itemNoteName: '考核说明',
|
||
itemNotePlaceholder: '可填写被考核成员、所在部门、重点协作事项或考核周期',
|
||
defaultCategory: '月度上级对成员工作配合满意度调研',
|
||
respondentNameLabel: '评价人',
|
||
respondentDepartmentLabel: '所属部门',
|
||
respondentNameFallback: '匿名主管',
|
||
dataSource: '上级主管评',
|
||
weight: 0.2,
|
||
weightLabel: '20%',
|
||
aggregationStrategy: 'trim_extremes',
|
||
maxTotal: 120,
|
||
achievementLabel: '配合度得分',
|
||
achievementSuffix: '',
|
||
trimNote: '样本少于 3 份,暂不剔除最高分和最低分',
|
||
trimmedNote: '已剔除 1 个最高总分和 1 个最低总分',
|
||
belowBand: '低于 60 分,此项考核为 0',
|
||
questions: [
|
||
{ id: 'executionDepth', title: '执行落实度', description: '交办的事放心吗?' },
|
||
{ id: 'ownership', title: '主动担责', description: '推一步走一步还是自己跑?' },
|
||
{ id: 'reportCommunication', title: '汇报沟通', description: '进度看得见吗?出事早知道吗?' },
|
||
{ id: 'adaptability', title: '灵活应变', description: '变了能不能跟着转?' },
|
||
{ id: 'teamAlignment', title: '团队补位', description: '别人忙不过来会搭把手吗?' },
|
||
{ id: 'emotionalStability', title: '情绪稳定度', description: '压力下解决问题还是制造情绪?' },
|
||
],
|
||
scoreBands: [
|
||
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价,对应 120-100 分' },
|
||
{ min: 85, max: 99.9, outMin: 85, outMax: 100, label: '满意,基本达到标准,对应 100-85 分' },
|
||
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间,对应 84-70 分' },
|
||
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间,对应 69-60 分' },
|
||
],
|
||
workflow: {
|
||
stages: [{
|
||
key: 'manager_review',
|
||
label: '主管评价',
|
||
order: 0,
|
||
reviewers: [{ source: 'direct_manager', label: '直属主管' }],
|
||
}],
|
||
scoring: { mode: 'final_reviewer', stageKey: 'manager_review', reviewerSource: 'direct_manager' },
|
||
},
|
||
},
|
||
{
|
||
code: 'visual_brand_tone',
|
||
name: '品牌调性视觉评分',
|
||
shortName: '视觉评分',
|
||
description: '适用于视觉、品牌调性、案例作品质量的看图评分。',
|
||
surveyType: 'visual_case',
|
||
ownerRoleName: '被考核人',
|
||
itemName: '案例作品',
|
||
itemNamePlaceholder: '例如:6 月品牌调性作品评审',
|
||
itemNoteName: '评分说明',
|
||
itemNotePlaceholder: '可填写作品范围、品牌要求或评审注意事项',
|
||
defaultCategory: '品牌调性视觉评分',
|
||
respondentNameLabel: '评价人',
|
||
respondentDepartmentLabel: '所属部门',
|
||
respondentNameFallback: '匿名评价人',
|
||
dataSource: '案例图片 + 评价人看图评分',
|
||
weight: 1,
|
||
weightLabel: '',
|
||
aggregationStrategy: 'direct_average',
|
||
maxTotal: 120,
|
||
questionMaxScore: 8,
|
||
requiredWorkItemCount: 3,
|
||
workItemName: '案例',
|
||
workItemImageRequired: true,
|
||
achievementLabel: '视觉得分',
|
||
achievementSuffix: '',
|
||
trimNote: '按案例独立评分后汇总',
|
||
trimmedNote: '按案例独立评分后汇总',
|
||
belowBand: '低于 60 分,此项考核为 0',
|
||
questions: [
|
||
{ id: 'brandUnderstanding', title: '品牌理解', description: '作品是否懂品牌,是否贴合品牌定位、调性和目标人群。', maxScore: 8 },
|
||
{ id: 'visualStyle', title: '视觉风格', description: '版式、色彩、质感、一致性是否成立。', maxScore: 8 },
|
||
{ id: 'copyExpression', title: '文案表达', description: '文案是否清楚、有感染力、有记忆点。', maxScore: 8 },
|
||
{ id: 'visualCopyFit', title: '图文协同', description: '视觉和文案是否互相增强。', maxScore: 8 },
|
||
{ id: 'creativeHighlight', title: '创意亮点', description: '是否有突破性表达和记忆点。', maxScore: 8 },
|
||
],
|
||
scoreBands: [
|
||
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价' },
|
||
{ min: 85, max: 99.9, outMin: 85, outMax: 99, label: '满意,基本达到标准' },
|
||
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间' },
|
||
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间' },
|
||
],
|
||
workflow: {
|
||
stages: [
|
||
{
|
||
key: 'self_review',
|
||
label: '本人自评',
|
||
order: 0,
|
||
reviewers: [{ source: 'self', label: '本人' }],
|
||
},
|
||
{
|
||
key: 'final_review',
|
||
label: '终评',
|
||
order: 1,
|
||
reviewers: [
|
||
{ source: 'direct_manager', label: '直属主管' },
|
||
{ source: 'fixed_employee', label: '终审人', employeeName: '元泽' },
|
||
],
|
||
},
|
||
],
|
||
scoring: { mode: 'final_reviewer', stageKey: 'final_review', reviewerSource: 'fixed_employee' },
|
||
},
|
||
},
|
||
]
|
||
|
||
export function getPerformanceSurveyTemplateVariant(
|
||
template: PerformanceSurveyTemplateShape,
|
||
requestedVariant?: string | null,
|
||
): PerformanceSurveyTemplateShape {
|
||
if (template.code !== 'visual_brand_tone') {
|
||
if (requestedVariant) throw new Error('非视觉评分模板不支持模板版本')
|
||
return { ...template, templateOptionKey: template.code }
|
||
}
|
||
|
||
const variant = (requestedVariant || PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS.WITH_COPY) as PerformanceSurveyTemplateVariant
|
||
if (!Object.values(PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS).includes(variant as PerformanceSurveyTemplateVariant)) {
|
||
throw new Error('视觉评分模板版本无效')
|
||
}
|
||
|
||
if (variant === PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS.WITH_COPY) {
|
||
return {
|
||
...template,
|
||
templateVariant: variant,
|
||
templateVariantName: '视觉评分(含文案)',
|
||
templateVariantVersion: 1,
|
||
templateOptionKey: `${template.code}:${variant}`,
|
||
questionMaxScore: 8,
|
||
questions: template.questions.map((question) => ({ ...question, maxScore: 8 })),
|
||
}
|
||
}
|
||
|
||
return {
|
||
...template,
|
||
templateVariant: variant,
|
||
templateVariantName: '视觉评分(不含文案)',
|
||
templateVariantVersion: 1,
|
||
templateOptionKey: `${template.code}:${variant}`,
|
||
questionMaxScore: 10,
|
||
questions: template.questions
|
||
.filter((question) => question.id !== 'copyExpression')
|
||
.map((question) => ({ ...question, maxScore: 10 })),
|
||
}
|
||
}
|
||
|
||
export function listPerformanceSurveyTemplateOptions(templates: PerformanceSurveyTemplateShape[]) {
|
||
return templates.flatMap((template) => (
|
||
template.code === 'visual_brand_tone'
|
||
? [
|
||
getPerformanceSurveyTemplateVariant(template, PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS.WITH_COPY),
|
||
getPerformanceSurveyTemplateVariant(template, PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS.WITHOUT_COPY),
|
||
]
|
||
: [getPerformanceSurveyTemplateVariant(template)]
|
||
))
|
||
}
|
||
|
||
export async function ensurePerformanceSurveyTemplates() {
|
||
for (const template of DEFAULT_PERFORMANCE_SURVEY_TEMPLATES) {
|
||
await prisma.performanceSurveyTemplate.upsert({
|
||
where: { code: template.code },
|
||
update: {},
|
||
create: {
|
||
code: template.code,
|
||
name: template.name,
|
||
description: template.description,
|
||
surveyType: template.surveyType,
|
||
weight: template.weight,
|
||
questions: template.questions,
|
||
scoreBands: template.scoreBands,
|
||
isActive: true,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
export async function listPerformanceSurveyTemplates() {
|
||
await ensurePerformanceSurveyTemplates()
|
||
|
||
const templates = await prisma.performanceSurveyTemplate.findMany({
|
||
where: { isActive: true },
|
||
orderBy: [{ surveyType: 'asc' }, { createdAt: 'asc' }],
|
||
})
|
||
|
||
return templates.map((template) => withDefaultTemplateMeta({
|
||
code: template.code,
|
||
name: template.name,
|
||
description: template.description || '',
|
||
surveyType: template.surveyType,
|
||
weight: template.weight,
|
||
questions: template.questions as PerformanceSurveyQuestion[],
|
||
scoreBands: template.scoreBands as PerformanceSurveyScoreBand[],
|
||
}))
|
||
}
|
||
|
||
export async function getPerformanceSurveyTemplate(code: string) {
|
||
await ensurePerformanceSurveyTemplates()
|
||
|
||
const template = await prisma.performanceSurveyTemplate.findUnique({
|
||
where: { code },
|
||
})
|
||
|
||
if (!template || !template.isActive) {
|
||
throw new Error('考核调研模板不存在')
|
||
}
|
||
|
||
return withDefaultTemplateMeta({
|
||
code: template.code,
|
||
name: template.name,
|
||
description: template.description || '',
|
||
surveyType: template.surveyType,
|
||
weight: template.weight,
|
||
questions: template.questions as PerformanceSurveyQuestion[],
|
||
scoreBands: template.scoreBands as PerformanceSurveyScoreBand[],
|
||
})
|
||
}
|
||
|
||
function withDefaultTemplateMeta(template: PerformanceSurveyTemplateShape): PerformanceSurveyTemplateShape {
|
||
const matchedDefault = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === template.code)
|
||
return {
|
||
...template,
|
||
shortName: template.shortName ?? matchedDefault?.shortName,
|
||
ownerRoleName: template.ownerRoleName ?? matchedDefault?.ownerRoleName,
|
||
itemName: template.itemName ?? matchedDefault?.itemName,
|
||
itemNamePlaceholder: template.itemNamePlaceholder ?? matchedDefault?.itemNamePlaceholder,
|
||
itemNoteName: template.itemNoteName ?? matchedDefault?.itemNoteName,
|
||
itemNotePlaceholder: template.itemNotePlaceholder ?? matchedDefault?.itemNotePlaceholder,
|
||
defaultCategory: template.defaultCategory ?? matchedDefault?.defaultCategory,
|
||
respondentNameLabel: template.respondentNameLabel ?? matchedDefault?.respondentNameLabel,
|
||
respondentDepartmentLabel: template.respondentDepartmentLabel ?? matchedDefault?.respondentDepartmentLabel,
|
||
respondentNameFallback: template.respondentNameFallback ?? matchedDefault?.respondentNameFallback,
|
||
dataSource: template.dataSource ?? matchedDefault?.dataSource,
|
||
weightLabel: matchedDefault?.weightLabel || `${Math.round(Number(template.weight || 0) * 100)}%`,
|
||
aggregationStrategy: template.aggregationStrategy ?? matchedDefault?.aggregationStrategy ?? 'trim_extremes',
|
||
maxTotal: template.maxTotal ?? matchedDefault?.maxTotal,
|
||
questionMaxScore: template.questionMaxScore ?? matchedDefault?.questionMaxScore,
|
||
requiredWorkItemCount: template.requiredWorkItemCount ?? matchedDefault?.requiredWorkItemCount,
|
||
workItemName: template.workItemName ?? matchedDefault?.workItemName,
|
||
workItemImageRequired: template.workItemImageRequired ?? matchedDefault?.workItemImageRequired,
|
||
achievementLabel: template.achievementLabel ?? matchedDefault?.achievementLabel,
|
||
achievementSuffix: template.achievementSuffix ?? matchedDefault?.achievementSuffix,
|
||
trimNote: template.trimNote ?? matchedDefault?.trimNote,
|
||
trimmedNote: template.trimmedNote ?? matchedDefault?.trimmedNote,
|
||
belowBand: template.belowBand ?? matchedDefault?.belowBand,
|
||
}
|
||
}
|
||
|
||
export function calculateResponseScores(
|
||
template: Pick<PerformanceSurveyTemplateShape, 'questions' | 'scoreBands' | 'weight' | 'questionMaxScore'>,
|
||
scores: Record<string, unknown>,
|
||
) {
|
||
const questionScores = template.questions.map((question) => {
|
||
const maxScore = Number(question.maxScore ?? template.questionMaxScore ?? 20)
|
||
const value = Number(scores[question.id])
|
||
if (!Number.isFinite(value) || value < 0 || value > maxScore) {
|
||
throw new Error(`评分项「${question.title}」必须是 0-${maxScore} 分`)
|
||
}
|
||
return value
|
||
})
|
||
|
||
const totalScore =
|
||
questionScores.length > 0
|
||
? questionScores.reduce((sum, score) => sum + score, 0)
|
||
: 0
|
||
const directScore = roundScore(totalScore)
|
||
|
||
return {
|
||
totalScore: directScore,
|
||
// 这三个字段是既有数据库兼容字段。考核调研不再做分档、权重或 KPI 折算,
|
||
// 新写入的数据统一保存用户实际提交的分数。
|
||
achievementScore: directScore,
|
||
kpiScore: directScore,
|
||
weightedScore: directScore,
|
||
}
|
||
}
|
||
|
||
export function getPerformanceSurveyQuestionScoreDetails(
|
||
template: Pick<PerformanceSurveyTemplateShape, 'questions' | 'questionMaxScore'>,
|
||
scores: unknown,
|
||
): PerformanceSurveyQuestionScoreDetail[] {
|
||
const scoreMap = scores && typeof scores === 'object' && !Array.isArray(scores)
|
||
? scores as Record<string, unknown>
|
||
: {}
|
||
|
||
return template.questions.map((question) => {
|
||
const value = Number(scoreMap[question.id])
|
||
const maxScore = Number(question.maxScore ?? template.questionMaxScore ?? 20)
|
||
return {
|
||
id: question.id,
|
||
title: question.title,
|
||
score: Number.isFinite(value) ? roundScore(value) : null,
|
||
maxScore: Number.isFinite(maxScore) && maxScore > 0 ? maxScore : 20,
|
||
}
|
||
})
|
||
}
|
||
|
||
export function calculateAggregateScore(
|
||
responses: Array<{
|
||
id?: string
|
||
totalScore: number
|
||
createdAt: Date | string
|
||
scores?: Record<string, unknown> | null
|
||
respondentName?: string | null
|
||
}>,
|
||
_scoreBands: PerformanceSurveyScoreBand[],
|
||
_weight: number,
|
||
template?: Pick<PerformanceSurveyTemplateShape, 'code' | 'questions' | 'trimNote' | 'trimmedNote' | 'belowBand' | 'achievementLabel' | 'achievementSuffix' | 'maxTotal' | 'weightLabel' | 'aggregationStrategy'>,
|
||
) {
|
||
const directAverage = responses.length > 0
|
||
? responses.reduce((sum, response) => sum + Number(response.totalScore || 0), 0) / responses.length
|
||
: null
|
||
const dimensionAverages = Object.fromEntries(
|
||
(template?.questions || []).map((question) => {
|
||
const values = responses
|
||
.map((response) => Number(response.scores?.[question.id]))
|
||
.filter(Number.isFinite)
|
||
const average = values.length > 0
|
||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||
: null
|
||
return [question.id, average == null ? null : roundScore(average)]
|
||
}),
|
||
)
|
||
const directScore = directAverage == null ? null : roundScore(directAverage)
|
||
|
||
return {
|
||
responseCount: responses.length,
|
||
effectiveCount: responses.length,
|
||
rawAverage: directScore,
|
||
trimmedAverage: directScore,
|
||
droppedHighestLowest: false,
|
||
trimNote: responses.length > 0 ? '按全部评分直接计算平均分' : '暂无评分',
|
||
removedHigh: null,
|
||
removedLow: null,
|
||
dimensionAverages,
|
||
achievementLabel: template?.achievementLabel,
|
||
achievementSuffix: template?.achievementSuffix,
|
||
maxTotal: template?.maxTotal,
|
||
weightLabel: template?.weightLabel,
|
||
band: null,
|
||
achievementScore: directScore,
|
||
kpiScore: directScore,
|
||
weightedScore: directScore,
|
||
}
|
||
}
|
||
|
||
function roundScore(value: number) {
|
||
return Math.round(Number(value || 0) * 100) / 100
|
||
}
|