gongxiangp2/lib/performance-survey-service.ts

2467 lines
83 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 type { Session } from 'next-auth'
import { pinyin } from 'pinyin-pro'
import { prisma } from '@/lib/prisma'
import { getDingtalkWorkNoticeConfigStatus } from '@/lib/dingtalk-client'
import {
calculateAggregateScore,
calculateResponseScores,
getPerformanceSurveyTemplate,
listPerformanceSurveyTemplates,
type PerformanceSurveyTemplateShape,
} from '@/lib/performance-survey-templates'
import {
ensurePerformanceSurveyNotificationWorkerStarted,
queueDuePerformanceSurveyReminders,
queueSurveyAssignmentNotifications,
queueSurveyCreatorNotification,
queueSurveyResultNotifications,
recoverDuePerformanceSurveyNotifications,
retryPerformanceSurveyNotification,
} from '@/lib/performance-survey-notifications'
import {
ensurePerformanceSurveyDailyEmployeeSyncStarted,
ensurePerformanceSurveyEmployeeProfileForUser,
getPerformanceSurveyAccountProfileWhere,
syncPerformanceSurveyEmployees,
} from '@/lib/performance-survey-report-sync'
import {
PERFORMANCE_SURVEY_IMAGE_LIMITS,
PerformanceSurveyImageProcessingError,
processPerformanceSurveyImages,
} from '@/lib/performance-survey-image-processing'
import { uploadToS3 } from '@/lib/s3'
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
evaluatorKey: string
}
function clean(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized.length > 0 ? normalized : null
}
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 = '全部部门'
const MONTHLY_ASSESSMENT_TEMPLATE_CODES = [
'course_share',
'cross_department',
'work_cooperation',
'visual_brand_tone',
] as const
type MonthlyAssessmentTemplateCode = typeof MONTHLY_ASSESSMENT_TEMPLATE_CODES[number]
const MONTHLY_ASSESSMENT_TEMPLATE_SET = new Set<string>(MONTHLY_ASSESSMENT_TEMPLATE_CODES)
function isMonthlyAssessmentTemplateCode(code?: string | null): code is MonthlyAssessmentTemplateCode {
return Boolean(code && MONTHLY_ASSESSMENT_TEMPLATE_SET.has(code))
}
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 getScopeDepartmentWhere(scopeDepartment: string) {
return {
OR: [
{ departmentName: scopeDepartment },
{ departmentPath: scopeDepartment },
{ departmentPath: { startsWith: `${scopeDepartment}/` } },
{ departmentPath: { contains: `/${scopeDepartment}/` } },
{ departmentPath: { endsWith: `/${scopeDepartment}` } },
],
}
}
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 }]),
],
})
} else {
departmentNames.push(parsed.departmentName)
}
}
const clauses: any[] = []
const uniqueDepartmentNames = Array.from(new Set(departmentNames))
if (uniqueDepartmentNames.length > 0) {
clauses.push({ 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 = clean(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 }
}
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')}` }
}
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')}`
}
function getRecentMonthValues(count = 12) {
const now = new Date()
return Array.from({ length: count }, (_, 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.map((month) => getMonthRange(month)).filter((range): range is { start: Date; end: Date } => Boolean(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 }) {
return {
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 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
}
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,
managerDingtalkUserId: employee.managerDingtalkUserId,
managerJobNumber: employee.managerJobNumber,
managerName: employee.managerName,
employmentStatus: employee.employmentStatus,
workPlace: employee.workPlace,
snapshotAt: new Date().toISOString(),
}
}
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) {
throw new PerformanceSurveyApiError('无权限管理考核调研', 403)
}
}
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'))
}
function isSurveyInManagerScope(ctx: UserContext, survey: { creatorUserId: string; departmentName?: string | null }) {
if (ctx.isSystemAdmin) return true
if (survey.creatorUserId === ctx.user.id) return true
if (!ctx.scopeDepartment) return false
return survey.departmentName === ctx.scopeDepartment
}
async function canManageSurvey(ctx: UserContext, survey: { id: string; creatorUserId: string; departmentName?: string | null }) {
if (survey.creatorUserId === ctx.user.id) return true
if (!ctx.isDepartmentAdmin) return false
if (isSurveyInManagerScope(ctx, survey)) return true
if (!ctx.scopeDepartment) return false
const scopeWhere = await getSurveyWhereByEmployeeProfileScope(getScopeDepartmentWhere(ctx.scopeDepartment))
const count = await prisma.performanceSurvey.count({
where: {
AND: [
{ id: survey.id },
scopeWhere,
],
},
})
return count > 0
}
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 }
}
async function getSurveyWhereByEmployeeProfileScope(profileWhere: any) {
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: profileWhere,
select: { id: true },
take: 10_000,
})
const employeeIds = employees.map((employee) => employee.id)
const clauses: any[] = [
{ targets: { some: { employeeProfile: { is: profileWhere } } } },
{ assignments: { some: { evaluatorEmployee: { is: profileWhere } } } },
]
if (employeeIds.length > 0) {
clauses.push({ creatorEmployeeId: { in: employeeIds } })
}
return mergeOrClauses(clauses)
}
async function getManagerSurveyBaseWhere(ctx: UserContext) {
if (ctx.isSystemAdmin) return {}
if (!ctx.scopeDepartment) return { creatorUserId: ctx.user.id }
return mergeOrClauses([
{ creatorUserId: ctx.user.id },
{ departmentName: ctx.scopeDepartment },
await getSurveyWhereByEmployeeProfileScope(getScopeDepartmentWhere(ctx.scopeDepartment)),
])
}
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(await getSurveyWhereByEmployeeProfileScope(mergeOrClauses(departmentClauses)))
}
}
if (monthRange) {
and.push({
OR: [
{ dueAt: { gte: monthRange.start, lt: monthRange.end } },
{
AND: [
{ dueAt: null },
{ createdAt: { gte: monthRange.start, lt: monthRange.end } },
],
},
],
})
}
return and.length === 1 ? and[0] : { AND: and }
}
export async function getPerformanceSurveyContext(session: Session | null): Promise<UserContext> {
ensurePerformanceSurveyNotificationWorkerStarted()
ensurePerformanceSurveyDailyEmployeeSyncStarted()
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 isSystemAdmin = Boolean(user.isSuperAdmin || user.role === 'ADMIN' || user.role === 'SYSTEM_ADMIN')
const isLeadership = isLeadershipProfile(employee)
const isDepartmentAdmin = Boolean(isSystemAdmin || user.role === 'DEPT_ADMIN' || isLeadership)
const scopeDepartment = clean(user.managedDepartment) || clean(employee?.departmentName) || clean(user.department)
return {
user,
employee,
isSystemAdmin,
isDepartmentAdmin,
isLeadership,
scopeDepartment,
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,
canCreate: true,
},
}
}
export async function getPerformanceSurveyTemplates(session: Session | null) {
await getPerformanceSurveyContext(session)
return listPerformanceSurveyTemplates()
}
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 buildWhere = async (includeKeyword: boolean) => {
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [accountWhere]
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 && ctx.scopeDepartment && !isEvaluatorSearch) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
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' } },
] })
}
return { AND: and } as any
}
const queryEmployees = async () => prisma.performanceSurveyEmployeeProfile.findMany({
where: await buildWhere(true),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: limit,
})
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)),
})
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,
])
})
employees = [...employees, ...pinyinMatches].slice(0, limit)
}
return employees.map(serializeEmployee)
}
export async function listPerformanceSurveyDepartments(session: Session | null, params?: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
const isEvaluatorPicker = clean(params?.get('purpose')) === 'evaluator'
const cacheKey = [
isEvaluatorPicker ? 'evaluator' : 'scoped',
ctx.isSystemAdmin ? 'system' : 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 accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [
accountWhere,
{ departmentName: { not: null } },
]
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorPicker) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: { AND: and },
select: {
departmentName: true,
departmentPath: true,
},
})
const nodeByPath = new Map<string, any>()
for (const employee of employees) {
const departmentName = clean(employee.departmentName)
const pathParts = parseDepartmentPathParts(employee.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('/')
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',
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',
},
})
const submittedAssignments = await prisma.performanceSurveyAssignment.findMany({
where: {
evaluatorKey: ctx.evaluatorKey,
status: 'submitted',
},
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: {
_count: {
select: { targets: true, assignments: true, responses: true },
},
},
orderBy: { createdAt: 'desc' },
take: 100,
})
const managedSurveyCount = ctx.isDepartmentAdmin
? await prisma.performanceSurvey.count({ where: managerWhere as any })
: createdSurveys.length
const managedOpenSurveyCount = ctx.isDepartmentAdmin
? await prisma.performanceSurvey.count({ where: { AND: [managerWhere, { status: 'open' }] } as any })
: createdSurveys.filter((survey) => survey.status === 'open').length
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: managerWhere as any,
},
include: {
survey: {
select: { templateCode: 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 resultTargets = ctx.employee
? await prisma.performanceSurveyTarget.findMany({
where: {
employeeProfileId: ctx.employee.id,
survey: { status: 'closed' },
},
include: { survey: true },
orderBy: { createdAt: 'desc' },
take: 100,
})
: []
const canSeeScopeSummary = Boolean(ctx.isSystemAdmin || ctx.isLeadership)
const scopeAssignmentEmployeeWhere = ctx.isSystemAdmin
? {}
: ctx.scopeDepartment
? { evaluatorEmployee: { is: getScopeDepartmentWhere(ctx.scopeDepartment) } }
: { evaluatorKey: ctx.evaluatorKey }
const scopeTargetEmployeeWhere = ctx.isSystemAdmin
? {}
: ctx.scopeDepartment
? { employeeProfile: { is: getScopeDepartmentWhere(ctx.scopeDepartment) } }
: ctx.employee
? { employeeProfileId: ctx.employee.id }
: { id: '__none__' }
const scopePendingCount = canSeeScopeSummary
? await prisma.performanceSurveyAssignment.count({
where: {
...scopeAssignmentEmployeeWhere,
status: 'pending',
survey: {
status: 'open',
OR: [
{ dueAt: null },
{ dueAt: { gt: now } },
],
},
} as any,
})
: pendingAssignments.length
const scopeSubmittedCount = canSeeScopeSummary
? await prisma.performanceSurveyAssignment.count({
where: {
...scopeAssignmentEmployeeWhere,
status: 'submitted',
} as any,
})
: submittedAssignmentCount
const scopeResultCount = canSeeScopeSummary
? await prisma.performanceSurveyTarget.count({
where: {
...scopeTargetEmployeeWhere,
survey: { status: 'closed' },
} as any,
})
: resultTargets.length
const personalSummary = {
pendingCount: pendingAssignments.length,
createdCount: createdSurveys.length,
openCreatedCount: createdSurveys.filter((survey) => survey.status === 'open').length,
submittedCount: submittedAssignmentCount,
resultCount: resultTargets.length,
}
const scopeSummary = canSeeScopeSummary
? {
pendingCount: scopePendingCount,
createdCount: managedSurveyCount,
openCreatedCount: managedOpenSurveyCount,
submittedCount: scopeSubmittedCount,
resultCount: scopeResultCount,
}
: null
return {
summary: {
...personalSummary,
personal: personalSummary,
scope: scopeSummary,
scopeMode: ctx.isSystemAdmin ? 'system' : ctx.isLeadership ? 'organization' : 'self',
},
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,
dueAt: survey.dueAt,
openedAt: survey.openedAt,
closedAt: survey.closedAt,
createdAt: survey.createdAt,
counts: survey._count,
})),
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,
dueAt: survey.dueAt,
openedAt: survey.openedAt,
closedAt: survey.closedAt,
createdAt: survey.createdAt,
creatorName: survey.creatorName,
counts: survey._count,
})),
results: resultTargets.map((target) => ({
surveyId: target.surveyId,
targetId: target.id,
title: target.survey.title,
targetName: target.targetName,
closedAt: target.survey.closedAt,
})),
templateStats: templates.map((template) => {
const responses = responsesByTemplate.get(template.code) || []
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,
canCreate: true,
},
}
}
function getMonthlyTemplates(templates: PerformanceSurveyTemplateShape[]) {
const byCode = new Map(templates.map((template) => [template.code, template]))
return MONTHLY_ASSESSMENT_TEMPLATE_CODES
.map((code) => byCode.get(code))
.filter((template): template is PerformanceSurveyTemplateShape => Boolean(template))
}
function getDashboardTargetMonth(target: any) {
return 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 summarizeMonthlyScoreCell(
template: PerformanceSurveyTemplateShape,
targets: any[],
detailAllowed: boolean,
) {
if (targets.length === 0) {
return {
templateCode: template.code,
score: null,
surveyCount: 0,
responseCount: 0,
assignmentCount: 0,
pendingCount: 0,
detailAllowed: false,
}
}
const responses = getTargetResponses(targets)
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, template.scoreBands, template.weight, template)
return {
templateCode: template.code,
score: responses.length > 0 ? aggregate.kpiScore : null,
surveyCount: targets.length,
responseCount: responses.length,
assignmentCount: assignments.length,
pendingCount: assignments.filter((assignment: any) => assignment.status !== 'submitted').length,
workItemCount: getTargetWorkItems(targets).length,
detailAllowed: detailAllowed && responses.length > 0,
}
}
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),
}
}
async function getPublicDashboardEmployees(ctx: UserContext, params?: URLSearchParams) {
const departmentValue = clean(params?.get('department'))
const employeeId = clean(params?.get('employeeId'))
const and: any[] = [await getPerformanceSurveyAccountProfileWhere()]
if (ctx.isLeadership && ctx.scopeDepartment) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
if (departmentValue) {
const departmentClauses = getDepartmentSelectionWhere([departmentValue])
if (departmentClauses.length > 0) and.push(mergeOrClauses(departmentClauses))
}
if (employeeId) {
and.push({ id: employeeId })
}
return prisma.performanceSurveyEmployeeProfile.findMany({
where: and.length === 1 ? and[0] : { AND: and },
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: 2000,
})
}
async function getMonthlyTargets(employeeIds: string[], monthRange: { start: Date; end: Date }) {
if (employeeIds.length === 0) return []
return prisma.performanceSurveyTarget.findMany({
where: {
targetType: 'employee',
employeeProfileId: { in: employeeIds },
survey: {
AND: [
{ templateCode: { in: [...MONTHLY_ASSESSMENT_TEMPLATE_CODES] } },
getSurveyMonthWhere(monthRange),
],
},
} as any,
include: {
employeeProfile: true,
survey: true,
assignments: true,
responses: true,
workItems: {
orderBy: { sortOrder: 'asc' },
include: { scores: true },
},
},
orderBy: [{ survey: { dueAt: 'desc' } }, { createdAt: 'desc' }],
take: 5000,
})
}
async function assertNoMonthlyAssessmentDuplicate(
templateCode: string,
assessmentDate: Date,
targetRows: Array<{ employeeProfileId?: string | null }>,
) {
if (!isMonthlyAssessmentTemplateCode(templateCode)) return
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 = getMonthRangeFromDate(assessmentDate)
const existingTargets = await prisma.performanceSurveyTarget.findMany({
where: {
targetType: 'employee',
employeeProfileId: { in: employeeIds },
survey: {
AND: [
{ templateCode },
getSurveyMonthWhere(monthRange),
],
},
} as any,
include: {
employeeProfile: true,
survey: true,
},
take: 50,
})
if (existingTargets.length === 0) return
const duplicateNames = Array.from(new Set(existingTargets.map((target) => (
target.employeeProfile ? getEmployeeDisplayName(target.employeeProfile) : target.targetName
))))
throw new PerformanceSurveyApiError(
`${monthRange.value} 已存在该模板考核:${duplicateNames.slice(0, 8).join('、')}${duplicateNames.length > 8 ? '等' : ''}`,
)
}
export async function getPerformanceSurveyDashboard(session: Session | null, params?: URLSearchParams) {
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 allTemplates = await listPerformanceSurveyTemplates()
const templates = getMonthlyTemplates(allTemplates)
const personalMonths = includePersonal ? getRecentMonthValues(12) : []
const personalEmployee = ctx.employee
const personalTargets = includePersonal && personalEmployee
? await getMonthlyTargets([personalEmployee.id], getMonthSpanRange(personalMonths))
: []
const publicEmployees = includePublic ? await getPublicDashboardEmployees(ctx, params) : []
const publicTargets = includePublic
? await getMonthlyTargets(publicEmployees.map((employee) => employee.id), monthRange)
: []
const publicDetailAllowed = Boolean(ctx.isSystemAdmin || ctx.isLeadership)
const publicRows = publicEmployees
.map((employee) => buildMonthlyRow(
employee,
monthValue,
templates,
publicTargets.filter((target) => target.employeeProfileId === employee.id),
publicDetailAllowed,
))
.filter((row) => row.hasData)
return {
month: monthValue,
filters: {
view: dashboardView,
month: monthValue,
department: clean(params?.get('department')),
employeeId: clean(params?.get('employeeId')),
},
permissions: {
isSystemAdmin: ctx.isSystemAdmin,
isLeadership: ctx.isLeadership,
isDepartmentAdmin: ctx.isDepartmentAdmin,
scopeMode: ctx.isSystemAdmin ? 'system' : ctx.isLeadership ? 'organization' : 'public-readonly',
scopeDepartment: ctx.scopeDepartment,
canOpenPersonalDetail: Boolean(ctx.employee),
canOpenPublicDetail: publicDetailAllowed,
},
templates: templates.map((template) => ({
code: template.code,
name: template.shortName || template.name,
fullName: template.name,
maxTotal: 120,
})),
personal: {
employee: personalEmployee ? serializeEmployee(personalEmployee) : null,
rows: includePersonal && personalEmployee
? personalMonths.map((month) => buildMonthlyRow(personalEmployee, month, templates, personalTargets, true))
: [],
},
public: {
month: monthValue,
rows: publicRows,
},
}
}
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'))
if (!employeeId) throw new PerformanceSurveyApiError('缺少人员参数')
if (!isMonthlyAssessmentTemplateCode(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) {
throw new PerformanceSurveyApiError('无权限查看该人员考核明细', 403)
}
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
const count = await prisma.performanceSurveyEmployeeProfile.count({
where: {
AND: [
{ id: employee.id },
getScopeDepartmentWhere(ctx.scopeDepartment),
],
},
})
if (count === 0) throw new PerformanceSurveyApiError('无权限查看该人员考核明细', 403)
}
}
const template = await getPerformanceSurveyTemplate(templateCode)
const targets = await prisma.performanceSurveyTarget.findMany({
where: {
targetType: 'employee',
employeeProfileId: employee.id,
survey: {
AND: [
{ templateCode },
getSurveyMonthWhere(monthRange),
],
},
} as any,
include: {
survey: true,
assignments: true,
responses: {
include: {
respondentEmployee: true,
},
orderBy: { createdAt: 'asc' },
},
workItems: {
orderBy: { sortOrder: 'asc' },
include: {
scores: {
include: {
evaluatorEmployee: true,
},
orderBy: { createdAt: 'asc' },
},
},
},
},
orderBy: [{ survey: { dueAt: 'desc' } }, { createdAt: 'desc' }],
take: 200,
})
const cell = summarizeMonthlyScoreCell(template, targets, true)
const responses = getTargetResponses(targets)
const participants = responses.map((response: any) => ({
id: response.id,
name: response.respondentEmployee
? getEmployeeDisplayName(response.respondentEmployee)
: response.respondentName || '匿名评价',
score: roundScore(Number(response.kpiScore ?? response.totalScore ?? 0)),
rawScore: roundScore(Number(response.totalScore ?? 0)),
comment: response.comment || '',
submittedAt: response.createdAt,
}))
const workItems = getTargetWorkItems(targets).map((item: any) => {
const scores = (item.scores || []).map((score: any) => ({
id: score.id,
name: score.evaluatorEmployee ? getEmployeeDisplayName(score.evaluatorEmployee) : score.evaluatorName || '匿名评价',
score: roundScore(Number(score.totalScore || 0)),
comment: score.comment || '',
submittedAt: score.createdAt,
}))
return {
id: item.id,
title: item.title,
description: item.description,
imageUrls: Array.isArray(item.imageUrls) ? item.imageUrls : [],
averageScore: average(scores.map((score: any) => Number(score.score)).filter(Number.isFinite)),
scores,
}
})
return {
month: monthValue,
employee: serializeEmployee(employee),
template: {
code: template.code,
name: template.shortName || template.name,
fullName: template.name,
maxTotal: 120,
isVisual: isVisualCaseTemplate(template),
},
score: cell.score,
surveyCount: cell.surveyCount,
responseCount: cell.responseCount,
participants,
workItems,
}
}
export async function createPerformanceSurvey(session: Session | null, body: any) {
const ctx = await getPerformanceSurveyContext(session)
const title = clean(body?.title)
if (!title) throw new PerformanceSurveyApiError('调研标题不能为空')
const templateCode = clean(body?.templateCode) || 'cross_department'
const template = await getPerformanceSurveyTemplate(templateCode)
const requestedSurveyKind = clean(body?.surveyKind) || template.surveyType
const surveyKind = template.code === 'course_share' ? 'person_period' : requestedSurveyKind
const dueAt = toDate(body?.dueAt)
const openNow = Boolean(body?.openNow || body?.status === 'open')
const requestedDepartmentName = clean(body?.departmentName)
const departmentName = requestedDepartmentName || ctx.scopeDepartment
if (!ctx.isSystemAdmin && requestedDepartmentName && requestedDepartmentName !== ctx.scopeDepartment) {
throw new PerformanceSurveyApiError('只能创建本人部门范围内的考核调研', 403)
}
const targets = await resolveSurveyTargets(body, surveyKind)
if (targets.length === 0) {
throw new PerformanceSurveyApiError(
surveyKind === 'item_course'
? `请填写${template.itemName || '课程、事项或活动'}名称`
: `请至少选择 1 个${template.ownerRoleName || '被考核人'}`,
)
}
const evaluators = await resolveSurveyEvaluators(body, ctx)
if (evaluators.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,
}))
await assertNoMonthlyAssessmentDuplicate(template.code, dueAt || new Date(), targetRows)
const assignmentRows: any[] = []
for (const target of targetRows) {
for (const evaluator of evaluators) {
if (
surveyKind !== 'item_course' &&
target.employeeProfileId &&
evaluator.employee?.id === target.employeeProfileId
) {
continue
}
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,
status: 'pending',
})
}
}
if (assignmentRows.length === 0) {
throw new PerformanceSurveyApiError('评价人不能全部等于被考核人,请调整评价范围')
}
await prisma.$transaction(async (tx) => {
await tx.performanceSurvey.create({
data: {
id: surveyId,
title,
description: clean(body?.description),
surveyKind,
templateCode: template.code,
templateSnapshot: template as any,
status: openNow ? 'open' : 'draft',
creatorUserId: ctx.user.id,
creatorName: getDisplayName(ctx.user),
creatorEmployeeId: ctx.employee?.id || null,
departmentName,
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)
}
return getPerformanceSurveyDetail(session, surveyId)
}
export async function getPerformanceSurveyDetail(session: Session | null, surveyId: string) {
const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
targets: {
orderBy: { sortOrder: 'asc' },
include: {
assignments: {
select: {
id: true,
evaluatorName: true,
evaluatorEmployeeId: true,
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 isAssigned = survey.assignments.length > 0
const canManage = await canManageSurvey(ctx, survey)
const isTarget = Boolean(
ctx.employee && survey.targets.some((target) => target.employeeProfileId === ctx.employee?.id),
)
if (!canManage && !isAssigned && !isTarget && survey.creatorUserId !== ctx.user.id) {
throw new PerformanceSurveyApiError('调研不存在或无权限', 404)
}
const currentAssignments = new Map(survey.assignments.map((assignment) => [assignment.targetId, assignment]))
return {
id: survey.id,
title: survey.title,
description: survey.description,
surveyKind: survey.surveyKind,
templateCode: survey.templateCode,
template: survey.templateSnapshot,
status: survey.status,
creatorName: survey.creatorName,
departmentName: survey.departmentName,
dueAt: survey.dueAt,
openedAt: survey.openedAt,
closedAt: survey.closedAt,
counts: survey._count,
canManage,
canSubmit: isAssigned && survey.status === 'open',
canViewResults: canManage || survey.creatorUserId === ctx.user.id || (survey.status === 'closed' && isTarget),
targets: survey.targets.map((target) => {
const currentAssignment = currentAssignments.get(target.id)
const response = currentAssignment?.response
return {
id: target.id,
targetType: target.targetType,
targetName: target.targetName,
targetSnapshot: target.targetSnapshot,
responseCount: target.responses.length,
assignmentCount: target.assignments.length,
canUploadWorkItems: Boolean(isVisualCaseTemplate(survey.templateCode) && ctx.employee?.id && target.employeeProfileId === ctx.employee.id),
workItems: target.workItems.map((item) => ({
id: item.id,
title: item.title,
description: item.description,
imageUrls: Array.isArray(item.imageUrls) ? item.imageUrls : [],
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,
response: response
? {
id: response.id,
scores: response.scores,
comment: response.comment,
totalScore: response.totalScore,
achievementScore: response.achievementScore,
kpiScore: response.kpiScore,
weightedScore: response.weightedScore,
createdAt: response.createdAt,
}
: null,
}
: null,
assignments: canManage
? target.assignments.map((assignment) => ({
id: assignment.id,
evaluatorName: assignment.evaluatorName,
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 } })
if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404)
await ensureSurveyManageScope(ctx, survey)
const nextStatus = clean(body?.status)
const data: any = {}
if (body?.title != null) data.title = clean(body.title) || survey.title
if (body?.description !== undefined) data.description = clean(body.description)
if (body?.dueAt !== undefined) data.dueAt = toDate(body.dueAt)
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('无效的调研状态')
}
await prisma.performanceSurvey.update({
where: { id: surveyId },
data,
})
if (data.status === 'open') {
await queueSurveyAssignmentNotifications(surveyId)
}
if (data.status === 'closed') {
await queueSurveyResultNotifications(surveyId)
await queueSurveyCreatorNotification(surveyId, 'survey.creator.closed')
}
return getPerformanceSurveyDetail(session, surveyId)
}
export async function submitPerformanceSurveyResponse(session: Session | null, surveyId: string, body: any) {
const ctx = await getPerformanceSurveyContext(session)
const targetId = clean(body?.targetId)
if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象')
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
})
if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404)
if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭')
if (survey.dueAt && survey.dueAt.getTime() < Date.now()) {
throw new PerformanceSurveyApiError('调研已超过截止时间')
}
const assignment = await prisma.performanceSurveyAssignment.findFirst({
where: {
surveyId,
targetId,
evaluatorKey: ctx.evaluatorKey,
},
include: {
target: true,
response: true,
},
})
if (!assignment) throw new PerformanceSurveyApiError('你没有该评价对象的填写权限', 403)
if (assignment.response || assignment.status === 'submitted') {
throw new PerformanceSurveyApiError('你已经提交过该评价,不能重复提交')
}
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
const submittedScores = calculateSubmittedPerformanceSurveyScores(template, body?.scores)
const scoreResult = submittedScores.result
const response = await prisma.$transaction(async (tx) => {
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' },
})
if (pendingCount === 0) {
await queueSurveyCreatorNotification(surveyId, 'survey.creator.completed')
}
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' },
},
},
},
_count: {
select: { assignments: true, responses: true, targets: true },
},
},
})
if (!survey) throw new PerformanceSurveyApiError('调研不存在或无权限', 404)
const canManage = await canManageSurvey(ctx, survey)
const isCreator = survey.creatorUserId === ctx.user.id
const isTarget = Boolean(
ctx.employee && survey.targets.some((target) => target.employeeProfileId === ctx.employee?.id),
)
const canView = canManage || isCreator || (survey.status === 'closed' && isTarget)
if (!canView) throw new PerformanceSurveyApiError('无权限查看结果', 403)
if (survey.status !== 'closed' && !canManage) {
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,
dueAt: survey.dueAt,
closedAt: survey.closedAt,
counts: survey._count,
},
targets: survey.targets.map((target) => {
const aggregate = calculateAggregateScore(
target.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,
)
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,
respondentName: showRealRespondent ? response.respondentName : `匿名评价 ${index + 1}`,
scores: response.scores,
comment: response.comment,
totalScore: response.totalScore,
achievementScore: response.achievementScore,
kpiScore: response.kpiScore,
weightedScore: response.weightedScore,
createdAt: response.createdAt,
})),
}
}),
permissions: {
showRealRespondent,
canManage,
},
}
}
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)
}
export async function createPerformanceSurveyWorkItem(session: Session | null, surveyId: string, formData: FormData) {
const ctx = await getPerformanceSurveyContext(session)
const targetId = readFormText(formData, '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('当前模板不支持案例上传')
if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭')
const target = survey.targets.find((item) => item.id === targetId)
if (!target) throw new PerformanceSurveyApiError('评价对象不存在', 404)
const canManage = await canManageSurvey(ctx, survey)
const canUpload = canManage || Boolean(ctx.employee?.id && target.employeeProfileId === ctx.employee.id)
if (!canUpload) throw new PerformanceSurveyApiError('无权限提交该案例', 403)
const files = readImageFiles(formData)
if (files.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片')
if (files.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`)
}
const title = readFormText(formData, 'title') || `案例 ${survey.workItems.length + 1}`
const description = readFormText(formData, 'description')
const imageInputs = []
for (const file of files) {
if (!file.type.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件')
if (file.size > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) {
throw new PerformanceSurveyApiError('单张图片不能超过 20MB')
}
imageInputs.push({
buffer: Buffer.from(await file.arrayBuffer()),
mimeType: file.type,
sourceName: file.name || 'case-image',
})
}
let processedImages
try {
processedImages = await processPerformanceSurveyImages(imageInputs)
} catch (error) {
if (error instanceof PerformanceSurveyImageProcessingError) {
throw new PerformanceSurveyApiError(error.message)
}
throw error
}
const imageUrls: string[] = []
const uploadBatchId = `${Date.now()}-${randomUUID()}`
for (const image of processedImages) {
const fileName = [
'performance-survey/work-items',
surveyId,
targetId,
`${uploadBatchId}-${image.sourceIndex + 1}-${image.sliceIndex + 1}.webp`,
].join('/')
imageUrls.push(await uploadToS3(image.buffer, fileName, 'image/webp'))
}
const created = await prisma.performanceSurveyWorkItem.create({
data: {
id: randomUUID(),
surveyId,
targetId,
submitterUserId: ctx.user.id,
submitterEmployeeId: ctx.employee?.id || null,
title,
description,
imageUrls,
sortOrder: survey.workItems.length,
status: 'submitted',
submittedAt: new Date(),
},
})
return {
id: created.id,
title: created.title,
description: created.description,
imageUrls: created.imageUrls,
submittedAt: created.submittedAt,
}
}
export async function submitPerformanceSurveyWorkItemScore(
session: Session | null,
surveyId: string,
workItemId: string,
body: any,
) {
const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
})
if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404)
if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭')
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
if (!isVisualCaseTemplate(template)) throw new PerformanceSurveyApiError('当前模板不支持案例评分')
const workItem = await prisma.performanceSurveyWorkItem.findUnique({
where: { id: workItemId },
include: { target: true },
})
if (!workItem || workItem.surveyId !== surveyId) throw new PerformanceSurveyApiError('案例不存在', 404)
const assignment = await prisma.performanceSurveyAssignment.findFirst({
where: {
surveyId,
targetId: workItem.targetId,
evaluatorKey: ctx.evaluatorKey,
},
include: {
response: true,
},
})
if (!assignment) throw new PerformanceSurveyApiError('你没有该案例的评分权限', 403)
if (assignment.response || assignment.status === 'submitted') {
throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交')
}
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) => {
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' },
})
if (pendingCount === 0) {
await queueSurveyCreatorNotification(surveyId, 'survey.creator.completed')
}
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 exportPerformanceSurveyCsv(session: Session | null) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const where = ctx.isSystemAdmin
? {}
: {
OR: [
{ creatorUserId: ctx.user.id },
...(ctx.scopeDepartment ? [{ departmentName: ctx.scopeDepartment }] : []),
],
}
const responses = await prisma.performanceSurveyResponse.findMany({
where: {
survey: where as any,
},
include: {
survey: true,
target: true,
assignment: true,
},
orderBy: { createdAt: 'desc' },
take: 5000,
})
const rows = [
['提交时间', '调研ID', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '总分', '满分', 'KPI分', '建议'],
...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,
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 || ''),
String(response.kpiScore),
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 && ctx.scopeDepartment) {
where.survey = { departmentName: ctx.scopeDepartment }
}
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,
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 && ctx.scopeDepartment) {
where.survey = { departmentName: ctx.scopeDepartment }
}
const grouped = await prisma.performanceSurveyNotification.groupBy({
by: ['status'],
where,
_count: { _all: true },
})
return {
workNotice: getDingtalkWorkNoticeConfigStatus(),
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()
return { reminderCount: reminderIds.length, recovered }
}
async function resolveSurveyTargets(body: any, surveyKind: string) {
const targetEmployeeIds = normalizeList(body?.targetEmployeeIds)
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 employeeTargets =
targetEmployeeIds.length > 0
? await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
AND: [
await getPerformanceSurveyAccountProfileWhere(),
{ id: { in: targetEmployeeIds } },
],
},
})
: []
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 && ctx.employee) {
or.push({ id: ctx.employee.id })
}
const and: any[] = [
await getPerformanceSurveyAccountProfileWhere(),
{ OR: or },
]
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())
}
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,
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
}