2598 lines
88 KiB
TypeScript
2598 lines
88 KiB
TypeScript
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,
|
||
getPerformanceSurveyQuestionScoreDetails,
|
||
getPerformanceSurveyTemplate,
|
||
listPerformanceSurveyTemplates,
|
||
type PerformanceSurveyTemplateShape,
|
||
} from '@/lib/performance-survey-templates'
|
||
import {
|
||
ensurePerformanceSurveyNotificationWorkerStarted,
|
||
queueDuePerformanceSurveyReminders,
|
||
queueSurveyAssignmentNotifications,
|
||
queueSurveyCreatorNotification,
|
||
queueSurveyResultNotifications,
|
||
queueSurveyTargetConfirmationNotifications,
|
||
recoverDuePerformanceSurveyNotifications,
|
||
retryPerformanceSurveyNotification,
|
||
} from '@/lib/performance-survey-notifications'
|
||
import {
|
||
ensurePerformanceSurveyDailyEmployeeSyncStarted,
|
||
ensurePerformanceSurveyEmployeeProfileForUser,
|
||
getPerformanceSurveyAccountProfileWhere,
|
||
syncPerformanceSurveyEmployees,
|
||
} from '@/lib/performance-survey-report-sync'
|
||
import {
|
||
PERFORMANCE_SURVEY_IMAGE_LIMITS,
|
||
PerformanceSurveyImageProcessingError,
|
||
assertPerformanceSurveyImageBatch,
|
||
assertPerformanceSurveyImageRequestSize,
|
||
inspectPerformanceSurveyImage,
|
||
processPerformanceSurveyImage,
|
||
serializePerformanceSurveyImagePayload,
|
||
withPerformanceSurveyImageProcessingSlot,
|
||
type PerformanceSurveyStoredImageGroup,
|
||
} from '@/lib/performance-survey-image-processing'
|
||
import { getPerformanceSurveyTargetEmployeeIds } from '@/lib/performance-survey-target-policy'
|
||
import { deleteFromS3, 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)),
|
||
dimensions: getPerformanceSurveyQuestionScoreDetails(template, response.scores),
|
||
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)),
|
||
dimensions: getPerformanceSurveyQuestionScoreDetails(template, score.scores),
|
||
comment: score.comment || '',
|
||
submittedAt: score.createdAt,
|
||
}))
|
||
const images = serializePerformanceSurveyImagePayload(item.imageUrls)
|
||
|
||
return {
|
||
id: item.id,
|
||
title: item.title,
|
||
description: item.description,
|
||
imageUrls: images.imageUrls,
|
||
imageGroups: images.imageGroups,
|
||
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)
|
||
}
|
||
|
||
if (surveyKind !== 'item_course' && !ctx.employee?.id) {
|
||
throw new PerformanceSurveyApiError('当前账户尚未匹配考核人员档案,暂时无法发起调研', 409)
|
||
}
|
||
|
||
const targets = await resolveSurveyTargets(body, surveyKind, ctx.employee?.id)
|
||
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)
|
||
}
|
||
await queueSurveyTargetConfirmationNotifications(surveyId)
|
||
|
||
return getPerformanceSurveyDetail(session, surveyId)
|
||
}
|
||
|
||
export async function getPerformanceSurveyDetail(session: Session | null, surveyId: string) {
|
||
const ctx = await getPerformanceSurveyContext(session)
|
||
const survey = await prisma.performanceSurvey.findUnique({
|
||
where: { id: surveyId },
|
||
include: {
|
||
targets: {
|
||
orderBy: { sortOrder: 'asc' },
|
||
include: {
|
||
assignments: {
|
||
select: {
|
||
id: true,
|
||
evaluatorName: true,
|
||
evaluatorEmployeeId: true,
|
||
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) => {
|
||
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,
|
||
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)
|
||
await queueSurveyTargetConfirmationNotifications(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' },
|
||
},
|
||
workItems: {
|
||
orderBy: { sortOrder: 'asc' },
|
||
include: {
|
||
scores: {
|
||
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,
|
||
})),
|
||
workItems: target.workItems.map((item) => {
|
||
const images = serializePerformanceSurveyImagePayload(item.imageUrls)
|
||
const scores = item.scores.map((score, index) => ({
|
||
id: score.id,
|
||
evaluatorName: showRealRespondent ? score.evaluatorName : `匿名评价 ${index + 1}`,
|
||
scores: score.scores,
|
||
comment: score.comment,
|
||
totalScore: score.totalScore,
|
||
createdAt: score.createdAt,
|
||
}))
|
||
return {
|
||
id: item.id,
|
||
title: item.title,
|
||
description: item.description,
|
||
imageUrls: images.imageUrls,
|
||
imageGroups: images.imageGroups,
|
||
averageScore: average(scores.map((score) => Number(score.totalScore)).filter(Number.isFinite)),
|
||
scores,
|
||
}
|
||
}),
|
||
}
|
||
}),
|
||
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 createPerformanceSurveyWorkItemFromRequest(
|
||
session: Session | null,
|
||
surveyId: string,
|
||
request: Request,
|
||
) {
|
||
try {
|
||
assertPerformanceSurveyImageRequestSize(request.headers.get('content-length'))
|
||
return await withPerformanceSurveyImageProcessingSlot(async () => {
|
||
const formData = await request.formData()
|
||
return createPerformanceSurveyWorkItem(session, surveyId, formData)
|
||
})
|
||
} catch (error) {
|
||
if (error instanceof PerformanceSurveyImageProcessingError) {
|
||
const status = error.message.includes('任务较多') ? 429 : 400
|
||
throw new PerformanceSurveyApiError(error.message, status)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
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 requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3))
|
||
if (survey.workItems.length >= requiredWorkItemCount) {
|
||
throw new PerformanceSurveyApiError(`当前调研最多提交 ${requiredWorkItemCount} 个案例`)
|
||
}
|
||
|
||
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 totalSourceBytes = files.reduce((sum, file) => sum + file.size, 0)
|
||
if (totalSourceBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxBatchSourceBytes) {
|
||
throw new PerformanceSurveyApiError('单个案例上传总大小不能超过 60MB')
|
||
}
|
||
|
||
const title = readFormText(formData, 'title') || `案例 ${survey.workItems.length + 1}`
|
||
const description = readFormText(formData, 'description')
|
||
const plans = []
|
||
for (let sourceIndex = 0; sourceIndex < files.length; sourceIndex += 1) {
|
||
const file = files[sourceIndex]
|
||
if (!file.type.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件')
|
||
if (file.size > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) {
|
||
throw new PerformanceSurveyApiError('单张图片不能超过 20MB')
|
||
}
|
||
const input = {
|
||
buffer: Buffer.from(await file.arrayBuffer()),
|
||
mimeType: file.type,
|
||
sourceName: file.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 < files.length; sourceIndex += 1) {
|
||
const file = files[sourceIndex]
|
||
const input = {
|
||
buffer: Buffer.from(await file.arrayBuffer()),
|
||
mimeType: file.type,
|
||
sourceName: file.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.performanceSurveyWorkItem.create({
|
||
data: {
|
||
id: randomUUID(),
|
||
surveyId,
|
||
targetId,
|
||
submitterUserId: ctx.user.id,
|
||
submitterEmployeeId: ctx.employee?.id || null,
|
||
title,
|
||
description,
|
||
imageUrls: imageGroups as any,
|
||
sortOrder: survey.workItems.length,
|
||
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
|
||
}
|
||
}
|
||
|
||
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, currentEmployeeId?: string | null) {
|
||
const targetEmployeeIds = 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 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
|
||
}
|