404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
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
|
||
}
|