gongxiangp2/lib/performance-survey-templates.ts

404 lines
16 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.

import { prisma } from '@/lib/prisma'
export type PerformanceSurveyQuestion = {
id: string
title: string
description?: string
}
export type PerformanceSurveyScoreBand = {
min: number
max: number
score?: number
outMin?: number
outMax?: number
label?: string
}
export type PerformanceSurveyTemplateShape = {
code: 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
maxTotal?: number
achievementLabel?: string
achievementSuffix?: string
trimNote?: string
trimmedNote?: string
belowBand?: string
questions: PerformanceSurveyQuestion[]
scoreBands: PerformanceSurveyScoreBand[]
}
export const DEFAULT_PERFORMANCE_SURVEY_TEMPLATES: PerformanceSurveyTemplateShape[] = [
{
code: 'course_share',
name: '课程分享满意度调研',
shortName: '课程分享',
description: '适用于课程培训、经验分享、内部活动后的满意度与效果反馈。',
surveyType: 'item_course',
ownerRoleName: '讲师',
itemName: '课程',
itemNamePlaceholder: '例如:5 月项目复盘分享',
itemNoteName: '课程备注',
itemNotePlaceholder: '可填写分享主题、项目背景或适用对象',
defaultCategory: '知识分享/项目复盘分享',
respondentNameLabel: '评价人',
respondentDepartmentLabel: '所属部门',
respondentNameFallback: '匿名学员',
dataSource: '现场学员评',
weight: 0.3,
weightLabel: '30%',
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 分' },
],
},
{
code: 'cross_department',
name: '跨部门协同满意度调研',
shortName: '跨部门协同',
description: '适用于跨部门项目、流程配合、协同响应等人员周期考核。',
surveyType: 'person_period',
ownerRoleName: '负责人',
itemName: '协同事项',
itemNamePlaceholder: '例如:新品上市跨部门协同',
itemNoteName: '协同说明',
itemNotePlaceholder: '可填写协同背景、参与部门、交付目标或周期',
defaultCategory: '跨部门协同',
respondentNameLabel: '评价人',
respondentDepartmentLabel: '所属部门',
respondentNameFallback: '匿名评价人',
dataSource: '协作满意度问卷评分',
weight: 0.2,
weightLabel: '20%',
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 分' },
],
},
{
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%',
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 分' },
],
},
]
export async function ensurePerformanceSurveyTemplates() {
for (const template of DEFAULT_PERFORMANCE_SURVEY_TEMPLATES) {
await prisma.performanceSurveyTemplate.upsert({
where: { code: template.code },
update: {
name: template.name,
description: template.description,
surveyType: template.surveyType,
weight: template.weight,
questions: template.questions,
scoreBands: template.scoreBands,
isActive: true,
},
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)}%`,
maxTotal: template.maxTotal ?? matchedDefault?.maxTotal,
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'>,
scores: Record<string, unknown>,
) {
const questionScores = template.questions.map((question) => {
const value = Number(scores[question.id])
if (!Number.isFinite(value) || value < 0 || value > 20) {
throw new Error(`评分项「${question.title}」必须是 0-20 分`)
}
return value
})
const totalScore =
questionScores.length > 0
? questionScores.reduce((sum, score) => sum + score, 0)
: 0
const kpiScore = mapAchievementToKpi(totalScore, template.scoreBands).score
return {
totalScore: roundScore(totalScore),
achievementScore: roundScore(totalScore),
kpiScore: roundScore(kpiScore),
weightedScore: roundScore(kpiScore * Number(template.weight || 1)),
}
}
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, 'questions' | 'trimNote' | 'trimmedNote' | 'belowBand' | 'achievementLabel' | 'achievementSuffix' | 'maxTotal' | 'weightLabel'>,
) {
const sorted = [...responses].sort((left, right) => {
const scoreDiff = Number(left.totalScore || 0) - Number(right.totalScore || 0)
if (scoreDiff !== 0) return scoreDiff
return new Date(String(left.createdAt)).getTime() - new Date(String(right.createdAt)).getTime()
})
const rawAverage =
sorted.length > 0
? sorted.reduce((sum, response) => sum + Number(response.totalScore || 0), 0) / sorted.length
: null
const droppedHighestLowest = sorted.length >= 3
const removedLow = droppedHighestLowest ? sorted[0] : null
const removedHigh = droppedHighestLowest ? sorted[sorted.length - 1] : null
const effective = droppedHighestLowest ? sorted.slice(1, -1) : sorted
const achievementScore =
effective.length > 0
? effective.reduce((sum, response) => sum + Number(response.totalScore || 0), 0) / effective.length
: 0
const kpi = mapAchievementToKpi(effective.length > 0 ? achievementScore : null, scoreBands, template?.belowBand)
const dimensionAverages = Object.fromEntries(
(template?.questions || []).map((question) => {
const average =
effective.length > 0
? effective.reduce((sum, response) => sum + Number(response.scores?.[question.id] || 0), 0) / effective.length
: null
return [question.id, average == null ? null : roundScore(average)]
}),
)
return {
responseCount: responses.length,
effectiveCount: effective.length,
rawAverage: rawAverage == null ? null : roundScore(rawAverage),
trimmedAverage: effective.length > 0 ? roundScore(achievementScore) : null,
droppedHighestLowest,
trimNote: droppedHighestLowest
? template?.trimmedNote || '已剔除 1 个最高总分和 1 个最低总分'
: template?.trimNote || '样本少于 3 份,暂不剔除最高分和最低分',
removedHigh: summarizeRemovedResponse(removedHigh),
removedLow: summarizeRemovedResponse(removedLow),
dimensionAverages,
achievementLabel: template?.achievementLabel,
achievementSuffix: template?.achievementSuffix,
maxTotal: template?.maxTotal,
weightLabel: template?.weightLabel,
band: kpi.band,
achievementScore: roundScore(achievementScore),
kpiScore: roundScore(kpi.score),
weightedScore: roundScore(kpi.score * Number(weight || 1)),
}
}
function summarizeRemovedResponse(response: {
id?: string
totalScore: number
createdAt: Date | string
respondentName?: string | null
} | null) {
if (!response) return null
return {
id: response.id,
totalScore: roundScore(response.totalScore),
respondentName: response.respondentName || '匿名评价',
createdAt: response.createdAt,
}
}
function mapAchievementToKpi(value: number | null, bands: PerformanceSurveyScoreBand[], belowBand?: string) {
if (value === null || value === undefined) {
return { score: 0, band: '暂无数据' }
}
const normalizedBands = [...bands].sort((left, right) => left.min - right.min)
const matched = normalizedBands.find((band) => value >= band.min && value <= band.max)
if (!matched) {
if (value < normalizedBands[0]?.min) return { score: 0, band: belowBand || '未达到最低档位' }
const last = normalizedBands[normalizedBands.length - 1]
return { score: last?.outMax ?? last?.score ?? 0, band: last?.label || '' }
}
if (matched.outMin != null && matched.outMax != null) {
const ratio = (value - matched.min) / (matched.max - matched.min)
return {
score: matched.outMin + ratio * (matched.outMax - matched.outMin),
band: matched.label || '',
}
}
const previous = normalizedBands
.filter((band) => band.max <= matched.min)
.sort((left, right) => right.max - left.max)[0]
if (!previous || matched.max === matched.min) {
return { score: matched.score ?? 0, band: matched.label || '' }
}
const ratio = (value - matched.min) / (matched.max - matched.min)
return {
score: (previous.score ?? 0) + ratio * ((matched.score ?? 0) - (previous.score ?? 0)),
band: matched.label || '',
}
}
function roundScore(value: number) {
return Math.round(Number(value || 0) * 100) / 100
}