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