gongxiangp2/lib/performance-survey-templates.ts
2026-07-23 09:25:50 +08:00

567 lines
22 KiB
TypeScript
Raw Permalink 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
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
}