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, scores: Record, ) { 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, scores: unknown, ): PerformanceSurveyQuestionScoreDetail[] { const scoreMap = scores && typeof scores === 'object' && !Array.isArray(scores) ? scores as Record : {} 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 | null respondentName?: string | null }>, _scoreBands: PerformanceSurveyScoreBand[], _weight: number, template?: Pick, ) { 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 }