import { randomUUID } from 'crypto' import { open, rm, unlink } from 'fs/promises' import path from 'path' import type { Session } from 'next-auth' import { pinyin } from 'pinyin-pro' import { prisma } from '@/lib/prisma' import { getDingtalkPerformanceSurveyNotificationChannel, getDingtalkRobotOtoConfigStatus, getDingtalkRobotOtoTestUserIds, getDingtalkWorkNoticeConfigStatus, } from '@/lib/dingtalk-client' import { calculateAggregateScore, calculateResponseScores, getPerformanceSurveyQuestionScoreDetails, getPerformanceSurveyTemplate, getPerformanceSurveyTemplateVariant, listPerformanceSurveyTemplateOptions, listPerformanceSurveyTemplates, type PerformanceSurveyTemplateShape, type PerformanceSurveyReviewerSource, type PerformanceSurveyWorkflowStage, } from '@/lib/performance-survey-templates' import { PerformanceSurveyPositionConfigError, archivePerformanceSurveyManualPositionAssignment, copyPerformanceSurveyPositionGroup, createPerformanceSurveyManualPositionAssignments, createPerformanceSurveyPositionGroup, copyPerformanceSurveyTemplateVersion, getAvailablePerformanceSurveyPositionTemplates, getManualReviewerStages, getPerformanceSurveyPositionAdminConfig, getPerformanceSurveyPositionGroupByCode, getPerformanceSurveyPositionGroupForEmployee, getPerformanceSurveyPositionMembershipsForEmployees, listPerformanceSurveyConfigAuditLogs, listPerformanceSurveyManualPositionAssignments, listPerformanceSurveyPositionGroups, normalizePerformanceSurveyAssessmentMonth, paginatePerformanceSurveyTemplates, publishPerformanceSurveyPositionPlan, publishPerformanceSurveyTemplateVersion, previewPerformanceSurveyPositionPlanImpact, removePerformanceSurveyPositionGroup, removePerformanceSurveyTemplate, savePerformanceSurveyPositionGroupDraft, savePerformanceSurveyTemplateDraft, updatePerformanceSurveyManualPositionAssignment, } from '@/lib/performance-survey-position-config' import { ensurePerformanceSurveyNotificationWorkerStarted, queueDuePerformanceSurveyReminders, queueSurveyAssignmentNotifications, queueSurveyCreatorNotification, queueSurveyResultNotifications, queueSurveyTargetConfirmationNotifications, recoverDuePerformanceSurveyNotifications, recoverMissingSurveyAssignmentNotifications, refreshPerformanceSurveyNotificationReadStatuses, retryPerformanceSurveyNotification, } from '@/lib/performance-survey-notifications' import { ensurePerformanceSurveyDailyEmployeeSyncStarted, ensurePerformanceSurveyEmployeeProfileForUser, getActivePerformanceSurveyEmployeeProfileWhere, getPerformanceSurveyAccountProfileWhere, previewPerformanceSurveyEmployeeSync, syncPerformanceSurveyEmployees, } from '@/lib/performance-survey-report-sync' import { ensureDailyOrganizationSyncStarted } from '@/lib/organization-sync' import { PERFORMANCE_SURVEY_IMAGE_LIMITS, PerformanceSurveyImageProcessingError, assertPerformanceSurveyImageBatch, inspectPerformanceSurveyImage, processPerformanceSurveyImage, serializePerformanceSurveyImagePayload, type PerformanceSurveyStoredImageGroup, } from '@/lib/performance-survey-image-processing' import { getPerformanceSurveyTargetEmployeeIds } from '@/lib/performance-survey-target-policy' import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order' import { PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS, PerformanceSurveyProcessedImageError, readPerformanceSurveyWebpDimensions, validatePerformanceSurveyProcessedImages, type PerformanceSurveyProcessedImageDeclaration, } from '@/lib/performance-survey-processed-images' import { deleteFromS3, uploadFileToS3, uploadToS3 } from '@/lib/s3' import { acquireTaskMaterialUploadSessionLocks, type MaterialUploadStorageLockRelease, } from '@/lib/material-upload-cross-process-lock' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { deleteOwnedUploadSession, getOwnedUploadSession, getOwnedUploadedFile, } from '@/lib/upload-sessions' export class PerformanceSurveyApiError extends Error { status: number constructor(message: string, status = 400) { super(message) this.status = status } } type UserContext = { user: { id: string username: string email: string role: string department: string | null managedDepartment: string | null isSuperAdmin: boolean dingtalkUserId: string | null realName: string | null avatar: string | null } employee: any | null isSystemAdmin: boolean isDepartmentAdmin: boolean isLeadership: boolean scopeDepartment: string | null scopeDepartmentPath: string | null evaluatorKey: string } function clean(value: unknown) { const normalized = String(value ?? '').trim() return normalized.length > 0 ? normalized : null } async function lockPerformanceSurvey(tx: any, surveyId: string) { const rows = await tx.$queryRawUnsafe( 'SELECT "status", "due_at" AS "dueAt" FROM "performance_surveys" WHERE "id" = $1 FOR UPDATE', surveyId, ) as Array<{ status: string; dueAt: Date | null }> return rows[0] || null } function assertLockedSurveyOpen(survey: { status: string; dueAt?: Date | null } | null) { if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭') if (survey.dueAt && new Date(survey.dueAt).getTime() < Date.now()) { throw new PerformanceSurveyApiError('调研已超过截止时间') } } function assertLockedSurveyAcceptsWorkItem( survey: { status: string; dueAt?: Date | null } | null, allowDraft: boolean, ) { if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) if (survey.status !== 'open' && !(allowDraft && survey.status === 'draft')) { throw new PerformanceSurveyApiError('调研未开放或已关闭') } if (survey.status === 'open' && survey.dueAt && new Date(survey.dueAt).getTime() < Date.now()) { throw new PerformanceSurveyApiError('调研已超过截止时间') } } const PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MS = Math.max( 5, Number(process.env.PERFORMANCE_SURVEY_DEPARTMENT_CACHE_SECONDS || 60), ) * 1000 const PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MAX_ENTRIES = Math.max( 8, Math.min(Number(process.env.PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MAX_ENTRIES || 64), 512), ) const performanceSurveyDepartmentCache = new Map() const performanceSurveyDepartmentCachePromises = new Map>() function clearPerformanceSurveyDepartmentCache() { performanceSurveyDepartmentCache.clear() performanceSurveyDepartmentCachePromises.clear() } function prunePerformanceSurveyDepartmentCache(now = Date.now()) { for (const [key, cached] of Array.from(performanceSurveyDepartmentCache.entries())) { if (cached.expiresAt <= now) performanceSurveyDepartmentCache.delete(key) } while (performanceSurveyDepartmentCache.size > PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MAX_ENTRIES) { const oldestKey = performanceSurveyDepartmentCache.keys().next().value if (!oldestKey) break performanceSurveyDepartmentCache.delete(oldestKey) } } function normalizeSearchText(value: unknown) { return String(value ?? '') .trim() .toLowerCase() .replace(/[\s/_\-||,,;;>]+/g, '') } function searchVariants(value: unknown) { const text = String(value ?? '').trim() if (!text) return [] const variants = [normalizeSearchText(text)] try { variants.push( normalizeSearchText(pinyin(text, { toneType: 'none', type: 'array' }).join('')), normalizeSearchText(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')), ) } catch { // 非中文或异常字符按原文匹配即可。 } return Array.from(new Set(variants.filter(Boolean))) } function matchesSearchKeyword(keyword: string, values: unknown[]) { const normalizedKeyword = normalizeSearchText(keyword) if (!normalizedKeyword) return true return values.some((value) => searchVariants(value).some((variant) => variant.includes(normalizedKeyword))) } function normalizeList(value: unknown): string[] { if (!Array.isArray(value)) return [] return Array.from(new Set(value.map((item) => clean(item)).filter((item): item is string => Boolean(item)))) } const DEPARTMENT_PATH_VALUE_PREFIX = 'deptPath:' const ALL_DEPARTMENTS_NODE_ID = 'dept-root' const ALL_DEPARTMENTS_NODE_NAME = '全部部门' function normalizeAssessmentMonth(value: unknown) { const normalized = clean(value) return normalized && /^\d{4}-(0[1-9]|1[0-2])$/.test(normalized) ? normalized : null } function formatAssessmentMonthTitle(month: string, templateName: string) { const [year, monthValue] = month.split('-') return `${year}年${Number(monthValue)}月 · ${templateName}` } function encodeDepartmentPart(value: string) { return encodeURIComponent(value) } function decodeDepartmentPart(value: string) { try { return decodeURIComponent(value) } catch { return value } } function parseDepartmentPathParts(value?: string | null) { const normalized = clean(value) if (!normalized) return [] if (normalized.startsWith('[')) { try { const parsed = JSON.parse(normalized) if (Array.isArray(parsed)) { return parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item)) } } catch { // 继续按普通分隔符拆分。 } } return normalized .split(/[/>||,,;;]/) .map((item) => item.trim()) .filter(Boolean) } function getDepartmentPathValue(departmentPath: string) { return `${DEPARTMENT_PATH_VALUE_PREFIX}${encodeDepartmentPart(departmentPath)}` } function getDepartmentNodeId(departmentPath: string) { return `dept:${encodeDepartmentPart(departmentPath)}` } function getScopeDepartmentPathWhere(scopeDepartmentPath: string) { return { OR: [ { departmentPath: scopeDepartmentPath }, { departmentPath: { startsWith: `${scopeDepartmentPath}/` } }, { departmentMemberships: { some: { OR: [ { departmentPath: scopeDepartmentPath }, { departmentPath: { startsWith: `${scopeDepartmentPath}/` } }, ], }, }, }, ], } } function getContextScopeDepartmentWhere(ctx: UserContext) { if (ctx.scopeDepartmentPath) { return getScopeDepartmentPathWhere(ctx.scopeDepartmentPath) } // 普通员工的旧档案可能只有部门名。管理权限不能使用这个回退, // 否则同名叶子部门会跨组织命中。 if (!ctx.isDepartmentAdmin && ctx.scopeDepartment) { return { OR: [ { departmentName: ctx.scopeDepartment }, { departmentMemberships: { some: { departmentName: ctx.scopeDepartment }, }, }, ], } } return null } function normalizeDepartmentPath(value?: string | null) { const parts = parseDepartmentPathParts(value) return parts.length > 0 ? parts.join('/') : null } function getManagedDepartmentPathFromEmployeePath( managedDepartment: string, employeeDepartmentPath?: string | null, ) { const normalizedManagedPath = normalizeDepartmentPath(managedDepartment) if (!normalizedManagedPath) return null const managedParts = parseDepartmentPathParts(normalizedManagedPath) if (managedParts.length > 1) return normalizedManagedPath const managedName = managedParts[0] const employeeParts = parseDepartmentPathParts(employeeDepartmentPath) const matchedIndex = employeeParts.lastIndexOf(managedName) return matchedIndex >= 0 ? employeeParts.slice(0, matchedIndex + 1).join('/') : null } async function resolveManagedDepartmentPath( managedDepartment: string, employeeDepartmentPath?: string | null, ) { const pathFromEmployee = getManagedDepartmentPathFromEmployeePath( managedDepartment, employeeDepartmentPath, ) if (pathFromEmployee) return pathFromEmployee const managedName = clean(managedDepartment) if (!managedName) return null const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { OR: [ { departmentName: managedName }, { departmentPath: managedName }, { departmentPath: { startsWith: `${managedName}/` } }, { departmentPath: { contains: `/${managedName}/` } }, { departmentPath: { endsWith: `/${managedName}` } }, { departmentMemberships: { some: { OR: [ { departmentName: managedName }, { departmentPath: managedName }, { departmentPath: { startsWith: `${managedName}/` } }, { departmentPath: { contains: `/${managedName}/` } }, { departmentPath: { endsWith: `/${managedName}` } }, ], }, }, }, ], }, select: { departmentName: true, departmentPath: true, departmentMemberships: { select: { departmentName: true, departmentPath: true, }, }, }, take: 10_000, }) const candidatePaths = new Set() for (const profile of profiles) { const departmentCandidates = [ { departmentName: profile.departmentName, departmentPath: profile.departmentPath, }, ...profile.departmentMemberships, ] for (const department of departmentCandidates) { const profileParts = parseDepartmentPathParts(department.departmentPath) if (profileParts.length === 0) continue const matchedIndex = profileParts.lastIndexOf(managedName) if (matchedIndex >= 0) { candidatePaths.add(profileParts.slice(0, matchedIndex + 1).join('/')) continue } if (clean(department.departmentName) === managedName) { candidatePaths.add(profileParts.join('/')) } } } // managedDepartment 仍可使用旧的部门名语义,但只有能唯一解析到完整路径时才放行。 return candidatePaths.size === 1 ? Array.from(candidatePaths)[0] : null } function parseDepartmentSelection(value: string) { const normalizedValue = clean(value) if ( !normalizedValue || normalizedValue === ALL_DEPARTMENTS_NODE_ID || normalizedValue === ALL_DEPARTMENTS_NODE_NAME || normalizedValue === '全部组织' ) { return null } if (normalizedValue.startsWith(DEPARTMENT_PATH_VALUE_PREFIX)) { const payload = normalizedValue.slice(DEPARTMENT_PATH_VALUE_PREFIX.length) const decoded = decodeDepartmentPart(payload) if (decoded.includes('|')) { const [departmentNameRaw, departmentPathRaw] = decoded.split('|') const departmentName = clean(departmentNameRaw) const departmentPath = clean(departmentPathRaw) if (departmentName && departmentPath) { return { departmentName, departmentPath } } } const departmentPath = clean(decoded) if (departmentPath) { const parts = parseDepartmentPathParts(departmentPath) return { departmentName: parts[parts.length - 1] || departmentPath, departmentPath, } } } const departmentName = clean(normalizedValue) return departmentName ? { departmentName, departmentPath: null } : null } function getDepartmentSelectionWhere(values: string[]) { const departmentNames: string[] = [] const departmentPathClauses: any[] = [] for (const value of values) { const parsed = parseDepartmentSelection(value) if (!parsed) continue if (parsed.departmentPath) { departmentPathClauses.push({ OR: [ { departmentPath: parsed.departmentPath }, { departmentPath: { startsWith: `${parsed.departmentPath}/` } }, ...(parsed.departmentPath.includes('/') ? [] : [{ departmentName: parsed.departmentName }]), { departmentMemberships: { some: { OR: [ { departmentPath: parsed.departmentPath }, { departmentPath: { startsWith: `${parsed.departmentPath}/` } }, ...(parsed.departmentPath.includes('/') ? [] : [{ departmentName: parsed.departmentName }]), ], }, }, }, ], }) } else { departmentNames.push(parsed.departmentName) } } const clauses: any[] = [] const uniqueDepartmentNames = Array.from(new Set(departmentNames)) if (uniqueDepartmentNames.length > 0) { clauses.push({ OR: [ { departmentName: { in: uniqueDepartmentNames } }, { departmentMemberships: { some: { departmentName: { in: uniqueDepartmentNames } }, }, }, ], }) } clauses.push(...departmentPathClauses) return clauses } function toDate(value: unknown) { const normalized = clean(value) if (!normalized) return null const date = new Date(normalized) return Number.isNaN(date.getTime()) ? null : date } function getMonthRange(value: unknown) { const normalized = normalizeAssessmentMonth(value) const match = normalized?.match(/^(\d{4})-(\d{2})$/) if (!match) return null const year = Number(match[1]) const month = Number(match[2]) if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return null const start = new Date(year, month - 1, 1, 0, 0, 0, 0) const end = new Date(year, month, 1, 0, 0, 0, 0) return { start, end, value: normalized } } function getCurrentMonthRange() { const now = new Date() const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0) const end = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0) return { start, end, value: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` } } export function resolvePerformanceSurveyPeriodInput( input: { assessmentMonth?: unknown; dueAt?: unknown; openNow?: boolean }, now = new Date(), ) { const requestedAssessmentMonth = clean(input.assessmentMonth) const assessmentMonth = normalizeAssessmentMonth(requestedAssessmentMonth) if (requestedAssessmentMonth && !assessmentMonth) { throw new PerformanceSurveyApiError('考核月份格式无效') } const requestedDueAt = clean(input.dueAt) const dueAt = toDate(input.dueAt) if (requestedDueAt && !dueAt) throw new PerformanceSurveyApiError('截止时间格式无效') if (input.openNow && dueAt && dueAt.getTime() <= now.getTime()) { throw new PerformanceSurveyApiError('截止时间必须晚于当前时间') } return { assessmentMonth: assessmentMonth || getMonthRangeFromDate(now).value, dueAt, } } function getMonthRangeFromDate(value: Date) { const start = new Date(value.getFullYear(), value.getMonth(), 1, 0, 0, 0, 0) const end = new Date(value.getFullYear(), value.getMonth() + 1, 1, 0, 0, 0, 0) return { start, end, value: `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` } } function getMonthValueFromDate(value: Date | string | null | undefined) { const date = value ? new Date(value) : new Date() const safeDate = Number.isNaN(date.getTime()) ? new Date() : date return `${safeDate.getFullYear()}-${String(safeDate.getMonth() + 1).padStart(2, '0')}` } // 旧考核调研源项目 data/database.json 中最早的真实调研计划于 2026-05-26。 const PERFORMANCE_SURVEY_HISTORY_START_MONTH = '2026-05' function getMonthValuesSince(startMonth: string) { const startRange = getMonthRange(startMonth) const now = new Date() if (!startRange || startRange.start > now) return [getCurrentMonthRange().value] const monthCount = (now.getFullYear() - startRange.start.getFullYear()) * 12 + now.getMonth() - startRange.start.getMonth() + 1 return Array.from({ length: monthCount }, (_, index) => { const date = new Date(now.getFullYear(), now.getMonth() - index, 1, 0, 0, 0, 0) return getMonthRangeFromDate(date).value }) } function getMonthSpanRange(months: string[]) { const ranges = months.flatMap((month) => { const range = getMonthRange(month) return range ? [range] : [] }) const start = new Date(Math.min(...ranges.map((range) => range.start.getTime()))) const end = new Date(Math.max(...ranges.map((range) => range.end.getTime()))) return { start, end } } function getSurveyMonthWhere(monthRange: { start: Date; end: Date }) { const startMonth = getMonthValueFromDate(monthRange.start) const inclusiveEnd = new Date(monthRange.end.getTime() - 1) const endMonth = getMonthValueFromDate(inclusiveEnd) return { OR: [ { assessmentMonth: { gte: startMonth, lte: endMonth } }, { AND: [ { assessmentMonth: null }, { OR: [ { dueAt: { gte: monthRange.start, lt: monthRange.end } }, { AND: [ { dueAt: null }, { createdAt: { gte: monthRange.start, lt: monthRange.end } }, ], }, ], }, ], }, ], } } function getQuestionMaxScore(template: PerformanceSurveyTemplateShape, question: { maxScore?: number }) { const maxScore = Number(question.maxScore ?? template.questionMaxScore ?? 20) return Number.isFinite(maxScore) && maxScore > 0 ? maxScore : 20 } function calculateSubmittedPerformanceSurveyScores(template: PerformanceSurveyTemplateShape, value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new PerformanceSurveyApiError('请逐项完成评分后再提交') } const scores = value as Record const missingQuestion = template.questions.find((question) => !Object.prototype.hasOwnProperty.call(scores, question.id)) if (missingQuestion) { throw new PerformanceSurveyApiError(`请完成评分项「${missingQuestion.title}」`) } try { return { scores, result: calculateResponseScores(template, scores), } } catch (error) { throw new PerformanceSurveyApiError(error instanceof Error ? error.message : '评分数据无效') } } function isVisualCaseTemplate(templateOrCode?: Pick | string | null) { if (!templateOrCode) return false if (typeof templateOrCode === 'string') return templateOrCode === 'visual_brand_tone' return templateOrCode.surveyType === 'visual_case' || templateOrCode.code === 'visual_brand_tone' } function canCreatePerformanceSurvey(ctx: Pick) { return Boolean(ctx.isSystemAdmin || ctx.employee?.id) } function readWorkflowRole(snapshot: unknown) { if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null return clean((snapshot as Record).performanceSurveyWorkflowRole) } function isSelfWorkflowRole(role: string | null) { return role === 'self' || role === 'self_visual_final' } function isFinalWorkflowRole(role: string | null) { return role === 'visual_final' || role === 'self_visual_final' } function isConfiguredWorkflowAssignment(assignment: any) { return clean(assignment?.workflowStageKey) && assignment.workflowStageKey !== 'legacy' } function isTemplateScoringAssignment(template: PerformanceSurveyTemplateShape, assignment: any) { const scoring = template.workflow?.scoring if (!scoring) { return !isVisualCaseTemplate(template) || isFinalWorkflowRole(readWorkflowRole(assignment?.evaluatorSnapshot)) } if (scoring.mode === 'final_reviewer') { return assignment?.workflowStageKey === scoring.stageKey && assignment?.reviewerSource === scoring.reviewerSource } return !scoring.stageKeys?.length || scoring.stageKeys.includes(String(assignment?.workflowStageKey || '')) } async function hasCompletedVisualSelfAssessment(surveyId: string, targetId: string) { const count = await prisma.performanceSurveyAssignment.count({ where: { surveyId, targetId, status: 'submitted', OR: [ { evaluatorSnapshot: { path: ['performanceSurveyWorkflowRole'], equals: 'self' } }, { evaluatorSnapshot: { path: ['performanceSurveyWorkflowRole'], equals: 'self_visual_final' } }, ], } as any, }) return count > 0 } async function assertAssignmentStageOpen(survey: any, assignment: any) { if (isConfiguredWorkflowAssignment(assignment)) { if (!assignment.releasedAt) { throw new PerformanceSurveyApiError('前一评审阶段完成后才会开放当前评价', 409) } return } if (!isVisualCaseTemplate(survey.templateSnapshot || survey.templateCode)) return if (isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot))) return if (!(await hasCompletedVisualSelfAssessment(survey.id, assignment.targetId))) { throw new PerformanceSurveyApiError('被考核人完成自评后才会开放主管与元泽评分', 409) } } async function canSubmitCurrentVisualStage(survey: any, assignments: any[]) { const configuredAssignments = assignments.filter(isConfiguredWorkflowAssignment) if (configuredAssignments.length > 0) { return configuredAssignments.some((assignment) => assignment.releasedAt && assignment.status !== 'submitted') } if (!isVisualCaseTemplate(survey.templateSnapshot || survey.templateCode)) return true if (assignments.some((assignment) => isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot)))) return true const targetIds = Array.from(new Set(assignments.map((assignment) => clean(assignment.targetId)).filter(Boolean))) as string[] if (targetIds.length === 0) return false return (await Promise.all(targetIds.map((targetId) => hasCompletedVisualSelfAssessment(survey.id, targetId)))).every(Boolean) } function average(values: number[]) { const valid = values.filter((value) => Number.isFinite(value)) if (valid.length === 0) return null return Math.round((valid.reduce((sum, value) => sum + value, 0) / valid.length) * 10) / 10 } function roundScore(value: number) { return Math.round(Number(value || 0) * 10) / 10 } async function queuePostSubmissionNotifications(input: { surveyId: string targetId?: string completedStageOrder?: number notifyCreator: boolean releaseVisualEvaluators: boolean }) { let releasedNextStage = false if (input.targetId && Number.isFinite(input.completedStageOrder)) { releasedNextStage = await releaseNextPerformanceSurveyWorkflowStage( input.surveyId, input.targetId, Number(input.completedStageOrder), ) } const jobs: Array<{ name: string; promise: Promise }> = [] if (input.notifyCreator) { jobs.push({ name: 'creator_completed', promise: queueSurveyCreatorNotification(input.surveyId, 'survey.creator.completed'), }) } if (input.releaseVisualEvaluators || releasedNextStage) { jobs.push({ name: 'visual_evaluators', promise: queueSurveyAssignmentNotifications(input.surveyId), }) } if (jobs.length === 0) return const results = await Promise.allSettled(jobs.map((job) => job.promise)) results.forEach((result, index) => { if (result.status === 'rejected') { console.error( `[performance-survey] 评分已保存,但后续通知排队失败:${jobs[index].name},surveyId=${input.surveyId}`, result.reason, ) } }) } function getDisplayName(user: { username: string; realName?: string | null }) { return clean(user.realName) || clean(user.username) || '未知用户' } function getEmployeeDisplayName(employee: any) { return clean(employee?.displayName) || clean(employee?.flowerName) || clean(employee?.realName) || clean(employee?.jobNumber) || '未知员工' } function getEmployeeSnapshot(employee: any) { if (!employee) return {} return { id: employee.id, dingtalkUserId: employee.dingtalkUserId, dingEmail: employee.dingEmail, jobNumber: employee.jobNumber, flowerName: employee.flowerName, realName: employee.realName, displayName: employee.displayName, departmentName: employee.departmentName, departmentPath: employee.departmentPath, positionTitle: employee.positionTitle, positionLevel: employee.positionLevel, levelType: employee.levelType, isLeadership: employee.isLeadership, isDingtalkAdmin: employee.isDingtalkAdmin, isDingtalkBoss: employee.isDingtalkBoss, isDingtalkSenior: employee.isDingtalkSenior, departmentMemberships: Array.isArray(employee.departmentMemberships) ? employee.departmentMemberships.map((membership: any) => ({ id: membership.id, sourceDeptId: membership.sourceDeptId, departmentName: membership.departmentName, departmentPath: membership.departmentPath, isPrimary: membership.isPrimary, syncedAt: membership.syncedAt, })) : [], managerDingtalkUserId: employee.managerDingtalkUserId, managerJobNumber: employee.managerJobNumber, managerName: employee.managerName, employmentStatus: employee.employmentStatus, workPlace: employee.workPlace, snapshotAt: new Date().toISOString(), } } async function releaseNextPerformanceSurveyWorkflowStage( surveyId: string, targetId: string, completedStageOrder: number, ) { return prisma.$transaction(async (tx) => { const currentPending = await tx.performanceSurveyAssignment.count({ where: { surveyId, targetId, workflowStageOrder: completedStageOrder, status: { not: 'submitted' }, }, }) if (currentPending > 0) return false const next = await tx.performanceSurveyAssignment.findFirst({ where: { surveyId, targetId, workflowStageOrder: { gt: completedStageOrder }, }, orderBy: { workflowStageOrder: 'asc' }, select: { workflowStageOrder: true }, }) if (!next) return false const result = await tx.performanceSurveyAssignment.updateMany({ where: { surveyId, targetId, workflowStageOrder: next.workflowStageOrder, releasedAt: null, }, data: { releasedAt: new Date() }, }) return result.count > 0 }) } function getEvaluatorKey(input: { employeeId?: string | null userId?: string | null dingtalkUserId?: string | null }) { if (input.dingtalkUserId) return `ding:${input.dingtalkUserId}` if (input.userId) return `user:${input.userId}` if (input.employeeId) return `employee:${input.employeeId}` return `unknown:${randomUUID()}` } function ensureManager(ctx: UserContext) { if (!ctx.isDepartmentAdmin || (!ctx.isSystemAdmin && !ctx.scopeDepartmentPath)) { throw new PerformanceSurveyApiError('无权限管理考核调研', 403) } } function ensureSystemAdministrator(ctx: UserContext) { if (!ctx.isSystemAdmin) { throw new PerformanceSurveyApiError('只有系统管理员可以维护考核岗位与模板配置', 403) } } /** * 公共看板调研的删除权限名单,按钉钉唯一值判定。 * * 此前这里比对的是用户名字符串。但库中曾同时存在「无邪」(简体 U+65E0)与 * 「無邪」(繁体 U+7121)两个账号,字符串比对只会命中其中一个——而命中的 * 那个未必是本人。钉钉唯一值全项目唯一,且不随改名或繁简体写法变化。 * * 通过环境变量覆盖,多个以逗号分隔。 */ const PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS = new Set( (process.env.PERFORMANCE_SURVEY_PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS || '09061817188066') .split(',') .map((value) => value.trim()) .filter(Boolean), ) function canDeletePerformanceSurveyPublicDashboardAssessment(ctx: UserContext) { const dingtalkUserId = ctx.user.dingtalkUserId?.trim() if (!dingtalkUserId) return false return PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS.has(dingtalkUserId) } function ensureCanDeletePerformanceSurveyPublicDashboardAssessment(ctx: UserContext) { if (!canDeletePerformanceSurveyPublicDashboardAssessment(ctx)) { throw new PerformanceSurveyApiError('只有无邪可以删除公共看板调研', 403) } } async function ensureReadOnlySystemAdministrator(session: Session | null) { const sessionUserId = (session?.user as any)?.id if (!sessionUserId) { throw new PerformanceSurveyApiError('请先登录', 401) } const user = await prisma.user.findUnique({ where: { id: sessionUserId }, select: { dingtalkUserId: true, email: true, }, }) if (!user) { throw new PerformanceSurveyApiError('登录用户不存在', 401) } const identityWhere: any[] = [] if (clean(user.dingtalkUserId)) { identityWhere.push({ dingtalkUserId: clean(user.dingtalkUserId) }) } if (clean(user.email)) { identityWhere.push({ dingEmail: { equals: clean(user.email), mode: 'insensitive', }, }) } const employee = identityWhere.length > 0 ? await prisma.performanceSurveyEmployeeProfile.findFirst({ where: { OR: identityWhere }, select: { levelType: true, positionLevel: true, isLeadership: true, }, }) : null if (!isLeadershipProfile(employee)) { throw new PerformanceSurveyApiError('需要系统管理员权限', 403) } } function rethrowPositionConfigError(error: unknown): never { if (error instanceof PerformanceSurveyPositionConfigError) { throw new PerformanceSurveyApiError(error.message, error.status) } throw error } function isLeadershipProfile(employee: any) { const levelType = clean(employee?.levelType)?.toUpperCase() const positionLevel = clean(employee?.positionLevel)?.toUpperCase() return Boolean(employee?.isLeadership || levelType === 'M' || positionLevel?.startsWith('M')) } async function canManageSurvey(ctx: UserContext, survey: { id: string; creatorUserId: string; departmentName?: string | null }) { if (survey.creatorUserId === ctx.user.id) return true if (ctx.isSystemAdmin) return true if (!ctx.isDepartmentAdmin) return false if (!ctx.scopeDepartmentPath) return false const scopeWhere = getSurveyWhereByAllEmployeeTargetsInScope( getScopeDepartmentPathWhere(ctx.scopeDepartmentPath), ) const count = await prisma.performanceSurvey.count({ where: { AND: [ { id: survey.id }, scopeWhere, ], }, }) return count > 0 } function getPersonalSurveyTargets( ctx: UserContext, survey: { creatorUserId: string; assignments?: Array<{ targetId: string }> }, targets: T[], ) { const assignedTargetIds = new Set((survey.assignments || []).map((assignment) => assignment.targetId)) return targets.filter((target) => ( assignedTargetIds.has(target.id) || Boolean(ctx.employee?.id && target.employeeProfileId === ctx.employee.id) )) } async function ensureSurveyManageScope(ctx: UserContext, survey: { id: string; creatorUserId: string; departmentName?: string | null }) { if (!(await canManageSurvey(ctx, survey))) { throw new PerformanceSurveyApiError('无权限管理该考核调研', 403) } } function mergeOrClauses(clauses: any[]) { const validClauses = clauses.filter(Boolean) if (validClauses.length === 0) return { id: '__none__' } return validClauses.length === 1 ? validClauses[0] : { OR: validClauses } } function getSurveyWhereByAllEmployeeTargetsInScope(profileWhere: any) { return { AND: [ { targets: { some: { targetType: 'employee' } } }, { targets: { none: { targetType: 'employee', OR: [ { employeeProfileId: null }, { employeeProfile: { isNot: profileWhere } }, ], }, }, }, ], } } function getSurveyWhereByAnyEmployeeTargetInScope(profileWhere: any) { return { targets: { some: { targetType: 'employee', employeeProfile: { is: profileWhere }, }, }, } } async function getManagerSurveyBaseWhere(ctx: UserContext) { if (ctx.isSystemAdmin) return {} if (!ctx.scopeDepartmentPath) return { creatorUserId: ctx.user.id } return mergeOrClauses([ { creatorUserId: ctx.user.id }, getSurveyWhereByAllEmployeeTargetsInScope( getScopeDepartmentPathWhere(ctx.scopeDepartmentPath), ), ]) } async function getManagerSurveyFilterWhere(ctx: UserContext, params?: URLSearchParams) { const and: any[] = [await getManagerSurveyBaseWhere(ctx)] const departmentValue = clean(params?.get('department')) const monthRange = getMonthRange(params?.get('month')) if (departmentValue) { const departmentClauses = getDepartmentSelectionWhere([departmentValue]) if (departmentClauses.length > 0) { and.push(getSurveyWhereByAnyEmployeeTargetInScope(mergeOrClauses(departmentClauses))) } } if (monthRange) { and.push(getSurveyMonthWhere(monthRange)) } return and.length === 1 ? and[0] : { AND: and } } export async function getPerformanceSurveyContext(session: Session | null): Promise { ensurePerformanceSurveyNotificationWorkerStarted() ensurePerformanceSurveyDailyEmployeeSyncStarted() // 组织架构、在职状态与离职联动的每日同步,与人员档案同步一并懒启动 ensureDailyOrganizationSyncStarted() const sessionUserId = (session?.user as any)?.id if (!sessionUserId) { throw new PerformanceSurveyApiError('请先登录', 401) } const user = await prisma.user.findUnique({ where: { id: sessionUserId }, select: { id: true, username: true, email: true, role: true, department: true, managedDepartment: true, isSuperAdmin: true, dingtalkUserId: true, realName: true, avatar: true, }, }) if (!user) { throw new PerformanceSurveyApiError('登录用户不存在', 401) } const employee = await ensurePerformanceSurveyEmployeeProfileForUser({ userId: user.id, dingtalkUserId: user.dingtalkUserId, email: user.email, username: user.username, realName: user.realName, displayName: getDisplayName(user), }) const isLeadership = isLeadershipProfile(employee) // 考核调研与素材库使用独立人员权限。这里只按考核人员档案判定, // 不继承素材库 users.role / isSuperAdmin。 const isSystemAdmin = isLeadership const isDepartmentAdmin = isLeadership const managedDepartment = clean(user.managedDepartment) const managedDepartmentParts = parseDepartmentPathParts(managedDepartment) const scopeDepartment = managedDepartment ? managedDepartmentParts[managedDepartmentParts.length - 1] || managedDepartment : clean(employee?.departmentName) || clean(user.department) const scopeDepartmentPath = managedDepartment ? await resolveManagedDepartmentPath(managedDepartment, employee?.departmentPath) : normalizeDepartmentPath(employee?.departmentPath) return { user, employee, isSystemAdmin, isDepartmentAdmin, isLeadership, scopeDepartment, scopeDepartmentPath, evaluatorKey: getEvaluatorKey({ employeeId: employee?.id, userId: user.id, dingtalkUserId: user.dingtalkUserId, }), } } export async function getPerformanceSurveyMe(session: Session | null) { const ctx = await getPerformanceSurveyContext(session) return { user: { id: ctx.user.id, name: getDisplayName(ctx.user), email: ctx.user.email, role: ctx.user.role, department: ctx.user.department, managedDepartment: ctx.user.managedDepartment, dingtalkUserId: ctx.user.dingtalkUserId, avatar: ctx.user.avatar, }, employee: ctx.employee ? getEmployeeSnapshot(ctx.employee) : null, permissions: { isSystemAdmin: ctx.isSystemAdmin, isDepartmentAdmin: ctx.isDepartmentAdmin, isLeadership: ctx.isLeadership, scopeDepartment: ctx.scopeDepartment, scopeDepartmentPath: ctx.scopeDepartmentPath, canCreate: canCreatePerformanceSurvey(ctx), canDeletePublicDashboardAssessment: canDeletePerformanceSurveyPublicDashboardAssessment(ctx), }, } } export async function getPerformanceSurveyPositionGroups(session: Session | null, params?: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) const month = normalizeAssessmentMonth(params?.get('month')) || getCurrentMonthRange().value const groups = await listPerformanceSurveyPositionGroups(month) const membership = ctx.employee ? await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, month) : { status: 'unmatched' as const, group: null, matches: [], source: 'automatic' as const, manualAssignmentId: null } return { month, groups: groups.map((group) => ({ id: group.id, code: group.code, name: group.name, status: group.status, isActive: group.isActive, templateCount: getAvailablePerformanceSurveyPositionTemplates(group).length, effectiveMonth: group.effectiveMonth, })), membership: { status: membership.status, groupCode: membership.group?.code || null, groupName: membership.group?.name || null, conflictGroupNames: membership.matches.map((group) => group.name), }, } } export async function getPerformanceSurveyTemplates(session: Session | null, params?: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) const month = normalizeAssessmentMonth(params?.get('month')) || getCurrentMonthRange().value const requestedGroupCode = clean(params?.get('positionGroupCode')) if (requestedGroupCode) { try { const group = await getPerformanceSurveyPositionGroupByCode(requestedGroupCode, month) if (group.status !== 'ready') throw new PerformanceSurveyApiError('该岗位组尚未完成配置', 409) return getAvailablePerformanceSurveyPositionTemplates(group) } catch (error) { return rethrowPositionConfigError(error) } } if (!ctx.employee) return [] const membership = await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, month) return membership.group?.status === 'ready' ? getAvailablePerformanceSurveyPositionTemplates(membership.group) : [] } export async function getPerformanceSurveyAdminConfiguration(session: Session | null, params?: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) return getPerformanceSurveyPositionAdminConfig( params?.get('month'), params?.get('scope'), ) } export async function savePerformanceSurveyAdminPositionGroup( session: Session | null, groupId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await savePerformanceSurveyPositionGroupDraft({ groupId, name: body?.name, sortOrder: body?.sortOrder, isActive: body?.isActive, rules: body?.rules, templateVersionIds: body?.templateVersionIds, effectiveMonth: body?.effectiveMonth, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function publishPerformanceSurveyAdminPositionGroup( session: Session | null, groupId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await publishPerformanceSurveyPositionPlan({ groupId, effectiveMonth: clean(body?.effectiveMonth) || null, draftPlanId: clean(body?.draftPlanId), draftVersion: body?.draftVersion, draftUpdatedAt: body?.draftUpdatedAt, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function savePerformanceSurveyAdminTemplate(session: Session | null, body: any) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await savePerformanceSurveyTemplateDraft({ versionId: clean(body?.versionId), definition: body?.definition, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function copyPerformanceSurveyAdminTemplateVersion( session: Session | null, versionId: string, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await copyPerformanceSurveyTemplateVersion({ versionId, reason: null, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function publishPerformanceSurveyAdminTemplateVersion( session: Session | null, versionId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await publishPerformanceSurveyTemplateVersion({ versionId, effectiveMonth: clean(body?.effectiveMonth) || null, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function createPerformanceSurveyAdminPositionGroup(session: Session | null, body: any) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await createPerformanceSurveyPositionGroup({ name: clean(body?.name) || '', sortOrder: body?.sortOrder, isActive: body?.isActive, rules: body?.rules, templateVersionIds: body?.templateVersionIds, effectiveMonth: body?.effectiveMonth, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function copyPerformanceSurveyAdminPositionGroup( session: Session | null, groupId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await copyPerformanceSurveyPositionGroup({ sourceGroupId: groupId, name: clean(body?.name) || '', month: body?.month, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function removePerformanceSurveyAdminPositionGroup( session: Session | null, groupId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await removePerformanceSurveyPositionGroup({ groupId, mode: ['auto', 'archive', 'delete'].includes(body?.mode) ? body.mode : 'auto', reason: clean(body?.reason) || '', actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function previewPerformanceSurveyAdminPositionGroup( session: Session | null, groupId: string, params: URLSearchParams, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await previewPerformanceSurveyPositionPlanImpact({ groupId, effectiveMonth: clean(params.get('month')) || '', }) } catch (error) { return rethrowPositionConfigError(error) } } export async function removePerformanceSurveyAdminTemplate( session: Session | null, templateId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await removePerformanceSurveyTemplate({ templateId, mode: ['auto', 'archive', 'delete'].includes(body?.mode) ? body.mode : 'auto', reason: clean(body?.reason) || '', actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function listPerformanceSurveyAdminPositionAssignments( session: Session | null, params: URLSearchParams, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await listPerformanceSurveyManualPositionAssignments(params) } catch (error) { return rethrowPositionConfigError(error) } } export async function createPerformanceSurveyAdminPositionAssignments(session: Session | null, body: any) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await createPerformanceSurveyManualPositionAssignments({ employeeIds: body?.employeeIds, positionGroupId: body?.positionGroupId, assignmentType: body?.assignmentType, effectiveFromMonth: body?.effectiveFromMonth, effectiveToMonth: body?.effectiveToMonth, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function updatePerformanceSurveyAdminPositionAssignment( session: Session | null, assignmentId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await updatePerformanceSurveyManualPositionAssignment({ assignmentId, positionGroupId: body?.positionGroupId, assignmentType: body?.assignmentType, effectiveFromMonth: body?.effectiveFromMonth, effectiveToMonth: body?.effectiveToMonth, reason: body?.reason, actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function archivePerformanceSurveyAdminPositionAssignment( session: Session | null, assignmentId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) try { return await archivePerformanceSurveyManualPositionAssignment({ assignmentId, reason: clean(body?.reason) || '', actorUserId: ctx.user.id, actorName: getDisplayName(ctx.user), }) } catch (error) { return rethrowPositionConfigError(error) } } export async function listPerformanceSurveyAdminConfigAuditLogs( session: Session | null, params: URLSearchParams, ) { const ctx = await getPerformanceSurveyContext(session) ensureSystemAdministrator(ctx) return listPerformanceSurveyConfigAuditLogs(params) } export async function searchPerformanceSurveyEmployees(session: Session | null, params: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) const search = clean(params.get('search')) || '' const department = clean(params.get('department')) const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 1000)) const isEvaluatorSearch = clean(params.get('purpose')) === 'evaluator' const positionGroupCode = clean(params.get('positionGroupCode')) const assessmentMonth = normalizeAssessmentMonth(params.get('month')) || getCurrentMonthRange().value const buildWhere = async (includeKeyword: boolean) => { const selectableProfileWhere = isEvaluatorSearch ? getActivePerformanceSurveyEmployeeProfileWhere() : await getPerformanceSurveyAccountProfileWhere() const and: any[] = [selectableProfileWhere] if (department) { const departmentClauses = getDepartmentSelectionWhere([department]) if (departmentClauses.length === 1) { and.push(departmentClauses[0]) } else if (departmentClauses.length > 1) { and.push({ OR: departmentClauses }) } } if (!ctx.isSystemAdmin && !isEvaluatorSearch) { and.push(getContextScopeDepartmentWhere(ctx) || { id: '__none__' }) } if (isEvaluatorSearch && ctx.employee?.id) { and.push({ id: { not: ctx.employee.id } }) } if (includeKeyword && search) { and.push({ OR: [ { displayName: { contains: search, mode: 'insensitive' } }, { flowerName: { contains: search, mode: 'insensitive' } }, { realName: { contains: search, mode: 'insensitive' } }, { jobNumber: { contains: search, mode: 'insensitive' } }, { dingEmail: { contains: search, mode: 'insensitive' } }, { departmentName: { contains: search, mode: 'insensitive' } }, { departmentPath: { contains: search, mode: 'insensitive' } }, { positionTitle: { contains: search, mode: 'insensitive' } }, { positionLevel: { contains: search, mode: 'insensitive' } }, { levelType: { contains: search, mode: 'insensitive' } }, { managerName: { contains: search, mode: 'insensitive' } }, { departmentMemberships: { some: { OR: [ { departmentName: { contains: search, mode: 'insensitive' } }, { departmentPath: { contains: search, mode: 'insensitive' } }, ], }, }, }, ] }) } return { AND: and } as any } const queryEmployees = async () => prisma.performanceSurveyEmployeeProfile.findMany({ where: await buildWhere(true), orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], ...(positionGroupCode ? {} : { take: limit }), include: { departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], }, }, }) let employees = await queryEmployees() const needsPinyinSearch = search && (employees.length === 0 || /[A-Za-z]/.test(search)) if (needsPinyinSearch && employees.length < limit) { const candidates = await prisma.performanceSurveyEmployeeProfile.findMany({ where: await buildWhere(false), orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], take: Math.min(5000, Math.max(1000, limit * 20)), include: { departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], }, }, }) const existingIds = new Set(employees.map((employee) => employee.id)) const pinyinMatches = candidates.filter((employee) => { if (existingIds.has(employee.id)) return false return matchesSearchKeyword(search, [ employee.displayName, employee.flowerName, employee.realName, employee.jobNumber, employee.dingEmail, employee.departmentName, employee.departmentPath, employee.positionTitle, employee.positionLevel, employee.levelType, employee.managerName, ...employee.departmentMemberships.flatMap((membership) => [ membership.departmentName, membership.departmentPath, ]), ]) }) employees = [...employees, ...pinyinMatches].slice(0, limit) } if (positionGroupCode) { const membershipByEmployeeId = await getPerformanceSurveyPositionMembershipsForEmployees( employees, assessmentMonth, ) employees = employees.filter((employee) => ( membershipByEmployeeId.get(employee.id)?.group?.code === positionGroupCode )).slice(0, limit) } return employees.map(serializeEmployee) } export async function listPerformanceSurveyDepartments(session: Session | null, params?: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) const purpose = clean(params?.get('purpose')) const isEvaluatorPicker = purpose === 'evaluator' const isDashboardFilter = purpose === 'dashboard' const dashboardMonth = isDashboardFilter ? (getMonthRange(params?.get('month')) || getCurrentMonthRange()).value : null const dashboardPositionGroupCode = isDashboardFilter ? clean(params?.get('positionGroupCode')) : null const cacheKey = [ isDashboardFilter ? `dashboard:${dashboardMonth}:${dashboardPositionGroupCode || 'none'}` : isEvaluatorPicker ? 'evaluator' : 'scoped', ctx.isSystemAdmin ? 'system' : ctx.scopeDepartmentPath || ctx.scopeDepartment || ctx.user.id, ].join(':') prunePerformanceSurveyDepartmentCache() const cached = performanceSurveyDepartmentCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) { return cached.value } const pending = performanceSurveyDepartmentCachePromises.get(cacheKey) if (pending) return pending const loadDepartments = async () => { const employees = isDashboardFilter ? (await prisma.performanceSurveyTarget.findMany({ where: { targetType: 'employee', employeeProfileId: { not: null }, survey: { AND: [ { status: { not: 'cancelled' } }, dashboardPositionGroupCode ? { positionGroupCode: dashboardPositionGroupCode } : { positionGroupCode: { not: null } }, getSurveyMonthWhere(getMonthRange(dashboardMonth)!), ], }, ...(!ctx.isSystemAdmin && ctx.isLeadership ? { employeeProfile: { is: ctx.scopeDepartmentPath ? getScopeDepartmentPathWhere(ctx.scopeDepartmentPath) : { id: '__none__' }, }, } : {}), } as any, select: { employeeProfile: { select: { departmentName: true, departmentPath: true, departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], select: { departmentName: true, departmentPath: true, }, }, }, }, }, distinct: ['employeeProfileId'], take: 2000, })).flatMap((target) => target.employeeProfile ? [target.employeeProfile] : []) : await (async () => { const selectableProfileWhere = isEvaluatorPicker ? getActivePerformanceSurveyEmployeeProfileWhere() : await getPerformanceSurveyAccountProfileWhere() const and: any[] = [ selectableProfileWhere, { OR: [ { departmentName: { not: null } }, { departmentMemberships: { some: {} } }, ], }, ] if (!ctx.isSystemAdmin && !isEvaluatorPicker) { and.push(getContextScopeDepartmentWhere(ctx) || { id: '__none__' }) } return prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: and }, select: { departmentName: true, departmentPath: true, departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], select: { departmentName: true, departmentPath: true, }, }, }, }) })() const nodeByPath = new Map() for (const employee of employees) { const departmentCandidates = [ { departmentName: employee.departmentName, departmentPath: employee.departmentPath, }, ...employee.departmentMemberships, ] const countedPaths = new Set() for (const department of departmentCandidates) { const departmentName = clean(department.departmentName) const pathParts = parseDepartmentPathParts(department.departmentPath) const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : [] if (parts.length === 0) continue for (let index = 0; index < parts.length; index += 1) { const fullPath = parts.slice(0, index + 1).join('/') if (countedPaths.has(fullPath)) continue countedPaths.add(fullPath) const parentPath = index > 0 ? parts.slice(0, index).join('/') : null const existing = nodeByPath.get(fullPath) if (existing) { existing.employeeCount += 1 continue } nodeByPath.set(fullPath, { id: getDepartmentNodeId(fullPath), name: parts[index], parentId: parentPath ? getDepartmentNodeId(parentPath) : ALL_DEPARTMENTS_NODE_ID, parentName: parentPath ? parts[index - 1] : ALL_DEPARTMENTS_NODE_NAME, employeeCount: 1, selectable: true, value: getDepartmentPathValue(fullPath), departmentName: parts[index], departmentPath: fullPath, fullPath, }) } } } return [ { id: ALL_DEPARTMENTS_NODE_ID, name: ALL_DEPARTMENTS_NODE_NAME, parentId: null, parentName: null, employeeCount: employees.length, selectable: false, value: null, departmentPath: null, fullPath: null, }, ...Array.from(nodeByPath.values()).sort((a, b) => { const depthDiff = parseDepartmentPathParts(a.departmentPath).length - parseDepartmentPathParts(b.departmentPath).length if (depthDiff !== 0) return depthDiff if (b.employeeCount !== a.employeeCount) return b.employeeCount - a.employeeCount return a.fullPath.localeCompare(b.fullPath, 'zh-CN') }), ] } const promise = loadDepartments() .then((value) => { performanceSurveyDepartmentCache.delete(cacheKey) performanceSurveyDepartmentCache.set(cacheKey, { value, expiresAt: Date.now() + PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MS, }) prunePerformanceSurveyDepartmentCache() return value }) .finally(() => { performanceSurveyDepartmentCachePromises.delete(cacheKey) }) performanceSurveyDepartmentCachePromises.set(cacheKey, promise) return promise } export async function listPerformanceSurveys(session: Session | null, params?: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) const now = new Date() const pendingAssignments = await prisma.performanceSurveyAssignment.findMany({ where: { evaluatorKey: ctx.evaluatorKey, status: 'pending', OR: [{ workflowStageKey: 'legacy' }, { releasedAt: { not: null } }], survey: { status: 'open', OR: [ { dueAt: null }, { dueAt: { gt: now } }, ], }, }, include: { survey: true, target: true, }, orderBy: { createdAt: 'desc' }, take: 100, }) const submittedAssignmentCount = await prisma.performanceSurveyAssignment.count({ where: { evaluatorKey: ctx.evaluatorKey, status: 'submitted', survey: { status: { not: 'cancelled' } }, }, }) const submittedAssignments = await prisma.performanceSurveyAssignment.findMany({ where: { evaluatorKey: ctx.evaluatorKey, status: 'submitted', survey: { status: { not: 'cancelled' } }, }, include: { survey: true, target: true, }, orderBy: { submittedAt: 'desc' }, take: 100, }) const personalSurveyWhere = { creatorUserId: ctx.user.id } const managerWhere = ctx.isDepartmentAdmin ? await getManagerSurveyBaseWhere(ctx) : { creatorUserId: ctx.user.id } const managedFilteredWhere = ctx.isDepartmentAdmin ? await getManagerSurveyFilterWhere(ctx, params) : { creatorUserId: ctx.user.id } const createdSurveys = await prisma.performanceSurvey.findMany({ where: personalSurveyWhere, include: { targets: { where: ctx.employee?.id ? { OR: [ { employeeProfileId: ctx.employee.id }, { targetType: { not: 'employee' } }, ], } : { targetType: { not: 'employee' } }, select: { id: true, _count: { select: { assignments: true, responses: true }, }, }, take: 1, }, _count: { select: { targets: true, assignments: true, responses: true }, }, }, orderBy: { createdAt: 'desc' }, take: 100, }) const managedSurveys = ctx.isDepartmentAdmin ? await prisma.performanceSurvey.findMany({ where: managedFilteredWhere as any, include: { _count: { select: { targets: true, assignments: true, responses: true }, }, }, orderBy: { createdAt: 'desc' }, take: 200, }) : [] const templates = await listPerformanceSurveyTemplates() const scopedResponses = await prisma.performanceSurveyResponse.findMany({ where: { survey: { AND: [managerWhere, { status: { not: 'cancelled' } }] } as any, }, include: { survey: { select: { templateCode: true }, }, assignment: { select: { evaluatorSnapshot: true, workflowStageKey: true, workflowStageOrder: true, reviewerSource: true, }, }, }, orderBy: { createdAt: 'desc' }, take: 5000, }) const responsesByTemplate = new Map() for (const response of scopedResponses) { const list = responsesByTemplate.get(response.survey.templateCode) || [] list.push(response) responsesByTemplate.set(response.survey.templateCode, list) } const personalSummary = { pendingCount: pendingAssignments.length, createdCount: createdSurveys.length, openCreatedCount: createdSurveys.filter((survey) => survey.status === 'open').length, submittedCount: submittedAssignmentCount, } return { summary: { ...personalSummary, personal: personalSummary, }, pending: pendingAssignments.map((assignment) => ({ assignmentId: assignment.id, surveyId: assignment.surveyId, targetId: assignment.targetId, title: assignment.survey.title, status: assignment.status, surveyStatus: assignment.survey.status, dueAt: assignment.survey.dueAt, targetName: assignment.target.targetName, templateCode: assignment.survey.templateCode, submittedAt: assignment.submittedAt, })), submitted: submittedAssignments.map((assignment) => ({ assignmentId: assignment.id, surveyId: assignment.surveyId, targetId: assignment.targetId, title: assignment.survey.title, status: assignment.status, surveyStatus: assignment.survey.status, dueAt: assignment.survey.dueAt, targetName: assignment.target.targetName, templateCode: assignment.survey.templateCode, submittedAt: assignment.submittedAt, })), created: createdSurveys.map((survey) => ({ id: survey.id, title: survey.title, description: survey.description, surveyKind: survey.surveyKind, templateCode: survey.templateCode, status: survey.status, departmentName: survey.departmentName, assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, openedAt: survey.openedAt, closedAt: survey.closedAt, cancelledAt: survey.cancelledAt, cancelledByName: survey.cancelledByName, cancelReason: survey.cancelReason, canCancel: ctx.isDepartmentAdmin && survey.status !== 'cancelled', personalTargetId: survey.targets[0]?.id || null, createdAt: survey.createdAt, counts: { targets: survey.targets[0] ? 1 : 0, assignments: survey.targets[0]?._count.assignments || 0, responses: survey.targets[0]?._count.responses || 0, }, })), managed: managedSurveys.map((survey) => ({ id: survey.id, title: survey.title, description: survey.description, surveyKind: survey.surveyKind, templateCode: survey.templateCode, status: survey.status, departmentName: survey.departmentName, assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, openedAt: survey.openedAt, closedAt: survey.closedAt, cancelledAt: survey.cancelledAt, cancelledByName: survey.cancelledByName, cancelReason: survey.cancelReason, canCancel: survey.status !== 'cancelled', createdAt: survey.createdAt, creatorName: survey.creatorName, counts: survey._count, })), templateStats: templates.map((template) => { const templateResponses = responsesByTemplate.get(template.code) || [] const responses = templateResponses.filter((response) => ( isTemplateScoringAssignment(template, response.assignment) )) const aggregate = calculateAggregateScore( responses.map((response) => ({ id: response.id, totalScore: response.totalScore, createdAt: response.createdAt, scores: response.scores as Record, respondentName: response.respondentName, })), template.scoreBands, template.weight, template, ) return { code: template.code, name: template.name, shortName: template.shortName, weight: template.weight, weightLabel: template.weightLabel, achievementLabel: template.achievementLabel, achievementSuffix: template.achievementSuffix, responseCount: responses.length, aggregate, } }), permissions: { isSystemAdmin: ctx.isSystemAdmin, isDepartmentAdmin: ctx.isDepartmentAdmin, isLeadership: ctx.isLeadership, scopeDepartment: ctx.scopeDepartment, scopeDepartmentPath: ctx.scopeDepartmentPath, canCreate: canCreatePerformanceSurvey(ctx), }, } } function getDashboardTargetMonth(target: any) { return normalizeAssessmentMonth(target?.survey?.assessmentMonth) || getMonthValueFromDate(target?.survey?.dueAt || target?.survey?.createdAt || target?.createdAt) } function getTargetResponses(targets: any[]) { return targets.flatMap((target) => target.responses || []) } function getTargetAssignments(targets: any[]) { return targets.flatMap((target) => target.assignments || []) } function getTargetWorkItems(targets: any[]) { return targets.flatMap((target) => target.workItems || []) } function getTargetWorkItemCount(targets: any[]) { return targets.reduce((sum, target) => ( sum + Number(target?._count?.workItems ?? target?.workItems?.length ?? 0) ), 0) } function summarizeMonthlyScoreCell( template: PerformanceSurveyTemplateShape, targets: any[], detailAllowed: boolean, ) { if (targets.length === 0) { return { templateCode: template.code, positionGroupCode: null, title: null, titles: [], surveyId: null, targetId: null, status: null, assessmentMonth: null, dueAt: null, score: null, rawAverage: null, effectiveAverage: null, rawMaxTotal: Number(template.maxTotal || 120), scoreBand: null, surveyCount: 0, responseCount: 0, assignmentCount: 0, pendingCount: 0, detailAllowed: false, } } const primaryTarget = targets[0] const snapshot = primaryTarget?.survey?.templateSnapshot const scoringTemplate = snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) ? snapshot as PerformanceSurveyTemplateShape : template const allResponses = getTargetResponses(targets) const responses = allResponses.filter((response: any) => ( isTemplateScoringAssignment(scoringTemplate, response.assignment) )) const assignments = getTargetAssignments(targets) const responseItems = responses.map((response: any) => ({ id: response.id, totalScore: Number(response.totalScore || 0), createdAt: response.createdAt, scores: response.scores as Record, respondentName: response.respondentName, })) const aggregate = calculateAggregateScore( responseItems, scoringTemplate.scoreBands, scoringTemplate.weight, scoringTemplate, ) const titles = Array.from(new Set(targets.map((target) => clean(target.survey?.title)).filter(Boolean))) const statuses = targets.map((target) => clean(target.survey?.status)).filter(Boolean) const status = statuses.includes('open') ? 'open' : statuses.includes('draft') ? 'draft' : statuses[0] || null return { templateCode: template.code, positionGroupCode: clean(primaryTarget?.survey?.positionGroupCode), title: titles[0] || null, titles, surveyId: primaryTarget?.surveyId || primaryTarget?.survey?.id || null, targetId: primaryTarget?.id || null, status, assessmentMonth: getDashboardTargetMonth(primaryTarget), dueAt: primaryTarget?.survey?.dueAt || null, openedAt: primaryTarget?.survey?.openedAt || null, closedAt: primaryTarget?.survey?.closedAt || null, score: responses.length > 0 ? aggregate.achievementScore : null, rawAverage: responses.length > 0 ? aggregate.rawAverage : null, effectiveAverage: responses.length > 0 ? aggregate.trimmedAverage : null, rawMaxTotal: Number(aggregate.maxTotal || scoringTemplate.maxTotal || 120), scoreBand: responses.length > 0 ? aggregate.band : null, surveyCount: targets.length, responseCount: responses.length, assignmentCount: assignments.length, pendingCount: assignments.filter((assignment: any) => assignment.status !== 'submitted').length, workItemCount: getTargetWorkItemCount(targets), detailAllowed, } } function buildMonthlyRow( employee: any, month: string, templates: PerformanceSurveyTemplateShape[], targets: any[], detailAllowed: boolean, ) { const cells = Object.fromEntries( templates.map((template) => { const templateTargets = targets.filter((target) => target.survey.templateCode === template.code && getDashboardTargetMonth(target) === month) return [template.code, summarizeMonthlyScoreCell(template, templateTargets, detailAllowed)] }), ) return { month, employee: serializeEmployee(employee), cells, hasData: Object.values(cells).some((cell: any) => cell.surveyCount > 0), } } function getPublicDashboardEmployeeWhere(ctx: UserContext, params?: URLSearchParams) { const departmentValue = clean(params?.get('department')) const employeeId = clean(params?.get('employeeId')) const and: any[] = [] if (!ctx.isSystemAdmin && ctx.isLeadership) { and.push( ctx.scopeDepartmentPath ? getScopeDepartmentPathWhere(ctx.scopeDepartmentPath) : { id: '__none__' }, ) } if (departmentValue) { const departmentClauses = getDepartmentSelectionWhere([departmentValue]) if (departmentClauses.length > 0) and.push(mergeOrClauses(departmentClauses)) } if (employeeId) { and.push({ id: employeeId }) } return and.length === 0 ? {} : and.length === 1 ? and[0] : { AND: and } } async function getMonthlyTargets( employeeIds: string[], monthRange: { start: Date; end: Date }, positionGroupCode: string, ) { if (employeeIds.length === 0) return [] return prisma.performanceSurveyTarget.findMany({ where: { targetType: 'employee', employeeProfileId: { in: employeeIds }, survey: { AND: [ { status: { not: 'cancelled' } }, { positionGroupCode }, getSurveyMonthWhere(monthRange), ], }, } as any, select: { id: true, surveyId: true, employeeProfileId: true, createdAt: true, survey: { select: { id: true, title: true, templateCode: true, templateSnapshot: true, status: true, assessmentMonth: true, dueAt: true, openedAt: true, closedAt: true, createdAt: true, }, }, assignments: { select: { id: true, status: true, submittedAt: true, workflowStageKey: true, workflowStageOrder: true, reviewerSource: true, releasedAt: true, }, }, responses: { select: { id: true, totalScore: true, kpiScore: true, createdAt: true, scores: true, respondentName: true, assignment: { select: { evaluatorSnapshot: true, workflowStageKey: true, workflowStageOrder: true, reviewerSource: true, }, }, }, }, _count: { select: { workItems: true } }, }, orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }], take: 5000, }) } async function getPublicMonthlyTargets( ctx: UserContext, params: URLSearchParams | undefined, monthRange: { start: Date; end: Date }, positionGroupCodes: string[], ) { const employeeWhere = getPublicDashboardEmployeeWhere(ctx, params) const positionGroupWhere = positionGroupCodes.length === 1 ? { positionGroupCode: positionGroupCodes[0] } : { positionGroupCode: { in: positionGroupCodes } } return prisma.performanceSurveyTarget.findMany({ where: { targetType: 'employee', employeeProfileId: { not: null }, employeeProfile: { is: employeeWhere }, survey: { AND: [ { status: { not: 'cancelled' } }, positionGroupWhere, getSurveyMonthWhere(monthRange), ], }, } as any, select: { id: true, surveyId: true, employeeProfileId: true, employeeProfile: true, createdAt: true, survey: { select: { id: true, title: true, templateCode: true, positionGroupCode: true, templateSnapshot: true, status: true, assessmentMonth: true, dueAt: true, openedAt: true, closedAt: true, createdAt: true, }, }, assignments: { select: { id: true, status: true, submittedAt: true, workflowStageKey: true, workflowStageOrder: true, reviewerSource: true, releasedAt: true, }, }, responses: { select: { id: true, totalScore: true, kpiScore: true, createdAt: true, scores: true, respondentName: true, assignment: { select: { evaluatorSnapshot: true, workflowStageKey: true, workflowStageOrder: true, reviewerSource: true, }, }, }, }, _count: { select: { workItems: true } }, }, orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }], take: 5000, }) } async function assertNoMonthlyAssessmentDuplicate( db: any, templateCode: string, assessmentMonth: string, targetRows: Array<{ employeeProfileId?: string | null }>, excludeSurveyId?: string, ) { const employeeIds = Array.from(new Set(targetRows.map((target) => target.employeeProfileId).filter((id): id is string => Boolean(id)))) if (employeeIds.length === 0) return const monthRange = getMonthRange(assessmentMonth) if (!monthRange) throw new PerformanceSurveyApiError('考核月份格式无效') const existingTargets = await db.performanceSurveyTarget.findMany({ where: { targetType: 'employee', employeeProfileId: { in: employeeIds }, survey: { AND: [ { status: { not: 'cancelled' } }, { templateCode }, getSurveyMonthWhere(monthRange), ...(excludeSurveyId ? [{ id: { not: excludeSurveyId } }] : []), ], }, } as any, include: { employeeProfile: true, survey: true, }, take: 50, }) if (existingTargets.length === 0) return const duplicateNames = Array.from(new Set(existingTargets.map((target: any) => ( target.employeeProfile ? getEmployeeDisplayName(target.employeeProfile) : target.targetName )))) throw new PerformanceSurveyApiError( `${assessmentMonth} 已存在该模板考核:${duplicateNames.slice(0, 8).join('、')}${duplicateNames.length > 8 ? '等' : ''}`, ) } async function lockMonthlyAssessmentKeys( db: any, templateCode: string, assessmentMonths: Array, targetRows: Array<{ employeeProfileId?: string | null }>, ) { const employeeIds = Array.from(new Set( targetRows .map((target) => clean(target.employeeProfileId)) .filter((id): id is string => Boolean(id)), )) const months = Array.from(new Set( assessmentMonths .map((month) => normalizeAssessmentMonth(month)) .filter((month): month is string => Boolean(month)), )) const lockKeys = employeeIds .flatMap((employeeId) => months.map((month) => ( `performance-survey-monthly:${templateCode}:${month}:${employeeId}` ))) .sort() for (const lockKey of lockKeys) { await db.$queryRawUnsafe( 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))::text AS locked', lockKey, ) } } export async function getPerformanceSurveyDashboard( session: Session | null, params?: URLSearchParams, options: { includeAllTemplates?: boolean } = {}, ) { const ctx = await getPerformanceSurveyContext(session) const requestedView = clean(params?.get('view')) const dashboardView = requestedView === 'personal' || requestedView === 'public' ? requestedView : 'all' const includePersonal = dashboardView !== 'public' const includePublic = dashboardView !== 'personal' const currentMonth = getCurrentMonthRange() const monthRange = getMonthRange(params?.get('month')) || currentMonth const monthValue = clean(params?.get('month')) || currentMonth.value const positionGroups = await listPerformanceSurveyPositionGroups(monthValue, { includeArchived: true }) const personalMembership = ctx.employee ? await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, monthValue, { includeArchived: true }) : { status: 'unmatched' as const, group: null, matches: [], source: 'automatic' as const, manualAssignmentId: null } const requestedGroupCode = clean(params?.get('positionGroupCode')) const allPositionGroupsRequested = dashboardView === 'public' && requestedGroupCode === 'all' let selectedGroup = dashboardView === 'personal' ? personalMembership.group : allPositionGroupsRequested ? null : requestedGroupCode ? positionGroups.find((group) => group.code === requestedGroupCode) || null : personalMembership.group || positionGroups.find((group) => group.status === 'ready') || null if (requestedGroupCode && !allPositionGroupsRequested && dashboardView !== 'personal' && !selectedGroup) { throw new PerformanceSurveyApiError('考核岗位组不存在', 404) } if (selectedGroup?.status !== 'ready') selectedGroup = null const readyPositionGroups = positionGroups.filter((group) => group.status === 'ready') const allTemplates = allPositionGroupsRequested ? Array.from(new Map( readyPositionGroups .flatMap((group) => group.templates) .map((template) => [template.code, template] as const), ).values()) : selectedGroup?.templates || [] const templatePageResult = options.includeAllTemplates ? { items: allTemplates, pager: { page: 1, pageSize: allTemplates.length, totalPages: 1, totalItems: allTemplates.length, start: allTemplates.length > 0 ? 1 : 0, end: allTemplates.length, }, } : paginatePerformanceSurveyTemplates(allTemplates, params?.get('itemPage')) const templates = templatePageResult.items const personalMonths = includePersonal ? getMonthValuesSince(PERFORMANCE_SURVEY_HISTORY_START_MONTH) : [] const personalEmployee = ctx.employee const personalTargets = includePersonal && personalEmployee && selectedGroup?.code ? await getMonthlyTargets([personalEmployee.id], getMonthSpanRange(personalMonths), selectedGroup.code) : [] const publicPositionGroupCodes = allPositionGroupsRequested ? readyPositionGroups.map((group) => group.code) : selectedGroup?.code ? [selectedGroup.code] : [] const publicTargets = includePublic && publicPositionGroupCodes.length > 0 ? await getPublicMonthlyTargets(ctx, params, monthRange, publicPositionGroupCodes) : [] const publicEmployeeById = new Map() for (const target of publicTargets) { const employee = target.employeeProfile if (employee?.id && !publicEmployeeById.has(employee.id)) { publicEmployeeById.set(employee.id, employee) } } const publicEmployees = Array.from(publicEmployeeById.values()).sort((left, right) => { const departmentOrder = String(left.departmentName || '').localeCompare(String(right.departmentName || ''), 'zh-CN') if (departmentOrder !== 0) return departmentOrder return String(left.displayName || '').localeCompare(String(right.displayName || ''), 'zh-CN') }) const publicDetailAllowed = Boolean( ctx.isSystemAdmin || (ctx.isLeadership && ctx.scopeDepartmentPath), ) const publicTargetsByEmployee = new Map() for (const target of publicTargets) { const key = clean(target.employeeProfileId) if (!key) continue const current = publicTargetsByEmployee.get(key) || [] current.push(target) publicTargetsByEmployee.set(key, current) } const publicRows = publicEmployees .map((employee) => buildMonthlyRow( employee, monthValue, templates, publicTargetsByEmployee.get(employee.id) || [], publicDetailAllowed, )) return { month: monthValue, filters: { view: dashboardView, month: monthValue, department: clean(params?.get('department')), employeeId: clean(params?.get('employeeId')), positionGroupCode: allPositionGroupsRequested ? 'all' : selectedGroup?.code || null, itemPage: templatePageResult.pager.page, }, permissions: { isSystemAdmin: ctx.isSystemAdmin, isLeadership: ctx.isLeadership, isDepartmentAdmin: ctx.isDepartmentAdmin, scopeMode: ctx.isSystemAdmin ? 'system' : ctx.isLeadership && ctx.scopeDepartmentPath ? 'organization' : 'public-readonly', scopeDepartment: ctx.scopeDepartment, scopeDepartmentPath: ctx.scopeDepartmentPath, canOpenPersonalDetail: Boolean(ctx.employee), canOpenPublicDetail: publicDetailAllowed, canDeletePublicDashboardAssessment: canDeletePerformanceSurveyPublicDashboardAssessment(ctx), }, positionGroups: positionGroups.map((group) => ({ id: group.id, code: group.code, name: group.name, status: group.status, isActive: group.isActive, templateCount: group.templates.length, effectiveMonth: group.effectiveMonth, })), positionMembership: { status: personalMembership.status, groupCode: personalMembership.group?.code || null, groupName: personalMembership.group?.name || null, conflictGroupNames: personalMembership.matches.map((group) => group.name), }, selectedPositionGroup: selectedGroup ? { id: selectedGroup.id, code: selectedGroup.code, name: selectedGroup.name, status: selectedGroup.status, effectiveMonth: selectedGroup.effectiveMonth, } : null, templatePager: templatePageResult.pager, templates: templates.map((template) => ({ code: template.code, templateVersionId: template.templateVersionId, name: template.shortName || template.name, fullName: template.name, maxTotal: Number(template.maxTotal || 120), })), personal: { employee: personalEmployee ? serializeEmployee(personalEmployee) : null, rows: includePersonal && personalEmployee && selectedGroup?.code ? personalMonths.map((month) => buildMonthlyRow(personalEmployee, month, templates, personalTargets, true)) : [], }, public: { month: monthValue, rows: publicRows, }, } } export async function getPerformanceSurveyDashboardExport( session: Session | null, params?: URLSearchParams, ) { const exportParams = params ? new URLSearchParams(params) : new URLSearchParams() exportParams.set('view', 'public') return getPerformanceSurveyDashboard(session, exportParams, { includeAllTemplates: true }) } export async function removePerformanceSurveyPublicDashboardAssessment( session: Session | null, employeeProfileId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) ensureCanDeletePerformanceSurveyPublicDashboardAssessment(ctx) const normalizedEmployeeProfileId = clean(employeeProfileId) const month = normalizeAssessmentMonth(body?.month) const positionGroupCode = clean(body?.positionGroupCode) const templateCode = clean(body?.templateCode) const reason = clean(body?.reason) if (!normalizedEmployeeProfileId) throw new PerformanceSurveyApiError('缺少人员参数') if (!month) throw new PerformanceSurveyApiError('考核月份格式无效') if (!positionGroupCode || positionGroupCode === 'all') { throw new PerformanceSurveyApiError('缺少有效考核岗位参数') } if (!templateCode) throw new PerformanceSurveyApiError('缺少有效考核项参数') if (!reason) throw new PerformanceSurveyApiError('请填写移除原因') if (reason.length > 500) throw new PerformanceSurveyApiError('删除原因不能超过500字') const monthRange = getMonthRange(month) if (!monthRange) throw new PerformanceSurveyApiError('考核月份格式无效') const actorName = getDisplayName(ctx.user) const result = await prisma.$transaction(async (tx) => { const targets = await tx.performanceSurveyTarget.findMany({ where: { targetType: 'employee', employeeProfileId: normalizedEmployeeProfileId, survey: { AND: [ { status: { not: 'cancelled' } }, { positionGroupCode }, { templateCode }, getSurveyMonthWhere(monthRange), ], }, } as any, select: { id: true, targetName: true, surveyId: true, survey: { select: { id: true, title: true, templateCode: true, positionGroupCode: true, }, }, workItems: { select: { imageUrls: true, }, }, }, take: 500, }) if (targets.length === 0) { throw new PerformanceSurveyApiError('该人员的当前考核项已被删除或不存在', 409) } const targetIds = targets.map((target: any) => target.id) const surveyIds = Array.from(new Set(targets.map((target: any) => target.surveyId))) as string[] const attachmentUrls = Array.from(new Set( targets.flatMap((target: any) => ( target.workItems.flatMap((item: any) => { const images = serializePerformanceSurveyImagePayload(item.imageUrls) return [ ...images.imageUrls, ...images.imageGroups.map((group) => group.previewUrl), ] }) )), )) as string[] const deleteResult = await tx.performanceSurveyTarget.deleteMany({ where: { id: { in: targetIds }, }, }) if (deleteResult.count === 0) { throw new PerformanceSurveyApiError('该人员的当前考核项已被删除或不存在', 409) } const emptySurveyIds: string[] = [] for (const surveyId of surveyIds) { const remainingTargetCount = await tx.performanceSurveyTarget.count({ where: { surveyId }, }) if (remainingTargetCount === 0) emptySurveyIds.push(surveyId) } if (emptySurveyIds.length > 0) { await tx.performanceSurvey.deleteMany({ where: { id: { in: emptySurveyIds } }, }) } const employeeName = clean(targets[0]?.targetName) || '该人员' await tx.performanceSurveyConfigAuditLog.create({ data: { action: 'public_dashboard.assessment_deleted', targetType: 'public_dashboard_assessment', targetId: normalizedEmployeeProfileId, actorUserId: ctx.user.id, actorName, reason, beforeSnapshot: { employeeProfileId: normalizedEmployeeProfileId, employeeName, month, positionGroupCode, templateCode, targetIds, surveyIds, attachmentCount: attachmentUrls.length, }, afterSnapshot: { deleted: true, deletedTargetCount: deleteResult.count, deletedSurveyIds: emptySurveyIds, }, metadata: { surveyTitles: Array.from(new Set(targets.map((target: any) => target.survey.title))), }, }, }) return { employeeProfileId: normalizedEmployeeProfileId, employeeName, month, positionGroupCode, templateCode, deletedTargetCount: deleteResult.count, deletedSurveyCount: emptySurveyIds.length, attachmentUrls, } }) clearPerformanceSurveyDepartmentCache() const attachmentCleanup = await Promise.allSettled( result.attachmentUrls.map((url) => deleteFromS3(url)), ) const failedAttachmentCount = attachmentCleanup.filter((item) => item.status === 'rejected').length if (failedAttachmentCount > 0) { console.error( `[performance-survey] ${failedAttachmentCount} 个已删除考核项附件清理失败`, attachmentCleanup.filter((item) => item.status === 'rejected'), ) } return { employeeProfileId: result.employeeProfileId, employeeName: result.employeeName, month: result.month, positionGroupCode: result.positionGroupCode, templateCode: result.templateCode, deletedTargetCount: result.deletedTargetCount, deletedSurveyCount: result.deletedSurveyCount, deletedAttachmentCount: attachmentCleanup.length - failedAttachmentCount, failedAttachmentCount, } } export async function getPerformanceSurveyDashboardDetail(session: Session | null, params?: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) const monthRange = getMonthRange(params?.get('month')) || getCurrentMonthRange() const monthValue = clean(params?.get('month')) || getCurrentMonthRange().value const employeeId = clean(params?.get('employeeId')) const templateCode = clean(params?.get('templateCode')) const positionGroupCode = clean(params?.get('positionGroupCode')) if (!employeeId) throw new PerformanceSurveyApiError('缺少人员参数') if (!templateCode) throw new PerformanceSurveyApiError('缺少有效模板参数') const employee = await prisma.performanceSurveyEmployeeProfile.findFirst({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { id: employeeId }, ], }, }) if (!employee) throw new PerformanceSurveyApiError('人员不存在', 404) const isSelf = employee.id === ctx.employee?.id if (!isSelf) { if (!ctx.isSystemAdmin && (!ctx.isLeadership || !ctx.scopeDepartmentPath)) { throw new PerformanceSurveyApiError('无权限查看该人员考核明细', 403) } if (!ctx.isSystemAdmin && ctx.scopeDepartmentPath) { const count = await prisma.performanceSurveyEmployeeProfile.count({ where: { AND: [ { id: employee.id }, getScopeDepartmentPathWhere(ctx.scopeDepartmentPath), ], }, }) if (count === 0) throw new PerformanceSurveyApiError('无权限查看该人员考核明细', 403) } } const membership = await getPerformanceSurveyPositionGroupForEmployee( employee, monthValue, { includeArchived: true }, ) const effectivePositionGroupCode = positionGroupCode || membership.group?.code if (!effectivePositionGroupCode) throw new PerformanceSurveyApiError('该人员尚未匹配唯一的考核岗位组', 409) const positionGroup = await getPerformanceSurveyPositionGroupByCode( effectivePositionGroupCode, monthValue, { includeArchived: true }, ) const template = positionGroup.templates.find((item) => item.code === templateCode) if (!template) throw new PerformanceSurveyApiError('该考核项不属于当前岗位组', 404) const targets = await prisma.performanceSurveyTarget.findMany({ where: { targetType: 'employee', employeeProfileId: employee.id, survey: { AND: [ { status: { not: 'cancelled' } }, { templateCode }, { positionGroupCode: effectivePositionGroupCode }, getSurveyMonthWhere(monthRange), ], }, } as any, include: { survey: true, assignments: true, responses: { include: { respondentEmployee: true, assignment: true, }, orderBy: { createdAt: 'asc' }, }, workItems: { orderBy: { sortOrder: 'asc' }, include: { scores: { include: { evaluatorEmployee: true, assignment: true, }, orderBy: { createdAt: 'asc' }, }, }, }, }, orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }], take: 200, }) const surveyIds = Array.from(new Set(targets.map((target) => target.surveyId))) const notifications = surveyIds.length > 0 ? await prisma.performanceSurveyNotification.findMany({ where: { surveyId: { in: surveyIds }, assignmentId: { not: null }, eventType: 'survey.assignment.created', }, select: { id: true, surveyId: true, targetId: true, assignmentId: true, recipientName: true, eventType: true, status: true, readStatus: true, readAt: true, readStatusCheckedAt: true, sentAt: true, completedAt: true, lastError: true, providerMessageId: true, createdAt: true, }, orderBy: { createdAt: 'desc' }, }) : [] const notificationByAssignment = new Map() for (const notification of notifications) { if (notification.assignmentId && !notificationByAssignment.has(notification.assignmentId)) { notificationByAssignment.set(notification.assignmentId, notification) } } const getTargetTemplate = (target: any) => ( target?.survey?.templateSnapshot && typeof target.survey.templateSnapshot === 'object' && !Array.isArray(target.survey.templateSnapshot) ? target.survey.templateSnapshot as PerformanceSurveyTemplateShape : template ) const cellTemplate = targets[0] ? getTargetTemplate(targets[0]) : template const cell = summarizeMonthlyScoreCell(cellTemplate, targets, true) const responses = getTargetResponses(targets) const participants = responses.map((response: any) => { const target = targets.find((item) => item.id === response.targetId) const responseTemplate = target ? getTargetTemplate(target) : template return { id: response.id, surveyId: response.surveyId, surveyTitle: target?.survey.title || '', targetId: response.targetId, name: response.respondentEmployee ? getEmployeeDisplayName(response.respondentEmployee) : response.respondentName || '匿名评价', score: roundScore(Number(response.totalScore ?? 0)), rawScore: roundScore(Number(response.totalScore ?? 0)), rawMaxTotal: Number(responseTemplate.maxTotal || 120), dimensions: getPerformanceSurveyQuestionScoreDetails(responseTemplate, response.scores), comment: response.comment || '', submittedAt: response.createdAt, } }) const workItems = getTargetWorkItems(targets).map((item: any) => { const target = targets.find((entry) => entry.id === item.targetId) const workItemTemplate = target ? getTargetTemplate(target) : template const workItemMaxTotal = (workItemTemplate.questions || []) .reduce((sum, question) => sum + getQuestionMaxScore(workItemTemplate, question), 0) const scores = (item.scores || []).map((score: any) => ({ id: score.id, name: score.evaluatorEmployee ? getEmployeeDisplayName(score.evaluatorEmployee) : score.evaluatorName || '匿名评价', score: roundScore(Number(score.totalScore || 0)), rawMaxTotal: workItemMaxTotal, dimensions: getPerformanceSurveyQuestionScoreDetails(workItemTemplate, score.scores), comment: score.comment || '', submittedAt: score.createdAt, })) const images = serializePerformanceSurveyImagePayload(item.imageUrls) return { id: item.id, surveyId: item.surveyId, surveyTitle: targets.find((target) => target.id === item.targetId)?.survey.title || '', targetId: item.targetId, title: item.title, description: item.description, imageUrls: images.imageUrls, imageGroups: images.imageGroups, averageScore: average((item.scores || []) .filter((score: any) => isTemplateScoringAssignment(workItemTemplate, score.assignment)) .map((score: any) => Number(score.totalScore)).filter(Number.isFinite)), scores, } }) const surveys = targets.map((target) => { const targetResponses = target.responses || [] return { id: target.survey.id, targetId: target.id, title: target.survey.title, status: target.survey.status, assessmentMonth: normalizeAssessmentMonth(target.survey.assessmentMonth) || getDashboardTargetMonth(target), dueAt: target.survey.dueAt, openedAt: target.survey.openedAt, closedAt: target.survey.closedAt, createdAt: target.survey.createdAt, creatorName: target.survey.creatorName, responseCount: targetResponses.length, assignmentCount: target.assignments.length, pendingCount: target.assignments.filter((assignment) => assignment.status !== 'submitted').length, } }) const evaluators = targets.flatMap((target) => ( target.assignments.map((assignment) => { const notification = notificationByAssignment.get(assignment.id) const response = target.responses.find((item) => item.assignmentId === assignment.id) return { id: assignment.id, surveyId: target.survey.id, surveyTitle: target.survey.title, targetId: target.id, name: assignment.evaluatorName, assignmentStatus: assignment.status, submittedAt: assignment.submittedAt, score: response ? roundScore(Number(response.totalScore ?? 0)) : null, notification: notification ? { id: notification.id, status: notification.status, sentAt: notification.sentAt, completedAt: notification.completedAt, lastError: notification.lastError, providerMessageId: notification.providerMessageId, readStatus: notification.readStatus, readAt: notification.readAt, readStatusCheckedAt: notification.readStatusCheckedAt, } : null, } }) )) const assignmentCount = evaluators.length const submittedCount = evaluators.filter((item) => item.assignmentStatus === 'submitted').length const notifiedCount = evaluators.filter((item) => item.notification?.status === 'succeeded').length const readCount = evaluators.filter((item) => item.notification?.readStatus === 'read').length return { month: monthValue, employee: serializeEmployee(employee), template: { code: template.code, name: template.shortName || template.name, fullName: template.name, maxTotal: Number(cell.rawMaxTotal || cellTemplate.maxTotal || 120), isVisual: isVisualCaseTemplate(template), }, score: cell.score, rawAverage: cell.rawAverage, effectiveAverage: cell.effectiveAverage, rawMaxTotal: cell.rawMaxTotal, scoreBand: cell.scoreBand, surveyCount: cell.surveyCount, responseCount: cell.responseCount, surveys, evaluators, workflow: { assignmentCount, notifiedCount, readCount, submittedCount, pendingCount: Math.max(0, assignmentCount - submittedCount), isClosed: surveys.length > 0 && surveys.every((survey) => survey.status === 'closed'), }, participants, workItems, } } export async function createPerformanceSurvey(session: Session | null, body: any) { const ctx = await getPerformanceSurveyContext(session) if (!canCreatePerformanceSurvey(ctx)) { throw new PerformanceSurveyApiError('当前账户尚未匹配考核人员档案,暂时无法发起调研', 403) } const assessmentMonthInput = normalizePerformanceSurveyAssessmentMonth(body?.assessmentMonth) if (!assessmentMonthInput) throw new PerformanceSurveyApiError('请选择有效的考核月份') if (!ctx.employee) throw new PerformanceSurveyApiError('当前账户尚未匹配人员档案', 409) let creatorMembership try { creatorMembership = await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, assessmentMonthInput) } catch (error) { return rethrowPositionConfigError(error) } if (creatorMembership.status !== 'matched' || !creatorMembership.group) { throw new PerformanceSurveyApiError( creatorMembership.status === 'conflict' ? '当前被考核人命中多个考核岗位组,请联系管理员处理' : '当前被考核人尚未配置考核岗位组', 409, ) } const positionGroup = creatorMembership.group if (positionGroup.status !== 'ready') throw new PerformanceSurveyApiError('该岗位组尚未完成配置,不能发起调研', 409) const legacyRequestedPositionGroupCode = clean(body?.positionGroupCode) if (legacyRequestedPositionGroupCode && legacyRequestedPositionGroupCode !== positionGroup.code) { throw new PerformanceSurveyApiError('考核岗位已根据被考核人的人员分配自动关联,请刷新页面后重试', 409) } const templateVersionId = clean(body?.templateVersionId) const templateCode = clean(body?.templateCode) const template = positionGroup.templates.find((item) => ( !item.templateArchivedAt && (templateVersionId ? item.templateVersionId === templateVersionId : item.code === templateCode) )) if (!template) throw new PerformanceSurveyApiError('所选考核项不属于当前岗位组或版本已失效', 409) const requestedEvaluatorEmployeeIds = normalizeList(body?.evaluatorEmployeeIds) if (ctx.employee?.id && requestedEvaluatorEmployeeIds.includes(ctx.employee.id)) { throw new PerformanceSurveyApiError('发起人不能作为评分人员,请选择其他人员', 409) } const surveyKind = template.surveyType const openNow = Boolean(body?.openNow || body?.status === 'open') const period = resolvePerformanceSurveyPeriodInput({ assessmentMonth: assessmentMonthInput, dueAt: body?.dueAt, openNow, }) const assessmentMonth = period.assessmentMonth const dueAt = period.dueAt const title = formatAssessmentMonthTitle(assessmentMonth, template.shortName || template.name) const requestedDepartmentName = clean(body?.departmentName) const departmentName = requestedDepartmentName || ctx.scopeDepartment if (!ctx.isSystemAdmin && requestedDepartmentName && requestedDepartmentName !== ctx.scopeDepartment) { throw new PerformanceSurveyApiError('只能创建本人部门范围内的考核调研', 403) } if (surveyKind !== 'item_course' && !ctx.employee?.id) { throw new PerformanceSurveyApiError('当前账户尚未匹配考核人员档案,暂时无法发起调研', 409) } const targets = await resolveSurveyTargets(body, surveyKind, ctx, false) if (targets.length === 0) { throw new PerformanceSurveyApiError( surveyKind === 'item_course' ? `请填写${template.itemName || '课程、事项或活动'}名称` : `请至少选择 1 个${template.ownerRoleName || '被考核人'}`, ) } const targetEmployees = targets.map((target: any) => target.employee).filter(Boolean) for (const employee of targetEmployees) { const membership = await getPerformanceSurveyPositionGroupForEmployee(employee, assessmentMonth) if (membership.status !== 'matched') { throw new PerformanceSurveyApiError( `${getEmployeeDisplayName(employee)}${membership.status === 'conflict' ? '命中多个岗位组' : '尚未匹配岗位组'},不能发起调研`, 409, ) } if (membership.group?.code !== positionGroup.code) { throw new PerformanceSurveyApiError('同一次调研只能选择同一考核岗位组内的人员', 409) } } const manualStages = getManualReviewerStages(template) const manualEvaluators = manualStages.length > 0 ? await resolveSurveyEvaluators(body, ctx) : [] if (manualStages.length > 0 && manualEvaluators.length === 0) throw new PerformanceSurveyApiError('请至少选择 1 个评价人') const surveyId = randomUUID() const targetRows = targets.map((target: any, index: number) => ({ id: randomUUID(), surveyId, targetType: target.targetType, employeeProfileId: target.employee?.id || null, targetName: target.targetName, targetSnapshot: target.employee ? getEmployeeSnapshot(target.employee) : target.snapshot, sortOrder: index, })) const assignmentRows: any[] = [] for (const target of targetRows) { const targetSource = targets.find((item: any) => item.employee?.id === target.employeeProfileId) const targetEmployee = targetSource?.employee || target.targetSnapshot const targetEvaluators = await resolveConfiguredWorkflowEvaluators({ template, targetEmployee, manualEvaluators, }) const firstStageOrder = Math.min(...targetEvaluators.map((evaluator) => Number(evaluator.workflowStageOrder))) for (const evaluator of targetEvaluators) { const evaluatorDingtalkUserId = clean(evaluator.dingtalkUserId) || clean(evaluator.employee?.dingtalkUserId) const isCreatorEvaluator = Boolean( (ctx.employee?.id && evaluator.employee?.id === ctx.employee.id) || (evaluator.userId && evaluator.userId === ctx.user.id) || ( ctx.user.dingtalkUserId && evaluatorDingtalkUserId === clean(ctx.user.dingtalkUserId) ), ) if ( isCreatorEvaluator && evaluator.reviewerSource !== 'self' && evaluator.reviewerSource !== 'fixed_employee' ) { throw new PerformanceSurveyApiError('发起人不能作为评分人员,请选择其他人员', 409) } if ( surveyKind !== 'item_course' && target.employeeProfileId && evaluator.employee?.id === target.employeeProfileId && evaluator.reviewerSource !== 'self' && evaluator.reviewerSource !== 'fixed_employee' ) { throw new PerformanceSurveyApiError(`${target.targetName}不能作为自己的${evaluator.reviewerLabel || '评分人'}`, 409) } assignmentRows.push({ id: randomUUID(), surveyId, targetId: target.id, evaluatorEmployeeId: evaluator.employee?.id || null, evaluatorUserId: evaluator.userId || null, evaluatorDingtalkUserId: evaluator.dingtalkUserId || null, evaluatorKey: evaluator.evaluatorKey, evaluatorName: evaluator.name, evaluatorSnapshot: { ...(evaluator.employee ? getEmployeeSnapshot(evaluator.employee) : evaluator.snapshot), performanceSurveyWorkflowStageKey: evaluator.workflowStageKey, performanceSurveyWorkflowStageLabel: evaluator.workflowStageLabel, performanceSurveyReviewerSource: evaluator.reviewerSource, }, workflowStageKey: evaluator.workflowStageKey, workflowStageOrder: evaluator.workflowStageOrder, reviewerSource: evaluator.reviewerSource, releasedAt: evaluator.workflowStageOrder === firstStageOrder ? new Date() : null, status: 'pending', }) } } const targetsWithoutAssignments = targetRows.filter((target: any) => ( !assignmentRows.some((assignment) => assignment.targetId === target.id) )) if (targetsWithoutAssignments.length > 0) { throw new PerformanceSurveyApiError( `以下被考核人没有有效评分人:${targetsWithoutAssignments.map((target: any) => target.targetName).join('、')},请手动调整评分人员`, 409, ) } await prisma.$transaction(async (tx) => { await lockMonthlyAssessmentKeys(tx, template.code, [assessmentMonth], targetRows) await assertNoMonthlyAssessmentDuplicate( tx, template.code, assessmentMonth, targetRows, ) await tx.performanceSurvey.create({ data: { id: surveyId, title, description: clean(body?.description), surveyKind, templateCode: template.code, templateSnapshot: template as any, templateVersionId: template.templateVersionId || null, positionGroupId: positionGroup.id, positionGroupCode: positionGroup.code, positionGroupSnapshot: { id: positionGroup.id, code: positionGroup.code, name: positionGroup.name, planId: positionGroup.planId, planVersion: positionGroup.planVersion, effectiveMonth: positionGroup.effectiveMonth, snapshotAt: new Date().toISOString(), }, status: openNow ? 'open' : 'draft', creatorUserId: ctx.user.id, creatorName: getDisplayName(ctx.user), creatorEmployeeId: ctx.employee?.id || null, departmentName, assessmentMonth, dueAt, openedAt: openNow ? new Date() : null, }, }) await tx.performanceSurveyTarget.createMany({ data: targetRows }) await tx.performanceSurveyAssignment.createMany({ data: assignmentRows, skipDuplicates: true }) }) if (openNow) { await queueSurveyAssignmentNotifications(surveyId) await queueSurveyTargetConfirmationNotifications(surveyId) } return getPerformanceSurveyDetail(session, surveyId) } export async function getPerformanceSurveyDetail(session: Session | null, surveyId: string) { const ctx = await getPerformanceSurveyContext(session) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: { orderBy: { sortOrder: 'asc' }, include: { assignments: { select: { id: true, evaluatorName: true, evaluatorEmployeeId: true, evaluatorSnapshot: true, workflowStageKey: true, workflowStageOrder: true, reviewerSource: true, releasedAt: true, status: true, submittedAt: true, }, orderBy: { createdAt: 'asc' }, }, responses: true, workItems: { orderBy: { sortOrder: 'asc' }, include: { scores: { orderBy: { createdAt: 'asc' }, }, }, }, }, }, assignments: { where: { evaluatorKey: ctx.evaluatorKey }, include: { response: true }, }, _count: { select: { assignments: true, responses: true, targets: true }, }, }, }) if (!survey) { throw new PerformanceSurveyApiError('调研不存在或无权限', 404) } const isCreator = survey.creatorUserId === ctx.user.id const visibleTargets = isCreator ? survey.targets : getPersonalSurveyTargets(ctx, survey, survey.targets) const isAssigned = survey.assignments.length > 0 const isTarget = Boolean( ctx.employee && visibleTargets.some((target) => target.employeeProfileId === ctx.employee?.id), ) const canManage = isCreator if (visibleTargets.length === 0 || (!isCreator && !isAssigned && !isTarget)) { throw new PerformanceSurveyApiError('调研不存在或无权限', 404) } const currentAssignments = new Map() for (const assignment of survey.assignments) { const previous = currentAssignments.get(assignment.targetId) const assignmentIsOpen = assignment.status === 'pending' && (assignment.workflowStageKey === 'legacy' || Boolean(assignment.releasedAt)) const previousIsOpen = previous?.status === 'pending' && (previous.workflowStageKey === 'legacy' || Boolean(previous.releasedAt)) if ( !previous || (assignmentIsOpen && !previousIsOpen) || (assignmentIsOpen === previousIsOpen && assignment.workflowStageOrder > previous.workflowStageOrder) ) { currentAssignments.set(assignment.targetId, assignment) } } return { id: survey.id, title: survey.title, description: survey.description, surveyKind: survey.surveyKind, templateCode: survey.templateCode, template: survey.templateSnapshot, positionGroupCode: survey.positionGroupCode, positionGroup: survey.positionGroupSnapshot, status: survey.status, creatorName: survey.creatorName, departmentName: survey.departmentName, assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, openedAt: survey.openedAt, closedAt: survey.closedAt, cancelledAt: survey.cancelledAt, cancelledByName: survey.cancelledByName, cancelReason: survey.cancelReason, counts: { targets: visibleTargets.length, assignments: visibleTargets.reduce((sum, target) => sum + target.assignments.length, 0), responses: visibleTargets.reduce((sum, target) => sum + target.responses.length, 0), }, canManage, canCancel: ctx.isDepartmentAdmin && canManage && survey.status !== 'cancelled', canSubmit: isAssigned && survey.status === 'open' && await canSubmitCurrentVisualStage(survey, survey.assignments), canViewResults: survey.status === 'closed' && (isCreator || isTarget), targets: visibleTargets.map((target) => { const currentAssignment = currentAssignments.get(target.id) const response = currentAssignment?.response const isVisualTemplate = isVisualCaseTemplate( (survey.templateSnapshot as PerformanceSurveyTemplateShape) || survey.templateCode, ) const requiredWorkItemCount = isVisualTemplate ? Math.max(1, Number((survey.templateSnapshot as PerformanceSurveyTemplateShape).requiredWorkItemCount || 3)) : 0 const workItemUploadRemaining = isVisualTemplate ? Math.max(0, requiredWorkItemCount - target.workItems.length) : 0 // 兼容旧调用方:该字段仅表示当前人是否为视觉评分对象;新调用方应使用下方容量感知字段决定是否展示上传操作。 const canUploadWorkItems = Boolean( isVisualTemplate && ctx.employee?.id && target.employeeProfileId === ctx.employee.id, ) const canCreateWorkItem = Boolean( isVisualTemplate && workItemUploadRemaining > 0 && (survey.status === 'open' || (survey.status === 'draft' && canManage)) && (canManage || canUploadWorkItems), ) return { id: target.id, targetType: target.targetType, targetName: target.targetName, targetSnapshot: target.targetSnapshot, responseCount: target.responses.length, assignmentCount: target.assignments.length, canUploadWorkItems, canCreateWorkItem, requiredWorkItemCount, workItemUploadRemaining, workItems: target.workItems.map((item) => { const images = serializePerformanceSurveyImagePayload(item.imageUrls) return { id: item.id, title: item.title, description: item.description, imageUrls: images.imageUrls, imageGroups: images.imageGroups, sortOrder: item.sortOrder, submittedAt: item.submittedAt, scores: canManage ? item.scores.map((score) => ({ id: score.id, assignmentId: score.assignmentId, evaluatorName: score.evaluatorName, scores: score.scores, comment: score.comment, totalScore: score.totalScore, createdAt: score.createdAt, })) : item.scores .filter((score) => score.assignmentId === currentAssignment?.id) .map((score) => ({ id: score.id, assignmentId: score.assignmentId, evaluatorName: score.evaluatorName, scores: score.scores, comment: score.comment, totalScore: score.totalScore, createdAt: score.createdAt, })), } }), currentAssignment: currentAssignment ? { id: currentAssignment.id, status: currentAssignment.status, submittedAt: currentAssignment.submittedAt, workflowRole: readWorkflowRole(currentAssignment.evaluatorSnapshot), response: response ? { id: response.id, scores: response.scores, comment: response.comment, totalScore: response.totalScore, achievementScore: response.totalScore, kpiScore: response.totalScore, weightedScore: response.totalScore, createdAt: response.createdAt, } : null, } : null, assignments: canManage ? target.assignments.map((assignment) => ({ id: assignment.id, evaluatorEmployeeId: assignment.evaluatorEmployeeId, evaluatorName: assignment.evaluatorName, evaluatorSnapshot: assignment.evaluatorSnapshot, reviewerSource: assignment.reviewerSource, status: assignment.status, submittedAt: assignment.submittedAt, })) : [], } }), } } export async function updatePerformanceSurvey(session: Session | null, surveyId: string, body: any) { const ctx = await getPerformanceSurveyContext(session) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: { select: { employeeProfileId: true }, }, }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) await ensureSurveyManageScope(ctx, survey) if (survey.status === 'cancelled') throw new PerformanceSurveyApiError('已撤销的调研不能修改') const nextStatus = clean(body?.status) const data: any = {} const isMonthlyAssessment = Boolean(survey.positionGroupId || survey.templateVersionId) const currentAssessmentMonth = normalizeAssessmentMonth(survey.assessmentMonth) || getMonthValueFromDate(survey.dueAt || survey.createdAt) if (body?.title != null && !isMonthlyAssessment) { data.title = clean(body.title) || survey.title } if (body?.description !== undefined) data.description = clean(body.description) if (body?.assessmentMonth !== undefined) { const assessmentMonth = normalizeAssessmentMonth(body.assessmentMonth) if (!assessmentMonth) throw new PerformanceSurveyApiError('考核月份格式无效') data.assessmentMonth = assessmentMonth } if (isMonthlyAssessment && (body?.title != null || body?.assessmentMonth !== undefined)) { const template = survey.templateSnapshot as PerformanceSurveyTemplateShape const effectiveAssessmentMonth = data.assessmentMonth || currentAssessmentMonth const templateName = clean(template.shortName) || clean(template.name) || survey.templateCode data.title = formatAssessmentMonthTitle(effectiveAssessmentMonth, templateName) } if (body?.dueAt !== undefined) { const requestedDueAt = clean(body.dueAt) data.dueAt = toDate(body.dueAt) if (requestedDueAt && !data.dueAt) throw new PerformanceSurveyApiError('截止时间格式无效') } if (nextStatus === 'open' && survey.status === 'draft') { data.status = 'open' data.openedAt = new Date() } else if (nextStatus === 'closed' && survey.status !== 'closed') { data.status = 'closed' data.closedAt = new Date() } else if (nextStatus && !['draft', 'open', 'closed'].includes(nextStatus)) { throw new PerformanceSurveyApiError('无效的调研状态') } const effectiveDueAt = body?.dueAt !== undefined ? data.dueAt : survey.dueAt const effectiveStatus = data.status || survey.status if (effectiveStatus === 'open' && effectiveDueAt && effectiveDueAt.getTime() <= Date.now()) { throw new PerformanceSurveyApiError('截止时间必须晚于当前时间') } await prisma.$transaction(async (tx) => { const lockedSurvey = await lockPerformanceSurvey(tx, surveyId) if (!lockedSurvey) throw new PerformanceSurveyApiError('调研不存在', 404) if (lockedSurvey.status === 'cancelled') { throw new PerformanceSurveyApiError('已撤销的调研不能修改') } if (data.status === 'open' && lockedSurvey.status === 'draft') { const template = survey.templateSnapshot as PerformanceSurveyTemplateShape if (isVisualCaseTemplate(template)) { const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3)) const targets = await tx.performanceSurveyTarget.findMany({ where: { surveyId }, select: { targetName: true, _count: { select: { workItems: true } }, }, }) const incompleteTargets = targets.filter((target) => target._count.workItems < requiredWorkItemCount) if (incompleteTargets.length > 0) { throw new PerformanceSurveyApiError( `视觉评分作品尚未上传完整:${incompleteTargets .map((target) => `${target.targetName}(${target._count.workItems}/${requiredWorkItemCount})`) .join('、')}`, 409, ) } } } if (isMonthlyAssessment && body?.assessmentMonth !== undefined) { await lockMonthlyAssessmentKeys( tx, survey.templateCode, [currentAssessmentMonth, data.assessmentMonth], survey.targets, ) await assertNoMonthlyAssessmentDuplicate( tx, survey.templateCode, data.assessmentMonth, survey.targets, survey.id, ) } await tx.performanceSurvey.update({ where: { id: surveyId }, data, }) }) if (data.status === 'open') { await queueSurveyAssignmentNotifications(surveyId) await queueSurveyTargetConfirmationNotifications(surveyId) } if (data.status === 'closed') { await queueSurveyResultNotifications(surveyId) await queueSurveyCreatorNotification(surveyId, 'survey.creator.closed') } return getPerformanceSurveyDetail(session, surveyId) } export async function cancelPerformanceSurvey(session: Session | null, surveyId: string, body: any) { const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId } }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) await ensureSurveyManageScope(ctx, survey) if (survey.status === 'cancelled') throw new PerformanceSurveyApiError('该调研已撤销') const reason = clean(body?.reason) if (!reason) throw new PerformanceSurveyApiError('请填写撤销原因') if (reason.length > 500) throw new PerformanceSurveyApiError('撤销原因不能超过500字') const cancelledAt = new Date() await prisma.$transaction(async (tx) => { const lockedSurvey = await lockPerformanceSurvey(tx, surveyId) if (!lockedSurvey) throw new PerformanceSurveyApiError('调研不存在', 404) if (lockedSurvey.status === 'cancelled') throw new PerformanceSurveyApiError('该调研已撤销') await tx.performanceSurvey.update({ where: { id: surveyId }, data: { status: 'cancelled', cancelledAt, cancelledByUserId: ctx.user.id, cancelledByName: ctx.user.realName || ctx.user.username, cancelReason: reason, }, }) await tx.performanceSurveyNotification.updateMany({ where: { surveyId, status: { in: ['pending', 'retrying', 'processing'] }, }, data: { status: 'skipped', nextRetryAt: null, completedAt: cancelledAt, lastError: `调研已撤销:${reason}`, }, }) }) return getPerformanceSurveyDetail(session, surveyId) } export async function submitPerformanceSurveyResponse(session: Session | null, surveyId: string, body: any) { const ctx = await getPerformanceSurveyContext(session) const targetId = clean(body?.targetId) if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象') const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭') if (survey.dueAt && survey.dueAt.getTime() < Date.now()) { throw new PerformanceSurveyApiError('调研已超过截止时间') } const assignment = await prisma.performanceSurveyAssignment.findFirst({ where: { surveyId, targetId, evaluatorKey: ctx.evaluatorKey, status: 'pending', OR: [{ workflowStageKey: 'legacy' }, { releasedAt: { not: null } }], }, orderBy: { workflowStageOrder: 'asc' }, include: { target: true, response: true, }, }) if (!assignment) throw new PerformanceSurveyApiError('你没有该评价对象的填写权限', 403) if (assignment.response || assignment.status === 'submitted') { throw new PerformanceSurveyApiError('你已经提交过该评价,不能重复提交') } await assertAssignmentStageOpen(survey, assignment) const template = survey.templateSnapshot as PerformanceSurveyTemplateShape const submittedScores = calculateSubmittedPerformanceSurveyScores(template, body?.scores) const scoreResult = submittedScores.result const response = await prisma.$transaction(async (tx) => { assertLockedSurveyOpen(await lockPerformanceSurvey(tx, surveyId)) const created = await tx.performanceSurveyResponse.create({ data: { id: randomUUID(), surveyId, targetId, assignmentId: assignment.id, respondentUserId: ctx.user.id, respondentEmployeeId: ctx.employee?.id || null, respondentName: getDisplayName(ctx.user), respondentSnapshot: ctx.employee ? getEmployeeSnapshot(ctx.employee) : { userId: ctx.user.id, displayName: getDisplayName(ctx.user), departmentName: ctx.user.department, snapshotAt: new Date().toISOString(), }, scores: submittedScores.scores as any, comment: clean(body?.comment), totalScore: scoreResult.totalScore, achievementScore: scoreResult.achievementScore, kpiScore: scoreResult.kpiScore, weightedScore: scoreResult.weightedScore, }, }) await tx.performanceSurveyAssignment.update({ where: { id: assignment.id }, data: { status: 'submitted', submittedAt: new Date(), }, }) return created }) const pendingCount = await prisma.performanceSurveyAssignment.count({ where: { surveyId, status: 'pending' }, }) await queuePostSubmissionNotifications({ surveyId, targetId, completedStageOrder: assignment.workflowStageOrder, notifyCreator: pendingCount === 0, releaseVisualEvaluators: isVisualCaseTemplate(template) && isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot)), }) return { id: response.id, message: '提交成功', } } export async function getPerformanceSurveyResults( session: Session | null, surveyId: string, params?: URLSearchParams, ) { const ctx = await getPerformanceSurveyContext(session) const targetIdFilter = clean(params?.get('targetId')) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: { where: targetIdFilter ? { id: targetIdFilter } : undefined, orderBy: { sortOrder: 'asc' }, include: { responses: { include: { assignment: true, }, orderBy: { createdAt: 'asc' }, }, workItems: { orderBy: { sortOrder: 'asc' }, include: { scores: { orderBy: { createdAt: 'asc' }, }, }, }, _count: { select: { assignments: true, responses: true }, }, }, }, _count: { select: { assignments: true, responses: true, targets: true }, }, }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在或无权限', 404) const visibleTargets = getPersonalSurveyTargets(ctx, survey, survey.targets) const isCreator = survey.creatorUserId === ctx.user.id const isTarget = Boolean( ctx.employee && visibleTargets.some((target) => target.employeeProfileId === ctx.employee?.id), ) const canManage = isCreator const canView = visibleTargets.length > 0 && (isCreator || (survey.status === 'closed' && isTarget)) if (!canView) throw new PerformanceSurveyApiError('无权限查看结果', 403) if (survey.status !== 'closed') { throw new PerformanceSurveyApiError('调研关闭后才能查看结果', 403) } const template = survey.templateSnapshot as PerformanceSurveyTemplateShape const showRealRespondent = canManage return { survey: { id: survey.id, title: survey.title, description: survey.description, status: survey.status, templateCode: survey.templateCode, template, assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, closedAt: survey.closedAt, counts: { targets: visibleTargets.length, assignments: visibleTargets.reduce((sum, target) => sum + target._count.assignments, 0), responses: visibleTargets.reduce((sum, target) => sum + target._count.responses, 0), }, }, targets: visibleTargets.map((target) => { const participantByAssignmentId = new Map( target.responses.map((response, index) => [ response.assignmentId, { key: `participant-${index + 1}`, name: showRealRespondent ? response.respondentName : `匿名评价 ${index + 1}`, }, ]), ) const aggregateResponses = target.responses.filter((response) => ( isTemplateScoringAssignment(template, response.assignment) )) const aggregate = calculateAggregateScore( aggregateResponses.map((response) => ({ id: response.id, totalScore: response.totalScore, createdAt: response.createdAt, scores: response.scores as Record, respondentName: response.respondentName, })), template.scoreBands, template.weight, template, ) const visibleAggregate = showRealRespondent ? aggregate : { ...aggregate, removedHigh: maskRemovedRespondentName(aggregate.removedHigh), removedLow: maskRemovedRespondentName(aggregate.removedLow), } return { id: target.id, targetType: target.targetType, targetName: target.targetName, targetSnapshot: target.targetSnapshot, aggregate: visibleAggregate, responses: target.responses.map((response, index) => ({ id: response.id, participantKey: participantByAssignmentId.get(response.assignmentId)?.key || `participant-${index + 1}`, respondentName: participantByAssignmentId.get(response.assignmentId)?.name || `匿名评价 ${index + 1}`, scores: response.scores, comment: response.comment, totalScore: response.totalScore, achievementScore: response.totalScore, kpiScore: response.totalScore, weightedScore: response.totalScore, workflowRole: readWorkflowRole(response.assignment.evaluatorSnapshot), createdAt: response.createdAt, })), workItems: target.workItems.map((item) => { const images = serializePerformanceSurveyImagePayload(item.imageUrls) const scores = item.scores.map((score) => { const participant = participantByAssignmentId.get(score.assignmentId) return { id: score.id, participantKey: participant?.key || null, evaluatorName: participant?.name || (showRealRespondent ? score.evaluatorName : '匿名评价'), scores: score.scores, comment: score.comment, totalScore: score.totalScore, workflowRole: readWorkflowRole( target.responses.find((response) => response.assignmentId === score.assignmentId)?.assignment.evaluatorSnapshot, ), workflowStageKey: target.responses.find((response) => response.assignmentId === score.assignmentId)?.assignment.workflowStageKey, reviewerSource: target.responses.find((response) => response.assignmentId === score.assignmentId)?.assignment.reviewerSource, createdAt: score.createdAt, } }) const aggregateScores = scores.filter((score) => isTemplateScoringAssignment(template, score)) return { id: item.id, title: item.title, description: item.description, imageUrls: images.imageUrls, imageGroups: images.imageGroups, averageScore: average(aggregateScores.map((score) => Number(score.totalScore)).filter(Number.isFinite)), scores, } }), } }), permissions: { showRealRespondent, canManage, }, } } type ResolvedPerformanceSurveyProcessedImage = { declaration: PerformanceSurveyProcessedImageDeclaration filePath: string fileSize: number } type PerformanceSurveyWorkItemImageSource = { name: string size: number mimeType: string readBuffer: () => Promise } type PersistedPerformanceSurveyWorkItem = { id: string surveyId: string targetId: string submitterUserId: string | null title: string description: string | null imageUrls: unknown status: string submittedAt: Date | null } function readFormText(formData: FormData, key: string) { return clean(formData.get(key)) } function readImageFiles(formData: FormData) { return ['images', 'image', 'files'] .flatMap((key) => formData.getAll(key)) .filter((value): value is File => typeof File !== 'undefined' && value instanceof File && value.size > 0) } async function cleanupPerformanceSurveyChunkedUploads( userId: string, uploads: Array<{ uploadId: string; filePath: string }>, ) { await Promise.allSettled(uploads.flatMap(({ uploadId, filePath }) => [ unlink(filePath), rm(path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId), { recursive: true, force: true }), deleteOwnedUploadSession(uploadId, userId), ])) } function serializeCreatedPerformanceSurveyWorkItem(created: PersistedPerformanceSurveyWorkItem) { const images = serializePerformanceSurveyImagePayload(created.imageUrls) return { id: created.id, title: created.title, description: created.description, imageUrls: images.imageUrls, imageGroups: images.imageGroups, submittedAt: created.submittedAt, } } function isCompleteCommittedPerformanceSurveyWorkItem( created: PersistedPerformanceSurveyWorkItem, expected: { id: string surveyId: string targetId: string submitterUserId: string imageGroups: PerformanceSurveyStoredImageGroup[] }, ) { if ( created.id !== expected.id || created.surveyId !== expected.surveyId || created.targetId !== expected.targetId || created.submitterUserId !== expected.submitterUserId || created.status !== 'submitted' || !created.submittedAt ) { return false } const storedImages = serializePerformanceSurveyImagePayload(created.imageUrls) return JSON.stringify(storedImages.imageGroups) === JSON.stringify(expected.imageGroups) } async function cleanupConsumedPerformanceSurveyUploadSessions(uploadIds: string[]) { const cleanupResults = await Promise.allSettled( uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId)), ) cleanupResults.forEach((result, index) => { if (result.status === 'rejected') { console.warn( `[performance-survey] 案例已保存,但上传会话 ${uploadIds[index]} 清理失败:`, result.reason, ) return } if (!result.value.success) { console.warn( `[performance-survey] 案例已保存,但上传会话 ${uploadIds[index]} 未能安全清理:`, result.value, ) } }) } export async function createPerformanceSurveyWorkItemFromRequest( session: Session | null, surveyId: string, request: Request, ) { const contentType = (request.headers.get('content-type') || '').toLowerCase() if (!contentType.includes('application/json')) { throw new PerformanceSurveyApiError('旧版图片上传协议已停用,请刷新页面并使用最新版 Chrome 或 Edge 重新上传', 409) } const body = await request.json() if (Array.isArray(body?.processedImages)) { return createPerformanceSurveyWorkItemFromProcessedUploads(session, surveyId, body) } if (Array.isArray(body?.images)) { throw new PerformanceSurveyApiError('原始图片上传协议已停用,请刷新页面后重新上传', 409) } throw new PerformanceSurveyApiError('缺少预处理图片') } async function readPerformanceSurveyWebpFileDimensions(filePath: string) { const handle = await open(filePath, 'r') try { const header = Buffer.alloc(64) const { bytesRead } = await handle.read(header, 0, header.length, 0) return readPerformanceSurveyWebpDimensions(header.subarray(0, bytesRead)) } finally { await handle.close() } } async function resolvePerformanceSurveyProcessedUpload( declaration: PerformanceSurveyProcessedImageDeclaration, userId: string, ): Promise { const uploadSession = await getOwnedUploadSession(declaration.uploadId, userId) if (!uploadSession) throw new PerformanceSurveyApiError('图片上传已过期,请重新选择图片') if (uploadSession.fileSize > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxArtifactBytes) { throw new PerformanceSurveyApiError('单张预处理图片不能超过 8MB') } const filePath = await getOwnedUploadedFile(declaration.uploadId, userId) if (!filePath) throw new PerformanceSurveyApiError(`图片「${declaration.sourceName}」上传不完整,请重试`) return { declaration, filePath, fileSize: uploadSession.fileSize, } } async function createPerformanceSurveyWorkItemFromProcessedUploads( session: Session | null, surveyId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) const userId = clean((session?.user as any)?.id) if (!userId) throw new PerformanceSurveyApiError('请先登录', 401) const targetId = clean(body?.targetId) if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象') let groups: ReturnType try { groups = validatePerformanceSurveyProcessedImages(body?.processedImages) } catch (error) { if (error instanceof PerformanceSurveyProcessedImageError) { throw new PerformanceSurveyApiError(error.message) } throw error } const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: true, workItems: { where: { targetId }, select: { id: true } }, }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) const template = survey.templateSnapshot as PerformanceSurveyTemplateShape if (!isVisualCaseTemplate(template)) throw new PerformanceSurveyApiError('当前模板不支持案例上传') const target = survey.targets.find((item) => item.id === targetId) if (!target) throw new PerformanceSurveyApiError('评价对象不存在', 404) const canManage = await canManageSurvey(ctx, survey) if (survey.status !== 'open' && !(survey.status === 'draft' && canManage)) { throw new PerformanceSurveyApiError('调研未开放或已关闭') } const canUpload = canManage || Boolean(ctx.employee?.id && target.employeeProfileId === ctx.employee.id) if (!canUpload) throw new PerformanceSurveyApiError('无权限提交该案例', 403) const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3)) if (survey.workItems.length >= requiredWorkItemCount) { throw new PerformanceSurveyApiError(`当前调研最多提交 ${requiredWorkItemCount} 个案例`) } const uploadIds = Array.from(new Set( groups.flatMap((group) => [ ...group.slices.map((declaration) => declaration.uploadId), ...(group.preview ? [group.preview.uploadId] : []), ]), )).sort() const workItemId = randomUUID() const resolvedByUploadId = new Map() const uploadedUrls: string[] = [] let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null const transactionOutcome: { state: 'not_attempted' | 'attempted' | 'not_committed' | 'committed' | 'unknown' } = { state: 'not_attempted' } try { releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds) let totalBytes = 0 for (const group of groups) { for (const declaration of [...group.slices, ...(group.preview ? [group.preview] : [])]) { const resolved = await resolvePerformanceSurveyProcessedUpload(declaration, userId) totalBytes += resolved.fileSize if (totalBytes > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxBatchBytes) { throw new PerformanceSurveyApiError('单个案例的预处理图片总大小不能超过 256MB') } const dimensions = await readPerformanceSurveyWebpFileDimensions(resolved.filePath) if (dimensions.width !== declaration.width || dimensions.height !== declaration.height) { throw new PerformanceSurveyApiError(`图片「${declaration.sourceName}」尺寸校验失败,请重新上传`) } resolvedByUploadId.set(declaration.uploadId, resolved) } } const uploadBatchId = `${Date.now()}-${randomUUID()}` const imageGroups: PerformanceSurveyStoredImageGroup[] = [] for (const group of groups) { const sliceRecords: PerformanceSurveyStoredImageGroup['slices'] = [] for (const declaration of group.slices) { const resolved = resolvedByUploadId.get(declaration.uploadId)! const fileName = [ 'performance-survey/work-items', surveyId, targetId, `${uploadBatchId}-${group.sourceIndex + 1}-${declaration.sliceIndex + 1}.webp`, ].join('/') const url = await uploadFileToS3(resolved.filePath, fileName, declaration.mimeType) uploadedUrls.push(url) sliceRecords.push({ url, width: declaration.width, height: declaration.height }) } const firstSlice = sliceRecords[0] let previewUrl = firstSlice.url let previewWidth = firstSlice.width let previewHeight = firstSlice.height if (group.preview) { const resolved = resolvedByUploadId.get(group.preview.uploadId)! const previewFileName = [ 'performance-survey/work-items', surveyId, targetId, `${uploadBatchId}-${group.sourceIndex + 1}-preview.webp`, ].join('/') previewUrl = await uploadFileToS3(resolved.filePath, previewFileName, group.preview.mimeType) uploadedUrls.push(previewUrl) previewWidth = group.preview.width previewHeight = group.preview.height } const width = Math.max(...sliceRecords.map((slice) => slice.width)) const height = sliceRecords.reduce((sum, slice) => sum + slice.height, 0) imageGroups.push({ version: 1, sourceName: group.sourceName, sourceWidth: width, sourceHeight: height, width, height, previewUrl, previewWidth, previewHeight, slices: sliceRecords, }) } const title = clean(body?.title) || `案例 ${survey.workItems.length + 1}` const description = clean(body?.description) let created: PersistedPerformanceSurveyWorkItem transactionOutcome.state = 'attempted' releaseUploadSessionLocks.assertHeld() try { created = await prisma.$transaction(async (tx) => { assertLockedSurveyAcceptsWorkItem( await lockPerformanceSurvey(tx, surveyId), survey.status === 'draft' && canManage, ) const currentCount = await tx.performanceSurveyWorkItem.count({ where: { surveyId, targetId } }) if (currentCount >= requiredWorkItemCount) { throw new PerformanceSurveyApiError(`当前调研最多提交 ${requiredWorkItemCount} 个案例`) } return tx.performanceSurveyWorkItem.create({ data: { id: workItemId, surveyId, targetId, submitterUserId: ctx.user.id, submitterEmployeeId: ctx.employee?.id || null, title, description, imageUrls: imageGroups as any, sortOrder: currentCount, status: 'submitted', submittedAt: new Date(), }, }) }) as PersistedPerformanceSurveyWorkItem transactionOutcome.state = 'committed' } catch (transactionError) { let persisted: PersistedPerformanceSurveyWorkItem | null try { persisted = await prisma.performanceSurveyWorkItem.findUnique({ where: { id: workItemId }, select: { id: true, surveyId: true, targetId: true, submitterUserId: true, title: true, description: true, imageUrls: true, status: true, submittedAt: true, }, }) as PersistedPerformanceSurveyWorkItem | null } catch (inspectionError) { transactionOutcome.state = 'unknown' console.error('[performance-survey] 案例事务异常后无法确认数据库最终状态:', inspectionError) throw new PerformanceSurveyApiError( '案例保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交', 503, ) } if (!persisted) { transactionOutcome.state = 'not_committed' throw transactionError } if (!isCompleteCommittedPerformanceSurveyWorkItem(persisted, { id: workItemId, surveyId, targetId, submitterUserId: ctx.user.id, imageGroups, })) { transactionOutcome.state = 'unknown' console.error('[performance-survey] 案例事务异常后发现不完整或不匹配的落库记录:', { workItemId, surveyId, targetId, }) throw new PerformanceSurveyApiError( '案例保存记录不完整,服务器已保留图片和上传会话,请联系管理员核查', 503, ) } transactionOutcome.state = 'committed' created = persisted } try { releaseUploadSessionLocks.assertHeld() await cleanupConsumedPerformanceSurveyUploadSessions(uploadIds) } catch (cleanupError) { console.warn('[performance-survey] 案例已保存,但上传会话清理阶段异常:', cleanupError) } return serializeCreatedPerformanceSurveyWorkItem(created) } catch (error) { if (transactionOutcome.state === 'not_attempted' || transactionOutcome.state === 'not_committed') { if (uploadedUrls.length > 0) { try { releaseUploadSessionLocks?.assertHeld() } catch (lockError) { console.error('[performance-survey] 无法确认上传会话锁仍有效,保留本次 S3 图片:', lockError) throw new PerformanceSurveyApiError( '案例保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交', 503, ) } await Promise.allSettled(uploadedUrls.map((url) => deleteFromS3(url))) } } if (error instanceof PerformanceSurveyProcessedImageError) { throw new PerformanceSurveyApiError(error.message) } throw error } finally { await releaseUploadSessionLocks?.() } } export async function createPerformanceSurveyWorkItem(session: Session | null, surveyId: string, formData: FormData) { const files = readImageFiles(formData) const totalSourceBytes = files.reduce((sum, file) => sum + file.size, 0) if (totalSourceBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxLegacyBatchSourceBytes) { throw new PerformanceSurveyApiError('旧版直传请求总大小不能超过 60MB,请刷新页面后重试') } return createPerformanceSurveyWorkItemFromSources(session, surveyId, { targetId: readFormText(formData, 'targetId'), title: readFormText(formData, 'title'), description: readFormText(formData, 'description'), sources: sortPerformanceSurveyImagesByName(files, (file) => file.name).map((file) => ({ name: file.name, size: file.size, mimeType: file.type, readBuffer: async () => Buffer.from(await file.arrayBuffer()), })), }) } async function createPerformanceSurveyWorkItemFromSources( session: Session | null, surveyId: string, input: { targetId: string | null title: string | null description: string | null sources: PerformanceSurveyWorkItemImageSource[] }, ) { const ctx = await getPerformanceSurveyContext(session) const targetId = input.targetId if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象') const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: true, workItems: { where: { targetId }, select: { id: true } }, }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) const template = survey.templateSnapshot as PerformanceSurveyTemplateShape if (!isVisualCaseTemplate(template)) throw new PerformanceSurveyApiError('当前模板不支持案例上传') const target = survey.targets.find((item) => item.id === targetId) if (!target) throw new PerformanceSurveyApiError('评价对象不存在', 404) const canManage = await canManageSurvey(ctx, survey) if (survey.status !== 'open' && !(survey.status === 'draft' && canManage)) { throw new PerformanceSurveyApiError('调研未开放或已关闭') } const canUpload = canManage || Boolean(ctx.employee?.id && target.employeeProfileId === ctx.employee.id) if (!canUpload) throw new PerformanceSurveyApiError('无权限提交该案例', 403) const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3)) if (survey.workItems.length >= requiredWorkItemCount) { throw new PerformanceSurveyApiError(`当前调研最多提交 ${requiredWorkItemCount} 个案例`) } const sources = input.sources if (sources.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片') if (sources.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) { throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张切片`) } const title = input.title || `案例 ${survey.workItems.length + 1}` const description = input.description const plans = [] for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { const source = sources[sourceIndex] if (!source.mimeType.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件') if (source.size > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) { throw new PerformanceSurveyApiError('单张图片不能超过 200MB') } const input = { buffer: await source.readBuffer(), mimeType: source.mimeType, sourceName: source.name || `case-image-${sourceIndex + 1}`, } try { plans.push(await inspectPerformanceSurveyImage(input, sourceIndex)) } catch (error) { if (error instanceof PerformanceSurveyImageProcessingError) { throw new PerformanceSurveyApiError(error.message) } throw error } } try { assertPerformanceSurveyImageBatch(plans) } catch (error) { if (error instanceof PerformanceSurveyImageProcessingError) { throw new PerformanceSurveyApiError(error.message) } throw error } const uploadBatchId = `${Date.now()}-${randomUUID()}` const uploadedUrls: string[] = [] const imageGroups: PerformanceSurveyStoredImageGroup[] = [] try { for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { const source = sources[sourceIndex] const input = { buffer: await source.readBuffer(), mimeType: source.mimeType, sourceName: source.name || `case-image-${sourceIndex + 1}`, } const sliceRecords: PerformanceSurveyStoredImageGroup['slices'] = [] const processed = await processPerformanceSurveyImage(input, plans[sourceIndex], async (image) => { const fileName = [ 'performance-survey/work-items', surveyId, targetId, `${uploadBatchId}-${image.sourceIndex + 1}-${image.sliceIndex + 1}.webp`, ].join('/') const url = await uploadToS3(image.buffer, fileName, 'image/webp') uploadedUrls.push(url) sliceRecords.push({ url, width: image.width, height: image.height }) }) const previewFileName = [ 'performance-survey/work-items', surveyId, targetId, `${uploadBatchId}-${sourceIndex + 1}-preview.webp`, ].join('/') const previewUrl = await uploadToS3(processed.preview.buffer, previewFileName, 'image/webp') uploadedUrls.push(previewUrl) imageGroups.push({ version: 1, sourceName: processed.plan.sourceName, sourceWidth: processed.plan.sourceWidth, sourceHeight: processed.plan.sourceHeight, width: processed.plan.outputWidth, height: processed.plan.outputHeight, previewUrl, previewWidth: processed.preview.width, previewHeight: processed.preview.height, slices: sliceRecords, }) } const created = await prisma.$transaction(async (tx) => { assertLockedSurveyAcceptsWorkItem( await lockPerformanceSurvey(tx, surveyId), survey.status === 'draft' && canManage, ) const currentCount = await tx.performanceSurveyWorkItem.count({ where: { surveyId, targetId } }) if (currentCount >= requiredWorkItemCount) { throw new PerformanceSurveyApiError(`当前调研最多提交 ${requiredWorkItemCount} 个案例`) } return tx.performanceSurveyWorkItem.create({ data: { id: randomUUID(), surveyId, targetId, submitterUserId: ctx.user.id, submitterEmployeeId: ctx.employee?.id || null, title, description, imageUrls: imageGroups as any, sortOrder: currentCount, status: 'submitted', submittedAt: new Date(), }, }) }) const images = serializePerformanceSurveyImagePayload(created.imageUrls) return { id: created.id, title: created.title, description: created.description, imageUrls: images.imageUrls, imageGroups: images.imageGroups, submittedAt: created.submittedAt, } } catch (error) { await Promise.allSettled(uploadedUrls.map((url) => deleteFromS3(url))) if (error instanceof PerformanceSurveyImageProcessingError) { throw new PerformanceSurveyApiError(error.message) } throw error } } type PerformanceSurveyWorkItemScoreBatchItem = { workItemId: string scores: Record comment: string | null scoreResult: ReturnType } function readPerformanceSurveyWorkItemScoreBatch( template: PerformanceSurveyTemplateShape, body: any, ) { const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3)) const rawItems = body?.items if (!Array.isArray(rawItems) || rawItems.length === 0 || rawItems.length > requiredWorkItemCount) { throw new PerformanceSurveyApiError(`请提交 1-${requiredWorkItemCount} 个案例的评分`) } const seenWorkItemIds = new Set() return rawItems.map((rawItem, index): PerformanceSurveyWorkItemScoreBatchItem => { const workItemId = clean(rawItem?.workItemId) if (!workItemId) { throw new PerformanceSurveyApiError(`第 ${index + 1} 个案例缺少案例标识`) } if (seenWorkItemIds.has(workItemId)) { throw new PerformanceSurveyApiError('同一案例不能重复评分') } seenWorkItemIds.add(workItemId) const submittedScores = calculateSubmittedPerformanceSurveyScores(template, rawItem?.scores) return { workItemId, scores: submittedScores.scores, comment: clean(rawItem?.comment), scoreResult: submittedScores.result, } }) } export async function submitPerformanceSurveyWorkItemScores( session: Session | null, surveyId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭') const template = survey.templateSnapshot as PerformanceSurveyTemplateShape if (!isVisualCaseTemplate(template)) throw new PerformanceSurveyApiError('当前模板不支持案例评分') const submissions = readPerformanceSurveyWorkItemScoreBatch(template, body) const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3)) const submittedWorkItemIds = submissions.map((item) => item.workItemId) const submittedWorkItems = await prisma.performanceSurveyWorkItem.findMany({ where: { surveyId, id: { in: submittedWorkItemIds }, }, select: { id: true, targetId: true }, }) if (submittedWorkItems.length !== submissions.length) { throw new PerformanceSurveyApiError('案例不存在或不属于当前调研', 404) } const targetIds = Array.from(new Set(submittedWorkItems.map((item) => item.targetId))) if (targetIds.length !== 1) { throw new PerformanceSurveyApiError('三个案例必须属于同一位被考核人') } const targetId = targetIds[0] const assignment = await prisma.performanceSurveyAssignment.findFirst({ where: { surveyId, targetId, evaluatorKey: ctx.evaluatorKey, status: 'pending', OR: [{ workflowStageKey: 'legacy' }, { releasedAt: { not: null } }], }, orderBy: { workflowStageOrder: 'asc' }, include: { response: true }, }) if (!assignment) throw new PerformanceSurveyApiError('你没有该案例的评分权限', 403) if (assignment.response || assignment.status === 'submitted') { throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交') } await assertAssignmentStageOpen(survey, assignment) const result = await prisma.$transaction(async (tx) => { assertLockedSurveyOpen(await lockPerformanceSurvey(tx, surveyId)) const currentAssignment = await tx.performanceSurveyAssignment.findFirst({ where: { id: assignment.id }, include: { response: true }, }) if (!currentAssignment) throw new PerformanceSurveyApiError('你没有该案例的评分权限', 403) if (currentAssignment.response || currentAssignment.status === 'submitted') { throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交') } const currentWorkItems = await tx.performanceSurveyWorkItem.findMany({ where: { surveyId, targetId }, select: { id: true }, orderBy: { sortOrder: 'asc' }, }) const currentWorkItemIds = new Set(currentWorkItems.map((item: { id: string }) => item.id)) if ( currentWorkItems.length !== requiredWorkItemCount || submittedWorkItemIds.some((workItemId) => !currentWorkItemIds.has(workItemId)) ) { throw new PerformanceSurveyApiError(`请一次完成当前 ${requiredWorkItemCount} 个案例的评分`) } const existingScores: Array<{ workItemId: string totalScore: number scores: unknown }> = await tx.performanceSurveyWorkItemScore.findMany({ where: { assignmentId: currentAssignment.id }, select: { workItemId: true, totalScore: true, scores: true, }, }) const existingWorkItemIds = new Set(existingScores.map((score) => score.workItemId)) const remainingWorkItemIds = currentWorkItems .map((item: { id: string }) => item.id) .filter((workItemId: string) => !existingWorkItemIds.has(workItemId)) const submittedWorkItemIdSet = new Set(submittedWorkItemIds) const includesAlreadyScoredWorkItem = submittedWorkItemIds.some((workItemId) => existingWorkItemIds.has(workItemId)) const missesRemainingWorkItem = remainingWorkItemIds.some((workItemId) => !submittedWorkItemIdSet.has(workItemId)) const includesUnexpectedWorkItem = submittedWorkItemIds.some((workItemId) => !remainingWorkItemIds.includes(workItemId)) if ( existingWorkItemIds.size !== existingScores.length || includesAlreadyScoredWorkItem || missesRemainingWorkItem || includesUnexpectedWorkItem || submissions.length !== remainingWorkItemIds.length ) { throw new PerformanceSurveyApiError(`请一次提交剩余 ${remainingWorkItemIds.length} 个未评分案例的完整评分`, 409) } const createdScores: Array<{ workItemId: string totalScore: number scores: unknown }> = [] for (const submission of submissions) { createdScores.push(await tx.performanceSurveyWorkItemScore.create({ data: { id: randomUUID(), workItemId: submission.workItemId, surveyId, targetId, assignmentId: currentAssignment.id, evaluatorUserId: ctx.user.id, evaluatorEmployeeId: ctx.employee?.id || null, evaluatorName: getDisplayName(ctx.user), scores: submission.scores as any, comment: submission.comment, totalScore: submission.scoreResult.totalScore, }, })) } const assignmentScores = [...existingScores, ...createdScores] const totalScore = assignmentScores.reduce((sum, score) => sum + Number(score.totalScore || 0), 0) const aggregateScores = Object.fromEntries( template.questions.map((question) => [ question.id, assignmentScores.reduce((sum, score) => { const values = score.scores as Record return sum + Number(values?.[question.id] || 0) }, 0), ]), ) const response = await tx.performanceSurveyResponse.create({ data: { id: randomUUID(), surveyId, targetId, assignmentId: currentAssignment.id, respondentUserId: ctx.user.id, respondentEmployeeId: ctx.employee?.id || null, respondentName: getDisplayName(ctx.user), respondentSnapshot: ctx.employee ? getEmployeeSnapshot(ctx.employee) : { userId: ctx.user.id, displayName: getDisplayName(ctx.user), departmentName: ctx.user.department, snapshotAt: new Date().toISOString(), }, scores: { ...aggregateScores, workItemScores: Object.fromEntries(assignmentScores.map((score) => [score.workItemId, score.totalScore])), dimensions: aggregateScores, }, comment: clean(body?.finalComment), totalScore, achievementScore: totalScore, kpiScore: totalScore, weightedScore: totalScore, }, }) await tx.performanceSurveyAssignment.update({ where: { id: currentAssignment.id }, data: { status: 'submitted', submittedAt: new Date(), }, }) return { response, createdScores } }) const pendingCount = await prisma.performanceSurveyAssignment.count({ where: { surveyId, status: 'pending' }, }) await queuePostSubmissionNotifications({ surveyId, targetId, completedStageOrder: assignment.workflowStageOrder, notifyCreator: pendingCount === 0, releaseVisualEvaluators: isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot)), }) return { id: result.response.id, completed: true, workItemScoreCount: result.createdScores.length, message: '全部案例评分完成', } } export async function submitPerformanceSurveyWorkItemScore( session: Session | null, surveyId: string, workItemId: string, body: any, ) { const ctx = await getPerformanceSurveyContext(session) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, }) if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404) if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭') const template = survey.templateSnapshot as PerformanceSurveyTemplateShape if (!isVisualCaseTemplate(template)) throw new PerformanceSurveyApiError('当前模板不支持案例评分') const workItem = await prisma.performanceSurveyWorkItem.findUnique({ where: { id: workItemId }, include: { target: true }, }) if (!workItem || workItem.surveyId !== surveyId) throw new PerformanceSurveyApiError('案例不存在', 404) const assignment = await prisma.performanceSurveyAssignment.findFirst({ where: { surveyId, targetId: workItem.targetId, evaluatorKey: ctx.evaluatorKey, status: 'pending', OR: [{ workflowStageKey: 'legacy' }, { releasedAt: { not: null } }], }, orderBy: { workflowStageOrder: 'asc' }, include: { response: true, }, }) if (!assignment) throw new PerformanceSurveyApiError('你没有该案例的评分权限', 403) if (assignment.response || assignment.status === 'submitted') { throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交') } await assertAssignmentStageOpen(survey, assignment) const submittedScores = calculateSubmittedPerformanceSurveyScores(template, body?.scores) const scoreResult = submittedScores.result const existing = await prisma.performanceSurveyWorkItemScore.findFirst({ where: { workItemId, assignmentId: assignment.id, }, }) if (existing) throw new PerformanceSurveyApiError('该案例已经评分,不能重复提交') const result = await prisma.$transaction(async (tx) => { assertLockedSurveyOpen(await lockPerformanceSurvey(tx, surveyId)) const created = await tx.performanceSurveyWorkItemScore.create({ data: { id: randomUUID(), workItemId, surveyId, targetId: workItem.targetId, assignmentId: assignment.id, evaluatorUserId: ctx.user.id, evaluatorEmployeeId: ctx.employee?.id || null, evaluatorName: getDisplayName(ctx.user), scores: submittedScores.scores as any, comment: clean(body?.comment), totalScore: scoreResult.totalScore, }, }) const workItems = await tx.performanceSurveyWorkItem.findMany({ where: { surveyId, targetId: workItem.targetId }, orderBy: { sortOrder: 'asc' }, include: { scores: { where: { assignmentId: assignment.id }, }, }, }) const requiredCount = Number(template.requiredWorkItemCount || 3) const allRequiredScored = workItems.length >= requiredCount && workItems.every((item) => item.scores.length > 0) if (allRequiredScored) { const assignmentScores = workItems.map((item) => item.scores[0]).filter(Boolean) const totalScore = assignmentScores.reduce((sum, score) => sum + Number(score.totalScore || 0), 0) const aggregateScores = Object.fromEntries( template.questions.map((question) => [ question.id, assignmentScores.reduce((sum, score) => { const values = score.scores as Record return sum + Number(values?.[question.id] || 0) }, 0), ]), ) await tx.performanceSurveyResponse.create({ data: { id: randomUUID(), surveyId, targetId: workItem.targetId, assignmentId: assignment.id, respondentUserId: ctx.user.id, respondentEmployeeId: ctx.employee?.id || null, respondentName: getDisplayName(ctx.user), respondentSnapshot: ctx.employee ? getEmployeeSnapshot(ctx.employee) : { userId: ctx.user.id, displayName: getDisplayName(ctx.user), departmentName: ctx.user.department, snapshotAt: new Date().toISOString(), }, scores: { ...aggregateScores, workItemScores: Object.fromEntries(assignmentScores.map((score) => [score.workItemId, score.totalScore])), dimensions: aggregateScores, }, comment: clean(body?.finalComment), totalScore, achievementScore: totalScore, kpiScore: totalScore, weightedScore: totalScore, }, }) await tx.performanceSurveyAssignment.update({ where: { id: assignment.id }, data: { status: 'submitted', submittedAt: new Date(), }, }) } return { created, completed: allRequiredScored } }) const pendingCount = await prisma.performanceSurveyAssignment.count({ where: { surveyId, status: 'pending' }, }) await queuePostSubmissionNotifications({ surveyId, targetId: workItem.targetId, completedStageOrder: assignment.workflowStageOrder, notifyCreator: pendingCount === 0, releaseVisualEvaluators: result.completed && isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot)), }) return { id: result.created.id, completed: result.completed, message: result.completed ? '全部案例评分完成' : '案例评分已保存', } } function maskRemovedRespondentName(item: T) { if (!item) return item return { ...item, respondentName: '已隐藏' } } export async function syncPerformanceSurveyEmployeeProfiles(session: Session | null, body: any) { const ctx = await getPerformanceSurveyContext(session) if (!ctx.isSystemAdmin) throw new PerformanceSurveyApiError('需要系统管理员权限', 403) const result = await syncPerformanceSurveyEmployees({ limit: Number(body?.limit || 3000) }) clearPerformanceSurveyDepartmentCache() return result } export async function previewPerformanceSurveyEmployeeProfiles(session: Session | null, body: any) { await ensureReadOnlySystemAdministrator(session) try { return await previewPerformanceSurveyEmployeeSync({ limit: Number(body?.limit || 3000) }) } catch (error) { const code = error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code || '').toUpperCase() : '' const message = error instanceof Error ? error.message.toLowerCase() : '' const timeout = ['ETIMEOUT', 'ESOCKETTIMEDOUT', 'ECONNTIMEOUT'].includes(code) || message.includes('timeout') || message.includes('timed out') || message.includes('超时') console.error('[performance-survey] 人员同步预检失败:', error) throw new PerformanceSurveyApiError( timeout ? '人员同步预检超时:上游花名册暂时响应较慢,请稍后重试。预检未写入任何数据。' : '人员同步预检失败:暂时无法读取上游花名册,请稍后重试。预检未写入任何数据。', timeout ? 504 : 502, ) } } export async function exportPerformanceSurveyCsv(session: Session | null) { const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) const where = await getManagerSurveyBaseWhere(ctx) const responses = await prisma.performanceSurveyResponse.findMany({ where: { survey: { AND: [where, { status: { not: 'cancelled' } }] } as any, }, include: { survey: true, target: true, assignment: true, }, orderBy: { createdAt: 'desc' }, take: 5000, }) const rows = [ ['提交时间', '调研ID', '考核月份', '模板代码', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '得分', '满分', '建议'], ...responses.map((response) => { const template = response.survey.templateSnapshot as PerformanceSurveyTemplateShape const respondentSnapshot = response.respondentSnapshot as Record const scores = response.scores as Record const dimensionScores = (scores?.dimensions || {}) as Record const scoreText = (template.questions || []) .map((question) => { const value = scores?.[question.id] ?? dimensionScores?.[question.id] ?? '' return `${question.title}:${value}/${getQuestionMaxScore(template, question)}` }) .join(';') return [ response.createdAt.toISOString(), response.surveyId, response.survey.assessmentMonth || getMonthValueFromDate(response.survey.dueAt || response.survey.createdAt), response.survey.templateCode, template.name || response.survey.templateCode, response.survey.creatorName, response.survey.creatorUserId, response.survey.title, response.target.targetName, response.respondentName, readSnapshotText(respondentSnapshot, 'departmentName') || readSnapshotText(respondentSnapshot, 'departmentPath'), scoreText, String(response.totalScore), String(template.maxTotal || ''), response.comment || '', ] }), ] return rows.map((row) => row.map(escapeCsv).join(',')).join('\n') } export async function listPerformanceSurveyNotifications(session: Session | null, params: URLSearchParams) { const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) const status = clean(params.get('status')) const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 200)) const where: any = {} if (status && status !== 'all') where.status = status if (!ctx.isSystemAdmin) { where.survey = await getManagerSurveyBaseWhere(ctx) } const notifications = await prisma.performanceSurveyNotification.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit, }) return notifications.map((item) => ({ id: item.id, surveyId: item.surveyId, recipientName: item.recipientName, eventType: item.eventType, title: item.title, status: item.status, attemptCount: item.attemptCount, maxAttempts: item.maxAttempts, nextRetryAt: item.nextRetryAt, lastError: item.lastError, readStatus: item.readStatus, readAt: item.readAt, readStatusCheckedAt: item.readStatusCheckedAt, sentAt: item.sentAt, completedAt: item.completedAt, createdAt: item.createdAt, })) } export async function getPerformanceSurveyNotificationStatus(session: Session | null) { const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) const where: any = {} if (!ctx.isSystemAdmin) { where.survey = await getManagerSurveyBaseWhere(ctx) } const grouped = await prisma.performanceSurveyNotification.groupBy({ by: ['status'], where, _count: { _all: true }, }) const notificationChannel = getDingtalkPerformanceSurveyNotificationChannel() const notificationConfig = notificationChannel === 'robot_oto' ? getDingtalkRobotOtoConfigStatus() : getDingtalkWorkNoticeConfigStatus() const robotOtoTestUserCount = notificationChannel === 'robot_oto' ? getDingtalkRobotOtoTestUserIds().length : 0 return { workNotice: notificationConfig, notificationChannel: { channel: notificationChannel, rolloutMode: notificationChannel === 'robot_oto' && robotOtoTestUserCount > 0 ? 'allowlist' : 'all', testUserCount: robotOtoTestUserCount, ...notificationConfig, }, counts: Object.fromEntries(grouped.map((row) => [row.status, row._count._all])), } } export async function retryPerformanceSurveyNotificationById(session: Session | null, notificationId: string) { const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) const notification = await prisma.performanceSurveyNotification.findUnique({ where: { id: notificationId }, include: { survey: true }, }) if (!notification) throw new PerformanceSurveyApiError('通知记录不存在', 404) if (notification.survey && !(await canManageSurvey(ctx, notification.survey))) { throw new PerformanceSurveyApiError('无权限补发该通知', 403) } await retryPerformanceSurveyNotification(notificationId) return { message: '已加入补发队列' } } export async function runPerformanceSurveyNotificationMaintenance(session: Session | null) { const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) const reminderIds = await queueDuePerformanceSurveyReminders() const recovered = await recoverDuePerformanceSurveyNotifications() const missingAssignmentsRecovered = await recoverMissingSurveyAssignmentNotifications() const readStatusRefreshed = await refreshPerformanceSurveyNotificationReadStatuses() return { reminderCount: reminderIds.length, recovered, missingAssignmentsRecovered, readStatusRefreshed } } async function resolveSurveyTargets( body: any, surveyKind: string, ctx: UserContext, forceCurrentEmployee = false, ) { const currentEmployeeId = ctx.employee?.id const targetEmployeeIds = forceCurrentEmployee ? (currentEmployeeId ? [currentEmployeeId] : []) : getPerformanceSurveyTargetEmployeeIds({ requestedEmployeeIds: body?.targetEmployeeIds, currentEmployeeId, surveyKind, }) const itemTargets = Array.isArray(body?.targetItems) ? body.targetItems .map((item: any) => ({ targetType: 'item', targetName: clean(item?.name || item?.targetName), snapshot: { name: clean(item?.name || item?.targetName), description: clean(item?.description), snapshotAt: new Date().toISOString(), }, })) .filter((item: any) => item.targetName) : [] // 写入权限只接受完整组织路径;旧档案的部门名回退仅用于普通查询, // 不能用来决定跨人员的被考核人范围。 const targetScopeWhere = ctx.scopeDepartmentPath ? getScopeDepartmentPathWhere(ctx.scopeDepartmentPath) : null const allowedTargetClauses = ctx.isSystemAdmin ? [] : [ mergeOrClauses([ currentEmployeeId ? { id: currentEmployeeId } : null, targetScopeWhere, ]), ] const employeeTargets = targetEmployeeIds.length > 0 ? await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { id: { in: targetEmployeeIds } }, ...allowedTargetClauses, ], }, }) : [] if (employeeTargets.length !== targetEmployeeIds.length) { throw new PerformanceSurveyApiError( ctx.isSystemAdmin ? '部分被考核人不存在或已不可用' : '部分被考核人不在当前组织权限范围内', ctx.isSystemAdmin ? 400 : 403, ) } const targets = employeeTargets.map((employee) => ({ targetType: 'employee', targetName: getEmployeeDisplayName(employee), employee, snapshot: getEmployeeSnapshot(employee), })) if (surveyKind === 'item_course') { return [ ...itemTargets, ...targets, ] } return targets.length > 0 ? targets : itemTargets } async function resolveSurveyEvaluators(body: any, ctx: UserContext) { const evaluatorEmployeeIds = normalizeList(body?.evaluatorEmployeeIds) const evaluatorDepartmentNames = normalizeList(body?.evaluatorDepartmentNames) const or: any[] = [] if (evaluatorEmployeeIds.length > 0) { or.push({ id: { in: evaluatorEmployeeIds } }) } if (evaluatorDepartmentNames.length > 0) { or.push(...getDepartmentSelectionWhere(evaluatorDepartmentNames)) } if (or.length === 0) return [] const and: any[] = [ getActivePerformanceSurveyEmployeeProfileWhere(), { OR: or }, ] if (ctx.employee?.id) { and.push({ id: { not: ctx.employee.id } }) } const where: any = { AND: and } const employees = or.length > 0 ? await prisma.performanceSurveyEmployeeProfile.findMany({ where, orderBy: { displayName: 'asc' } }) : [] const dingtalkUserIds = employees.map((employee) => clean(employee.dingtalkUserId)).filter((item): item is string => Boolean(item)) const dingEmails = employees.map((employee) => clean(employee.dingEmail)).filter((item): item is string => Boolean(item)) const userLookupOr = [ ...(dingtalkUserIds.length > 0 ? [{ dingtalkUserId: { in: dingtalkUserIds } }] : []), ...(dingEmails.length > 0 ? [{ email: { in: dingEmails } }] : []), ] const users = userLookupOr.length > 0 ? await prisma.user.findMany({ where: { OR: userLookupOr }, select: { id: true, dingtalkUserId: true, email: true }, }) : [] const userByDing = new Map(users.map((user) => [user.dingtalkUserId, user]).filter(([dingtalkUserId]) => Boolean(dingtalkUserId)) as Array<[string, (typeof users)[number]]>) const userByEmail = new Map(users.map((user) => [user.email, user])) const deduped = new Map() for (const employee of employees) { const profileDingtalkUserId = clean(employee.dingtalkUserId) const matchedUser = (profileDingtalkUserId ? userByDing.get(profileDingtalkUserId) || null : null) || (employee.dingEmail ? userByEmail.get(employee.dingEmail) || null : null) const dingtalkUserId = profileDingtalkUserId || clean(matchedUser?.dingtalkUserId) const userId = matchedUser?.id || null const evaluatorKey = getEvaluatorKey({ employeeId: employee.id, userId, dingtalkUserId, }) deduped.set(evaluatorKey, { employee, userId, dingtalkUserId, evaluatorKey, name: getEmployeeDisplayName(employee), snapshot: getEmployeeSnapshot(employee), }) } return Array.from(deduped.values()) } async function resolveEmployeeEvaluator(employee: any) { const dingtalkUserId = clean(employee?.dingtalkUserId) const dingEmail = clean(employee?.dingEmail) const matchedUser = dingtalkUserId || dingEmail ? await prisma.user.findFirst({ where: { OR: [ ...(dingtalkUserId ? [{ dingtalkUserId }] : []), ...(dingEmail ? [{ email: dingEmail }] : []), ], }, select: { id: true, dingtalkUserId: true }, }) : null const userId = matchedUser?.id || null const resolvedDingtalkUserId = dingtalkUserId || clean(matchedUser?.dingtalkUserId) return { employee, userId, dingtalkUserId: resolvedDingtalkUserId, evaluatorKey: getEvaluatorKey({ employeeId: employee.id, userId, dingtalkUserId: resolvedDingtalkUserId }), name: getEmployeeDisplayName(employee), snapshot: getEmployeeSnapshot(employee), } } async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager: any) { const sourcePayload = manager?.sourcePayload && typeof manager.sourcePayload === 'object' ? manager.sourcePayload as Record : {} const linkedUserId = clean(sourcePayload.materialUserId) || clean(sourcePayload.userId) const managerDingtalkIdentity = clean(manager?.dingtalkUserId) const managerEmail = clean(manager?.dingEmail) const accountIdentityWhere = linkedUserId ? { id: linkedUserId } : managerDingtalkIdentity ? { dingtalkUserId: managerDingtalkIdentity } : managerEmail ? { email: { equals: managerEmail, mode: 'insensitive' as const } } : null if (!accountIdentityWhere) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少可用登录身份,请先校准人员档案`, 409) } const activeAccounts = await prisma.user.findMany({ where: { AND: [ { archivedAt: null, status: 'active', isActive: true, }, accountIdentityWhere, ], }, select: { id: true, dingtalkUserId: true, }, take: 2, }) if (activeAccounts.length !== 1) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管尚无可用账户,请先校准人员档案`, 409) } const accountDingtalkUserId = clean(activeAccounts[0].dingtalkUserId) if ( managerDingtalkIdentity && managerDingtalkIdentity !== accountDingtalkUserId ) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管钉钉身份不一致,请先校准人员档案`, 409) } const resolvedDingtalkUserId = managerDingtalkIdentity || accountDingtalkUserId if (!resolvedDingtalkUserId) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少钉钉唯一值,请先校准人员档案`, 409) } return { ...manager, dingtalkUserId: resolvedDingtalkUserId, } } async function resolveDirectManagerForWorkflow(targetEmployee: any) { const managerDingtalkUserId = clean(targetEmployee?.managerDingtalkUserId) const managerJobNumber = clean(targetEmployee?.managerJobNumber) // 上游已提供主管唯一值时只做精确匹配;值失效或冲突不能用组织推断掩盖。 if (managerDingtalkUserId || managerJobNumber) { const configuredManagerIdentity = { ...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}), ...(managerJobNumber ? { jobNumber: managerJobNumber } : {}), } const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ getActivePerformanceSurveyEmployeeProfileWhere(), configuredManagerIdentity, ], }, take: 2, }) if (managers.length !== 1) { throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409) } return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } // 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 M 级人员。 const targetEmployeeId = clean(targetEmployee?.id) if (!targetEmployeeId) { throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) } const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ where: { employeeProfileId: targetEmployeeId, isPrimary: true, }, select: { sourceDeptId: true }, take: 2, }) const primarySourceDeptIds = Array.from(new Set( primaryMemberships .map((membership) => clean(membership.sourceDeptId)) .filter((sourceDeptId): sourceDeptId is string => Boolean(sourceDeptId)), )) if (primarySourceDeptIds.length !== 1) { throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) } const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ getActivePerformanceSurveyEmployeeProfileWhere(), { id: { not: targetEmployeeId } }, { OR: [ { isLeadership: true }, { levelType: { equals: 'M', mode: 'insensitive' } }, { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, ], }, { departmentMemberships: { some: { sourceDeptId: primarySourceDeptIds[0], isPrimary: true, }, }, }, ], }, take: 2, }) if (managers.length === 0) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409) } if (managers.length > 1) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内存在多名M级人员,请明确配置直属主管`, 409) } return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } async function resolveConfiguredWorkflowEvaluators(input: { template: PerformanceSurveyTemplateShape targetEmployee: any manualEvaluators: any[] }) { const stages = [...(input.template.workflow?.stages || [])] .sort((left, right) => left.order - right.order) if (stages.length === 0) throw new PerformanceSurveyApiError('模板尚未配置评审流程', 409) const evaluatorRows: any[] = [] for (const stage of stages) { for (const reviewer of stage.reviewers) { let evaluators: any[] = [] if (reviewer.source === 'self') { evaluators = [await resolveEmployeeEvaluator(input.targetEmployee)] } else if (reviewer.source === 'direct_manager') { evaluators = [await resolveEmployeeEvaluator(await resolveDirectManagerForWorkflow(input.targetEmployee))] } else if (reviewer.source === 'fixed_employee') { const fixedEmployeeId = clean(reviewer.employeeId) if (!fixedEmployeeId) throw new PerformanceSurveyApiError(`模板固定评分人「${reviewer.label}」尚未配置`, 409) const fixedEmployee = await prisma.performanceSurveyEmployeeProfile.findFirst({ where: { AND: [await getPerformanceSurveyAccountProfileWhere(), { id: fixedEmployeeId }] }, }) if (!fixedEmployee) throw new PerformanceSurveyApiError(`模板固定评分人「${reviewer.label}」不存在或已不可用`, 409) evaluators = [await resolveEmployeeEvaluator(fixedEmployee)] } else if (reviewer.source === 'manual_selection') { evaluators = input.manualEvaluators } for (const evaluator of evaluators) { evaluatorRows.push({ ...evaluator, workflowStageKey: stage.key, workflowStageOrder: stage.order, workflowStageLabel: stage.label, reviewerSource: reviewer.source as PerformanceSurveyReviewerSource, reviewerLabel: reviewer.label, }) } } } const scoring = input.template.workflow?.scoring const deduped = new Map() for (const evaluator of evaluatorRows) { const key = `${evaluator.workflowStageKey}:${evaluator.evaluatorKey}` const previous = deduped.get(key) const isPreferredFinalReviewer = scoring?.mode === 'final_reviewer' && evaluator.workflowStageKey === scoring.stageKey && evaluator.reviewerSource === scoring.reviewerSource if (!previous || isPreferredFinalReviewer) deduped.set(key, evaluator) } return Array.from(deduped.values()) } function serializeEmployee(employee: any) { return { id: employee.id, dingtalkUserId: employee.dingtalkUserId, dingEmail: employee.dingEmail, jobNumber: employee.jobNumber, flowerName: employee.flowerName, realName: employee.realName, displayName: employee.displayName, departmentName: employee.departmentName, departmentPath: employee.departmentPath, positionTitle: employee.positionTitle, positionLevel: employee.positionLevel, levelType: employee.levelType, isLeadership: employee.isLeadership, isDingtalkAdmin: employee.isDingtalkAdmin, isDingtalkBoss: employee.isDingtalkBoss, isDingtalkSenior: employee.isDingtalkSenior, departmentMemberships: Array.isArray(employee.departmentMemberships) ? employee.departmentMemberships.map((membership: any) => ({ id: membership.id, sourceDeptId: membership.sourceDeptId, departmentName: membership.departmentName, departmentPath: membership.departmentPath, isPrimary: membership.isPrimary, syncedAt: membership.syncedAt, })) : [], managerDingtalkUserId: employee.managerDingtalkUserId, managerJobNumber: employee.managerJobNumber, managerName: employee.managerName, employmentStatus: employee.employmentStatus, workPlace: employee.workPlace, syncedAt: employee.syncedAt, } } function readSnapshotText(snapshot: Record | null | undefined, key: string) { if (!snapshot || typeof snapshot !== 'object') return '' return clean(snapshot[key]) || '' } function escapeCsv(value: string) { const text = String(value ?? '') return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text }