gongxiangp2/lib/performance-survey-service.ts
2026-09-04 11:50:50 +08:00

5480 lines
194 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'crypto'
import { EmployeeManagerResolutionError, resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager'
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<string, { expiresAt: number; value: any[] }>()
const performanceSurveyDepartmentCachePromises = new Map<string, Promise<any[]>>()
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<string>()
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<string, unknown>
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<PerformanceSurveyTemplateShape, 'surveyType' | 'code'> | 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<UserContext, 'isSystemAdmin' | 'employee'>) {
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<string, unknown>).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<unknown> }> = []
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<T extends {
id: string
targetType: string
employeeProfileId?: string | null
}>(
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<UserContext> {
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<string, any>()
for (const employee of employees) {
const departmentCandidates = [
{
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
},
...employee.departmentMemberships,
]
const countedPaths = new Set<string>()
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<string, typeof scopedResponses>()
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<string, unknown>,
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<string, unknown>,
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<string | null | undefined>,
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<string, any>()
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<string, any[]>()
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<string, (typeof notifications)[number]>()
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,
targetId?: string | null,
) {
const ctx = await getPerformanceSurveyContext(session)
const targetIdFilter = clean(targetId)
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
targets: {
where: targetIdFilter ? { id: targetIdFilter } : undefined,
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 visibleTargetIds = new Set(visibleTargets.map((target) => target.id))
const visibleAssignments = survey.assignments.filter((assignment) => visibleTargetIds.has(assignment.targetId))
const isAssigned = visibleAssignments.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<string, (typeof survey.assignments)[number]>()
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, visibleAssignments),
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,
requiredTargetId?: string | null,
) {
const ctx = await getPerformanceSurveyContext(session)
const targetId = clean(body?.targetId)
if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象')
if (requiredTargetId && targetId !== requiredTargetId) {
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403)
}
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<string, unknown>,
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<Buffer>
}
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<ResolvedPerformanceSurveyProcessedImage> {
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<typeof validatePerformanceSurveyProcessedImages>
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<string, ResolvedPerformanceSurveyProcessedImage>()
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<string, unknown>
comment: string | null
scoreResult: ReturnType<typeof calculateResponseScores>
}
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<string>()
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,
requiredTargetId?: string | null,
) {
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]
if (requiredTargetId && targetId !== requiredTargetId) {
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403)
}
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<string, unknown>
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,
requiredTargetId?: string | null,
) {
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)
if (requiredTargetId && workItem.targetId !== requiredTargetId) {
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403)
}
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<string, unknown>
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<T extends { respondentName?: string | null } | null>(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<string, unknown>
const scores = response.scores as Record<string, unknown>
const dimensionScores = (scores?.dimensions || {}) as Record<string, unknown>
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<string, any>()
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 resolveActiveBoundAccountForEmployee(employeeId: string | null) {
if (!employeeId) return null
const bindings = await prisma.userEmployeeBinding.findMany({
where: {
employeeProfileId: employeeId,
status: 'ACTIVE',
user: {
archivedAt: null,
status: 'active',
isActive: true,
},
},
select: {
user: {
select: {
id: true,
dingtalkUserId: true,
email: true,
},
},
},
take: 2,
})
if (bindings.length > 1) {
throw new PerformanceSurveyApiError('人员存在多个有效账号绑定,请先校准人员档案', 409)
}
return bindings[0]?.user || null
}
async function resolveEmployeeEvaluator(employee: any) {
const dingtalkUserId = clean(employee?.dingtalkUserId)
const dingEmail = clean(employee?.dingEmail)
const boundUser = await resolveActiveBoundAccountForEmployee(clean(employee?.id))
const matchedUser = boundUser || (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<string, unknown>
: {}
const linkedUserId = clean(sourcePayload.materialUserId) || clean(sourcePayload.userId)
const managerDingtalkIdentity = clean(manager?.dingtalkUserId)
const managerEmail = clean(manager?.dingEmail)
const boundAccount = await resolveActiveBoundAccountForEmployee(clean(manager?.id))
const accountIdentityWhere = boundAccount
? null
: linkedUserId
? { id: linkedUserId }
: managerDingtalkIdentity
? { dingtalkUserId: managerDingtalkIdentity }
: managerEmail
? { email: { equals: managerEmail, mode: 'insensitive' as const } }
: null
if (!boundAccount && !accountIdentityWhere) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少可用登录身份,请先校准人员档案`, 409)
}
const activeAccounts = boundAccount
? [boundAccount]
: 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) {
try {
const manager = await resolveEmployeeDirectManagerProfile(
{ ...targetEmployee, displayName: getEmployeeDisplayName(targetEmployee) },
{ activeWhere: getActivePerformanceSurveyEmployeeProfileWhere() },
)
return resolveAvailableDirectManagerProfile(targetEmployee, manager)
} catch (error) {
if (error instanceof EmployeeManagerResolutionError) {
throw new PerformanceSurveyApiError(error.message, 409)
}
throw error
}
}
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<string, any>()
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<string, unknown> | 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
}