gongxiangp2/lib/performance-survey-service.ts
2026-07-08 19:37:45 +08:00

1630 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { randomUUID } from 'crypto'
import type { Session } from 'next-auth'
import { pinyin } from 'pinyin-pro'
import { prisma } from '@/lib/prisma'
import { getDingtalkWorkNoticeConfigStatus } from '@/lib/dingtalk-client'
import {
calculateAggregateScore,
calculateResponseScores,
getPerformanceSurveyTemplate,
listPerformanceSurveyTemplates,
type PerformanceSurveyTemplateShape,
} from '@/lib/performance-survey-templates'
import {
ensurePerformanceSurveyNotificationWorkerStarted,
queueDuePerformanceSurveyReminders,
queueSurveyAssignmentNotifications,
queueSurveyCreatorNotification,
queueSurveyResultNotifications,
recoverDuePerformanceSurveyNotifications,
retryPerformanceSurveyNotification,
} from '@/lib/performance-survey-notifications'
import {
ensurePerformanceSurveyDailyEmployeeSyncStarted,
ensurePerformanceSurveyEmployeeProfileForUser,
getPerformanceSurveyAccountProfileWhere,
syncPerformanceSurveyEmployees,
} from '@/lib/performance-survey-report-sync'
export class PerformanceSurveyApiError extends Error {
status: number
constructor(message: string, status = 400) {
super(message)
this.status = status
}
}
type UserContext = {
user: {
id: string
username: string
email: string
role: string
department: string | null
managedDepartment: string | null
isSuperAdmin: boolean
dingtalkUserId: string | null
realName: string | null
avatar: string | null
}
employee: any | null
isSystemAdmin: boolean
isDepartmentAdmin: boolean
isLeadership: boolean
scopeDepartment: string | null
evaluatorKey: string
}
function clean(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized.length > 0 ? normalized : null
}
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 encodeDepartmentPart(value: string) {
return encodeURIComponent(value)
}
function decodeDepartmentPart(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function parseDepartmentPathParts(value?: string | null) {
const normalized = clean(value)
if (!normalized) return []
if (normalized.startsWith('[')) {
try {
const parsed = JSON.parse(normalized)
if (Array.isArray(parsed)) {
return parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item))
}
} catch {
// 继续按普通分隔符拆分。
}
}
return normalized
.split(/[/>||,,;;]/)
.map((item) => item.trim())
.filter(Boolean)
}
function getDepartmentPathValue(departmentPath: string) {
return `${DEPARTMENT_PATH_VALUE_PREFIX}${encodeDepartmentPart(departmentPath)}`
}
function getDepartmentNodeId(departmentPath: string) {
return `dept:${encodeDepartmentPart(departmentPath)}`
}
function getScopeDepartmentWhere(scopeDepartment: string) {
return {
OR: [
{ departmentName: scopeDepartment },
{ departmentPath: scopeDepartment },
{ departmentPath: { startsWith: `${scopeDepartment}/` } },
{ departmentPath: { contains: `/${scopeDepartment}/` } },
{ departmentPath: { endsWith: `/${scopeDepartment}` } },
],
}
}
function parseDepartmentSelection(value: string) {
if (value.startsWith(DEPARTMENT_PATH_VALUE_PREFIX)) {
const payload = value.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(value)
return departmentName ? { departmentName, departmentPath: null } : null
}
function getDepartmentSelectionWhere(values: string[]) {
const departmentNames: string[] = []
const departmentPathClauses: any[] = []
for (const value of values) {
const parsed = parseDepartmentSelection(value)
if (!parsed) continue
if (parsed.departmentPath) {
departmentPathClauses.push({
OR: [
{ departmentPath: parsed.departmentPath },
{ departmentPath: { startsWith: `${parsed.departmentPath}/` } },
...(parsed.departmentPath.includes('/') ? [] : [{ departmentName: parsed.departmentName }]),
],
})
} else {
departmentNames.push(parsed.departmentName)
}
}
const clauses: any[] = []
const uniqueDepartmentNames = Array.from(new Set(departmentNames))
if (uniqueDepartmentNames.length > 0) {
clauses.push({ departmentName: { in: uniqueDepartmentNames } })
}
clauses.push(...departmentPathClauses)
return clauses
}
function toDate(value: unknown) {
const normalized = clean(value)
if (!normalized) return null
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
function getMonthRange(value: unknown) {
const normalized = clean(value)
const match = normalized?.match(/^(\d{4})-(\d{2})$/)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return null
const start = new Date(year, month - 1, 1, 0, 0, 0, 0)
const end = new Date(year, month, 1, 0, 0, 0, 0)
return { start, end }
}
function getDisplayName(user: { username: string; realName?: string | null }) {
return clean(user.realName) || clean(user.username) || '未知用户'
}
function getEmployeeDisplayName(employee: any) {
return clean(employee?.displayName) || clean(employee?.flowerName) || clean(employee?.realName) || clean(employee?.jobNumber) || '未知员工'
}
function getEmployeeSnapshot(employee: any) {
if (!employee) return {}
return {
id: employee.id,
dingtalkUserId: employee.dingtalkUserId,
dingEmail: employee.dingEmail,
jobNumber: employee.jobNumber,
flowerName: employee.flowerName,
realName: employee.realName,
displayName: employee.displayName,
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
positionTitle: employee.positionTitle,
positionLevel: employee.positionLevel,
levelType: employee.levelType,
isLeadership: employee.isLeadership,
managerDingtalkUserId: employee.managerDingtalkUserId,
managerJobNumber: employee.managerJobNumber,
managerName: employee.managerName,
employmentStatus: employee.employmentStatus,
workPlace: employee.workPlace,
snapshotAt: new Date().toISOString(),
}
}
function getEvaluatorKey(input: {
employeeId?: string | null
userId?: string | null
dingtalkUserId?: string | null
}) {
if (input.dingtalkUserId) return `ding:${input.dingtalkUserId}`
if (input.userId) return `user:${input.userId}`
if (input.employeeId) return `employee:${input.employeeId}`
return `unknown:${randomUUID()}`
}
function ensureManager(ctx: UserContext) {
if (!ctx.isDepartmentAdmin) {
throw new PerformanceSurveyApiError('无权限管理考核调研', 403)
}
}
function isLeadershipProfile(employee: any) {
const levelType = clean(employee?.levelType)?.toUpperCase()
const positionLevel = clean(employee?.positionLevel)?.toUpperCase()
return Boolean(employee?.isLeadership || levelType === 'M' || positionLevel?.startsWith('M'))
}
function isSurveyInManagerScope(ctx: UserContext, survey: { creatorUserId: string; departmentName?: string | null }) {
if (ctx.isSystemAdmin) return true
if (survey.creatorUserId === ctx.user.id) return true
if (!ctx.scopeDepartment) return false
return survey.departmentName === ctx.scopeDepartment
}
async function canManageSurvey(ctx: UserContext, survey: { id: string; creatorUserId: string; departmentName?: string | null }) {
if (!ctx.isDepartmentAdmin) return false
if (isSurveyInManagerScope(ctx, survey)) return true
if (!ctx.scopeDepartment) return false
const scopeWhere = await getSurveyWhereByEmployeeProfileScope(getScopeDepartmentWhere(ctx.scopeDepartment))
const count = await prisma.performanceSurvey.count({
where: {
AND: [
{ id: survey.id },
scopeWhere,
],
},
})
return count > 0
}
async function ensureSurveyManageScope(ctx: UserContext, survey: { id: string; creatorUserId: string; departmentName?: string | null }) {
if (!(await canManageSurvey(ctx, survey))) {
throw new PerformanceSurveyApiError('无权限管理该考核调研', 403)
}
}
function mergeOrClauses(clauses: any[]) {
const validClauses = clauses.filter(Boolean)
if (validClauses.length === 0) return { id: '__none__' }
return validClauses.length === 1 ? validClauses[0] : { OR: validClauses }
}
async function getSurveyWhereByEmployeeProfileScope(profileWhere: any) {
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: profileWhere,
select: { id: true },
take: 10_000,
})
const employeeIds = employees.map((employee) => employee.id)
const clauses: any[] = [
{ targets: { some: { employeeProfile: { is: profileWhere } } } },
{ assignments: { some: { evaluatorEmployee: { is: profileWhere } } } },
]
if (employeeIds.length > 0) {
clauses.push({ creatorEmployeeId: { in: employeeIds } })
}
return mergeOrClauses(clauses)
}
async function getManagerSurveyBaseWhere(ctx: UserContext) {
if (ctx.isSystemAdmin) return {}
if (!ctx.scopeDepartment) return { creatorUserId: ctx.user.id }
return mergeOrClauses([
{ creatorUserId: ctx.user.id },
{ departmentName: ctx.scopeDepartment },
await getSurveyWhereByEmployeeProfileScope(getScopeDepartmentWhere(ctx.scopeDepartment)),
])
}
async function getManagerSurveyFilterWhere(ctx: UserContext, params?: URLSearchParams) {
const and: any[] = [await getManagerSurveyBaseWhere(ctx)]
const departmentValue = clean(params?.get('department'))
const monthRange = getMonthRange(params?.get('month'))
if (departmentValue) {
const departmentClauses = getDepartmentSelectionWhere([departmentValue])
if (departmentClauses.length > 0) {
and.push(await getSurveyWhereByEmployeeProfileScope(mergeOrClauses(departmentClauses)))
}
}
if (monthRange) {
and.push({
OR: [
{ dueAt: { gte: monthRange.start, lt: monthRange.end } },
{
AND: [
{ dueAt: null },
{ createdAt: { gte: monthRange.start, lt: monthRange.end } },
],
},
],
})
}
return and.length === 1 ? and[0] : { AND: and }
}
export async function getPerformanceSurveyContext(session: Session | null): Promise<UserContext> {
ensurePerformanceSurveyNotificationWorkerStarted()
ensurePerformanceSurveyDailyEmployeeSyncStarted()
const sessionUserId = (session?.user as any)?.id
if (!sessionUserId) {
throw new PerformanceSurveyApiError('请先登录', 401)
}
const user = await prisma.user.findUnique({
where: { id: sessionUserId },
select: {
id: true,
username: true,
email: true,
role: true,
department: true,
managedDepartment: true,
isSuperAdmin: true,
dingtalkUserId: true,
realName: true,
avatar: true,
},
})
if (!user) {
throw new PerformanceSurveyApiError('登录用户不存在', 401)
}
const employee = await ensurePerformanceSurveyEmployeeProfileForUser({
userId: user.id,
dingtalkUserId: user.dingtalkUserId,
email: user.email,
username: user.username,
realName: user.realName,
displayName: getDisplayName(user),
})
const isSystemAdmin = Boolean(user.isSuperAdmin || user.role === 'ADMIN' || user.role === 'SYSTEM_ADMIN')
const isLeadership = isLeadershipProfile(employee)
const isDepartmentAdmin = Boolean(isSystemAdmin || user.role === 'DEPT_ADMIN' || isLeadership)
const scopeDepartment = clean(user.managedDepartment) || clean(employee?.departmentName) || clean(user.department)
return {
user,
employee,
isSystemAdmin,
isDepartmentAdmin,
isLeadership,
scopeDepartment,
evaluatorKey: getEvaluatorKey({
employeeId: employee?.id,
userId: user.id,
dingtalkUserId: user.dingtalkUserId,
}),
}
}
export async function getPerformanceSurveyMe(session: Session | null) {
const ctx = await getPerformanceSurveyContext(session)
return {
user: {
id: ctx.user.id,
name: getDisplayName(ctx.user),
email: ctx.user.email,
role: ctx.user.role,
department: ctx.user.department,
managedDepartment: ctx.user.managedDepartment,
dingtalkUserId: ctx.user.dingtalkUserId,
avatar: ctx.user.avatar,
},
employee: ctx.employee ? getEmployeeSnapshot(ctx.employee) : null,
permissions: {
isSystemAdmin: ctx.isSystemAdmin,
isDepartmentAdmin: ctx.isDepartmentAdmin,
isLeadership: ctx.isLeadership,
scopeDepartment: ctx.scopeDepartment,
canCreate: true,
},
}
}
export async function getPerformanceSurveyTemplates(session: Session | null) {
await getPerformanceSurveyContext(session)
return listPerformanceSurveyTemplates()
}
export async function searchPerformanceSurveyEmployees(session: Session | null, params: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
const search = clean(params.get('search')) || ''
const department = clean(params.get('department'))
const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 1000))
const isEvaluatorSearch = clean(params.get('purpose')) === 'evaluator'
const buildWhere = async (includeKeyword: boolean) => {
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [accountWhere]
if (department) {
const departmentClauses = getDepartmentSelectionWhere([department])
if (departmentClauses.length === 1) {
and.push(departmentClauses[0])
} else if (departmentClauses.length > 1) {
and.push({ OR: departmentClauses })
}
}
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorSearch) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
if (includeKeyword && search) {
and.push({ OR: [
{ displayName: { contains: search, mode: 'insensitive' } },
{ flowerName: { contains: search, mode: 'insensitive' } },
{ realName: { contains: search, mode: 'insensitive' } },
{ jobNumber: { contains: search, mode: 'insensitive' } },
{ dingEmail: { contains: search, mode: 'insensitive' } },
{ departmentName: { contains: search, mode: 'insensitive' } },
{ departmentPath: { contains: search, mode: 'insensitive' } },
{ positionTitle: { contains: search, mode: 'insensitive' } },
{ positionLevel: { contains: search, mode: 'insensitive' } },
{ levelType: { contains: search, mode: 'insensitive' } },
{ managerName: { contains: search, mode: 'insensitive' } },
] })
}
return { AND: and } as any
}
const queryEmployees = async () => prisma.performanceSurveyEmployeeProfile.findMany({
where: await buildWhere(true),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: limit,
})
let employees = await queryEmployees()
const needsPinyinSearch = search && (employees.length === 0 || /[A-Za-z]/.test(search))
if (needsPinyinSearch && employees.length < limit) {
const candidates = await prisma.performanceSurveyEmployeeProfile.findMany({
where: await buildWhere(false),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: Math.min(5000, Math.max(1000, limit * 20)),
})
const existingIds = new Set(employees.map((employee) => employee.id))
const pinyinMatches = candidates.filter((employee) => {
if (existingIds.has(employee.id)) return false
return matchesSearchKeyword(search, [
employee.displayName,
employee.flowerName,
employee.realName,
employee.jobNumber,
employee.dingEmail,
employee.departmentName,
employee.departmentPath,
employee.positionTitle,
employee.positionLevel,
employee.levelType,
employee.managerName,
])
})
employees = [...employees, ...pinyinMatches].slice(0, limit)
}
return employees.map(serializeEmployee)
}
export async function listPerformanceSurveyDepartments(session: Session | null, params?: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
const isEvaluatorPicker = clean(params?.get('purpose')) === 'evaluator'
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [
accountWhere,
{ departmentName: { not: null } },
]
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorPicker) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: { AND: and },
select: {
departmentName: true,
departmentPath: true,
},
})
const nodeByPath = new Map<string, any>()
for (const employee of employees) {
const departmentName = clean(employee.departmentName)
const pathParts = parseDepartmentPathParts(employee.departmentPath)
const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : []
if (parts.length === 0) continue
for (let index = 0; index < parts.length; index += 1) {
const fullPath = parts.slice(0, index + 1).join('/')
const parentPath = index > 0 ? parts.slice(0, index).join('/') : null
const existing = nodeByPath.get(fullPath)
if (existing) {
existing.employeeCount += 1
continue
}
nodeByPath.set(fullPath, {
id: getDepartmentNodeId(fullPath),
name: parts[index],
parentId: parentPath ? getDepartmentNodeId(parentPath) : ALL_DEPARTMENTS_NODE_ID,
parentName: parentPath ? parts[index - 1] : ALL_DEPARTMENTS_NODE_NAME,
employeeCount: 1,
selectable: true,
value: getDepartmentPathValue(fullPath),
departmentName: parts[index],
departmentPath: fullPath,
fullPath,
})
}
}
return [
{
id: ALL_DEPARTMENTS_NODE_ID,
name: ALL_DEPARTMENTS_NODE_NAME,
parentId: null,
parentName: null,
employeeCount: employees.length,
selectable: false,
value: null,
departmentPath: null,
fullPath: null,
},
...Array.from(nodeByPath.values()).sort((a, b) => {
const depthDiff = parseDepartmentPathParts(a.departmentPath).length - parseDepartmentPathParts(b.departmentPath).length
if (depthDiff !== 0) return depthDiff
if (b.employeeCount !== a.employeeCount) return b.employeeCount - a.employeeCount
return a.fullPath.localeCompare(b.fullPath, 'zh-CN')
}),
]
}
export async function listPerformanceSurveys(session: Session | null, params?: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
const now = new Date()
const pendingAssignments = await prisma.performanceSurveyAssignment.findMany({
where: {
evaluatorKey: ctx.evaluatorKey,
status: 'pending',
survey: {
status: 'open',
OR: [
{ dueAt: null },
{ dueAt: { gt: now } },
],
},
},
include: {
survey: true,
target: true,
},
orderBy: { createdAt: 'desc' },
take: 100,
})
const submittedAssignmentCount = await prisma.performanceSurveyAssignment.count({
where: {
evaluatorKey: ctx.evaluatorKey,
status: 'submitted',
},
})
const submittedAssignments = await prisma.performanceSurveyAssignment.findMany({
where: {
evaluatorKey: ctx.evaluatorKey,
status: 'submitted',
},
include: {
survey: true,
target: true,
},
orderBy: { submittedAt: 'desc' },
take: 100,
})
const personalSurveyWhere = { creatorUserId: ctx.user.id }
const managerWhere = ctx.isDepartmentAdmin
? await getManagerSurveyBaseWhere(ctx)
: { creatorUserId: ctx.user.id }
const managedFilteredWhere = ctx.isDepartmentAdmin
? await getManagerSurveyFilterWhere(ctx, params)
: { creatorUserId: ctx.user.id }
const createdSurveys = await prisma.performanceSurvey.findMany({
where: personalSurveyWhere,
include: {
_count: {
select: { targets: true, assignments: true, responses: true },
},
},
orderBy: { createdAt: 'desc' },
take: 100,
})
const managedSurveyCount = ctx.isDepartmentAdmin
? await prisma.performanceSurvey.count({ where: managerWhere as any })
: createdSurveys.length
const managedOpenSurveyCount = ctx.isDepartmentAdmin
? await prisma.performanceSurvey.count({ where: { AND: [managerWhere, { status: 'open' }] } as any })
: createdSurveys.filter((survey) => survey.status === 'open').length
const managedSurveys = ctx.isDepartmentAdmin
? await prisma.performanceSurvey.findMany({
where: managedFilteredWhere as any,
include: {
_count: {
select: { targets: true, assignments: true, responses: true },
},
},
orderBy: { createdAt: 'desc' },
take: 200,
})
: []
const templates = await listPerformanceSurveyTemplates()
const scopedResponses = await prisma.performanceSurveyResponse.findMany({
where: {
survey: managerWhere as any,
},
include: {
survey: {
select: { templateCode: true },
},
},
orderBy: { createdAt: 'desc' },
take: 5000,
})
const responsesByTemplate = new Map<string, typeof scopedResponses>()
for (const response of scopedResponses) {
const list = responsesByTemplate.get(response.survey.templateCode) || []
list.push(response)
responsesByTemplate.set(response.survey.templateCode, list)
}
const resultTargets = ctx.employee
? await prisma.performanceSurveyTarget.findMany({
where: {
employeeProfileId: ctx.employee.id,
survey: { status: 'closed' },
},
include: { survey: true },
orderBy: { createdAt: 'desc' },
take: 100,
})
: []
const canSeeScopeSummary = Boolean(ctx.isSystemAdmin || ctx.isLeadership)
const scopeAssignmentEmployeeWhere = ctx.isSystemAdmin
? {}
: ctx.scopeDepartment
? { evaluatorEmployee: { is: getScopeDepartmentWhere(ctx.scopeDepartment) } }
: { evaluatorKey: ctx.evaluatorKey }
const scopeTargetEmployeeWhere = ctx.isSystemAdmin
? {}
: ctx.scopeDepartment
? { employeeProfile: { is: getScopeDepartmentWhere(ctx.scopeDepartment) } }
: ctx.employee
? { employeeProfileId: ctx.employee.id }
: { id: '__none__' }
const scopePendingCount = canSeeScopeSummary
? await prisma.performanceSurveyAssignment.count({
where: {
...scopeAssignmentEmployeeWhere,
status: 'pending',
survey: {
status: 'open',
OR: [
{ dueAt: null },
{ dueAt: { gt: now } },
],
},
} as any,
})
: pendingAssignments.length
const scopeSubmittedCount = canSeeScopeSummary
? await prisma.performanceSurveyAssignment.count({
where: {
...scopeAssignmentEmployeeWhere,
status: 'submitted',
} as any,
})
: submittedAssignmentCount
const scopeResultCount = canSeeScopeSummary
? await prisma.performanceSurveyTarget.count({
where: {
...scopeTargetEmployeeWhere,
survey: { status: 'closed' },
} as any,
})
: resultTargets.length
const personalSummary = {
pendingCount: pendingAssignments.length,
createdCount: createdSurveys.length,
openCreatedCount: createdSurveys.filter((survey) => survey.status === 'open').length,
submittedCount: submittedAssignmentCount,
resultCount: resultTargets.length,
}
const scopeSummary = canSeeScopeSummary
? {
pendingCount: scopePendingCount,
createdCount: managedSurveyCount,
openCreatedCount: managedOpenSurveyCount,
submittedCount: scopeSubmittedCount,
resultCount: scopeResultCount,
}
: null
return {
summary: {
...personalSummary,
personal: personalSummary,
scope: scopeSummary,
scopeMode: ctx.isSystemAdmin ? 'system' : ctx.isLeadership ? 'organization' : 'self',
},
pending: pendingAssignments.map((assignment) => ({
assignmentId: assignment.id,
surveyId: assignment.surveyId,
targetId: assignment.targetId,
title: assignment.survey.title,
status: assignment.status,
surveyStatus: assignment.survey.status,
dueAt: assignment.survey.dueAt,
targetName: assignment.target.targetName,
templateCode: assignment.survey.templateCode,
submittedAt: assignment.submittedAt,
})),
submitted: submittedAssignments.map((assignment) => ({
assignmentId: assignment.id,
surveyId: assignment.surveyId,
targetId: assignment.targetId,
title: assignment.survey.title,
status: assignment.status,
surveyStatus: assignment.survey.status,
dueAt: assignment.survey.dueAt,
targetName: assignment.target.targetName,
templateCode: assignment.survey.templateCode,
submittedAt: assignment.submittedAt,
})),
created: createdSurveys.map((survey) => ({
id: survey.id,
title: survey.title,
description: survey.description,
surveyKind: survey.surveyKind,
templateCode: survey.templateCode,
status: survey.status,
departmentName: survey.departmentName,
dueAt: survey.dueAt,
openedAt: survey.openedAt,
closedAt: survey.closedAt,
createdAt: survey.createdAt,
counts: survey._count,
})),
managed: managedSurveys.map((survey) => ({
id: survey.id,
title: survey.title,
description: survey.description,
surveyKind: survey.surveyKind,
templateCode: survey.templateCode,
status: survey.status,
departmentName: survey.departmentName,
dueAt: survey.dueAt,
openedAt: survey.openedAt,
closedAt: survey.closedAt,
createdAt: survey.createdAt,
creatorName: survey.creatorName,
counts: survey._count,
})),
results: resultTargets.map((target) => ({
surveyId: target.surveyId,
targetId: target.id,
title: target.survey.title,
targetName: target.targetName,
closedAt: target.survey.closedAt,
})),
templateStats: templates.map((template) => {
const responses = responsesByTemplate.get(template.code) || []
const aggregate = calculateAggregateScore(
responses.map((response) => ({
id: response.id,
totalScore: response.totalScore,
createdAt: response.createdAt,
scores: response.scores as Record<string, unknown>,
respondentName: response.respondentName,
})),
template.scoreBands,
template.weight,
template,
)
return {
code: template.code,
name: template.name,
shortName: template.shortName,
weight: template.weight,
weightLabel: template.weightLabel,
achievementLabel: template.achievementLabel,
achievementSuffix: template.achievementSuffix,
responseCount: responses.length,
aggregate,
}
}),
permissions: {
isSystemAdmin: ctx.isSystemAdmin,
isDepartmentAdmin: ctx.isDepartmentAdmin,
isLeadership: ctx.isLeadership,
scopeDepartment: ctx.scopeDepartment,
canCreate: true,
},
}
}
export async function createPerformanceSurvey(session: Session | null, body: any) {
const ctx = await getPerformanceSurveyContext(session)
const title = clean(body?.title)
if (!title) throw new PerformanceSurveyApiError('调研标题不能为空')
const templateCode = clean(body?.templateCode) || 'cross_department'
const template = await getPerformanceSurveyTemplate(templateCode)
const surveyKind = clean(body?.surveyKind) || template.surveyType
const dueAt = toDate(body?.dueAt)
const openNow = Boolean(body?.openNow || body?.status === 'open')
const requestedDepartmentName = clean(body?.departmentName)
const departmentName = requestedDepartmentName || ctx.scopeDepartment
if (!ctx.isSystemAdmin && requestedDepartmentName && requestedDepartmentName !== ctx.scopeDepartment) {
throw new PerformanceSurveyApiError('只能创建本人部门范围内的考核调研', 403)
}
const targets = await resolveSurveyTargets(body, surveyKind)
if (targets.length === 0) {
throw new PerformanceSurveyApiError(
surveyKind === 'item_course'
? `请填写${template.itemName || '课程、事项或活动'}名称`
: `请至少选择 1 个${template.ownerRoleName || '被考核人'}`,
)
}
const evaluators = await resolveSurveyEvaluators(body, ctx)
if (evaluators.length === 0) {
throw new PerformanceSurveyApiError('请至少选择 1 个评价人')
}
const surveyId = randomUUID()
const targetRows = targets.map((target: any, index: number) => ({
id: randomUUID(),
surveyId,
targetType: target.targetType,
employeeProfileId: target.employee?.id || null,
targetName: target.targetName,
targetSnapshot: target.employee ? getEmployeeSnapshot(target.employee) : target.snapshot,
sortOrder: index,
}))
const assignmentRows: any[] = []
for (const target of targetRows) {
for (const evaluator of evaluators) {
if (
surveyKind === 'person_period' &&
target.employeeProfileId &&
evaluator.employee?.id === target.employeeProfileId
) {
continue
}
assignmentRows.push({
id: randomUUID(),
surveyId,
targetId: target.id,
evaluatorEmployeeId: evaluator.employee?.id || null,
evaluatorUserId: evaluator.userId || null,
evaluatorDingtalkUserId: evaluator.dingtalkUserId || null,
evaluatorKey: evaluator.evaluatorKey,
evaluatorName: evaluator.name,
evaluatorSnapshot: evaluator.employee ? getEmployeeSnapshot(evaluator.employee) : evaluator.snapshot,
status: 'pending',
})
}
}
if (assignmentRows.length === 0) {
throw new PerformanceSurveyApiError('评价人不能全部等于被考核人,请调整评价范围')
}
await prisma.$transaction(async (tx) => {
await tx.performanceSurvey.create({
data: {
id: surveyId,
title,
description: clean(body?.description),
surveyKind,
templateCode: template.code,
templateSnapshot: template as any,
status: openNow ? 'open' : 'draft',
creatorUserId: ctx.user.id,
creatorName: getDisplayName(ctx.user),
creatorEmployeeId: ctx.employee?.id || null,
departmentName,
dueAt,
openedAt: openNow ? new Date() : null,
},
})
await tx.performanceSurveyTarget.createMany({ data: targetRows })
await tx.performanceSurveyAssignment.createMany({ data: assignmentRows, skipDuplicates: true })
})
if (openNow) {
await queueSurveyAssignmentNotifications(surveyId)
}
return getPerformanceSurveyDetail(session, surveyId)
}
export async function getPerformanceSurveyDetail(session: Session | null, surveyId: string) {
const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
targets: {
orderBy: { sortOrder: 'asc' },
include: {
assignments: {
select: {
id: true,
evaluatorName: true,
evaluatorEmployeeId: true,
status: true,
submittedAt: true,
},
orderBy: { createdAt: 'asc' },
},
responses: true,
},
},
assignments: {
where: { evaluatorKey: ctx.evaluatorKey },
include: { response: true },
},
_count: {
select: { assignments: true, responses: true, targets: true },
},
},
})
if (!survey) {
throw new PerformanceSurveyApiError('调研不存在或无权限', 404)
}
const isAssigned = survey.assignments.length > 0
const canManage = await canManageSurvey(ctx, survey)
const isTarget = Boolean(
ctx.employee && survey.targets.some((target) => target.employeeProfileId === ctx.employee?.id),
)
if (!canManage && !isAssigned && !isTarget && survey.creatorUserId !== ctx.user.id) {
throw new PerformanceSurveyApiError('调研不存在或无权限', 404)
}
const currentAssignments = new Map(survey.assignments.map((assignment) => [assignment.targetId, assignment]))
return {
id: survey.id,
title: survey.title,
description: survey.description,
surveyKind: survey.surveyKind,
templateCode: survey.templateCode,
template: survey.templateSnapshot,
status: survey.status,
creatorName: survey.creatorName,
departmentName: survey.departmentName,
dueAt: survey.dueAt,
openedAt: survey.openedAt,
closedAt: survey.closedAt,
counts: survey._count,
canManage,
canSubmit: isAssigned && survey.status === 'open',
canViewResults: canManage || survey.creatorUserId === ctx.user.id || (survey.status === 'closed' && isTarget),
targets: survey.targets.map((target) => {
const currentAssignment = currentAssignments.get(target.id)
const response = currentAssignment?.response
return {
id: target.id,
targetType: target.targetType,
targetName: target.targetName,
targetSnapshot: target.targetSnapshot,
responseCount: target.responses.length,
assignmentCount: target.assignments.length,
currentAssignment: currentAssignment
? {
id: currentAssignment.id,
status: currentAssignment.status,
submittedAt: currentAssignment.submittedAt,
response: response
? {
id: response.id,
scores: response.scores,
comment: response.comment,
totalScore: response.totalScore,
achievementScore: response.achievementScore,
kpiScore: response.kpiScore,
weightedScore: response.weightedScore,
createdAt: response.createdAt,
}
: null,
}
: null,
assignments: canManage
? target.assignments.map((assignment) => ({
id: assignment.id,
evaluatorName: assignment.evaluatorName,
status: assignment.status,
submittedAt: assignment.submittedAt,
}))
: [],
}
}),
}
}
export async function updatePerformanceSurvey(session: Session | null, surveyId: string, body: any) {
const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId } })
if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404)
await ensureSurveyManageScope(ctx, survey)
const nextStatus = clean(body?.status)
const data: any = {}
if (body?.title != null) data.title = clean(body.title) || survey.title
if (body?.description !== undefined) data.description = clean(body.description)
if (body?.dueAt !== undefined) data.dueAt = toDate(body.dueAt)
if (nextStatus === 'open' && survey.status === 'draft') {
data.status = 'open'
data.openedAt = new Date()
} else if (nextStatus === 'closed' && survey.status !== 'closed') {
data.status = 'closed'
data.closedAt = new Date()
} else if (nextStatus && !['draft', 'open', 'closed'].includes(nextStatus)) {
throw new PerformanceSurveyApiError('无效的调研状态')
}
await prisma.performanceSurvey.update({
where: { id: surveyId },
data,
})
if (data.status === 'open') {
await queueSurveyAssignmentNotifications(surveyId)
}
if (data.status === 'closed') {
await queueSurveyResultNotifications(surveyId)
await queueSurveyCreatorNotification(surveyId, 'survey.creator.closed')
}
return getPerformanceSurveyDetail(session, surveyId)
}
export async function submitPerformanceSurveyResponse(session: Session | null, surveyId: string, body: any) {
const ctx = await getPerformanceSurveyContext(session)
const targetId = clean(body?.targetId)
if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象')
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
})
if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404)
if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭')
if (survey.dueAt && survey.dueAt.getTime() < Date.now()) {
throw new PerformanceSurveyApiError('调研已超过截止时间')
}
const assignment = await prisma.performanceSurveyAssignment.findFirst({
where: {
surveyId,
targetId,
evaluatorKey: ctx.evaluatorKey,
},
include: {
target: true,
response: true,
},
})
if (!assignment) throw new PerformanceSurveyApiError('你没有该评价对象的填写权限', 403)
if (assignment.response || assignment.status === 'submitted') {
throw new PerformanceSurveyApiError('你已经提交过该评价,不能重复提交')
}
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
const scoreResult = calculateResponseScores(template, body?.scores || {})
const response = await prisma.$transaction(async (tx) => {
const created = await tx.performanceSurveyResponse.create({
data: {
id: randomUUID(),
surveyId,
targetId,
assignmentId: assignment.id,
respondentUserId: ctx.user.id,
respondentEmployeeId: ctx.employee?.id || null,
respondentName: getDisplayName(ctx.user),
respondentSnapshot: ctx.employee
? getEmployeeSnapshot(ctx.employee)
: {
userId: ctx.user.id,
displayName: getDisplayName(ctx.user),
departmentName: ctx.user.department,
snapshotAt: new Date().toISOString(),
},
scores: body?.scores || {},
comment: clean(body?.comment),
totalScore: scoreResult.totalScore,
achievementScore: scoreResult.achievementScore,
kpiScore: scoreResult.kpiScore,
weightedScore: scoreResult.weightedScore,
},
})
await tx.performanceSurveyAssignment.update({
where: { id: assignment.id },
data: {
status: 'submitted',
submittedAt: new Date(),
},
})
return created
})
const pendingCount = await prisma.performanceSurveyAssignment.count({
where: { surveyId, status: 'pending' },
})
if (pendingCount === 0) {
await queueSurveyCreatorNotification(surveyId, 'survey.creator.completed')
}
return {
id: response.id,
message: '提交成功',
}
}
export async function getPerformanceSurveyResults(
session: Session | null,
surveyId: string,
params?: URLSearchParams,
) {
const ctx = await getPerformanceSurveyContext(session)
const targetIdFilter = clean(params?.get('targetId'))
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
targets: {
where: targetIdFilter ? { id: targetIdFilter } : undefined,
orderBy: { sortOrder: 'asc' },
include: {
responses: {
include: {
assignment: true,
},
orderBy: { createdAt: 'asc' },
},
},
},
_count: {
select: { assignments: true, responses: true, targets: true },
},
},
})
if (!survey) throw new PerformanceSurveyApiError('调研不存在或无权限', 404)
const canManage = await canManageSurvey(ctx, survey)
const isCreator = survey.creatorUserId === ctx.user.id
const isTarget = Boolean(
ctx.employee && survey.targets.some((target) => target.employeeProfileId === ctx.employee?.id),
)
const canView = canManage || isCreator || (survey.status === 'closed' && isTarget)
if (!canView) throw new PerformanceSurveyApiError('无权限查看结果', 403)
if (survey.status !== 'closed' && !canManage) {
throw new PerformanceSurveyApiError('调研关闭后才能查看结果', 403)
}
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
const showRealRespondent = canManage
return {
survey: {
id: survey.id,
title: survey.title,
description: survey.description,
status: survey.status,
templateCode: survey.templateCode,
template,
dueAt: survey.dueAt,
closedAt: survey.closedAt,
counts: survey._count,
},
targets: survey.targets.map((target) => {
const aggregate = calculateAggregateScore(
target.responses.map((response) => ({
id: response.id,
totalScore: response.totalScore,
createdAt: response.createdAt,
scores: response.scores as Record<string, unknown>,
respondentName: response.respondentName,
})),
template.scoreBands,
template.weight,
template,
)
const visibleAggregate = showRealRespondent
? aggregate
: {
...aggregate,
removedHigh: maskRemovedRespondentName(aggregate.removedHigh),
removedLow: maskRemovedRespondentName(aggregate.removedLow),
}
return {
id: target.id,
targetType: target.targetType,
targetName: target.targetName,
targetSnapshot: target.targetSnapshot,
aggregate: visibleAggregate,
responses: target.responses.map((response, index) => ({
id: response.id,
respondentName: showRealRespondent ? response.respondentName : `匿名评价 ${index + 1}`,
scores: response.scores,
comment: response.comment,
totalScore: response.totalScore,
achievementScore: response.achievementScore,
kpiScore: response.kpiScore,
weightedScore: response.weightedScore,
createdAt: response.createdAt,
})),
}
}),
permissions: {
showRealRespondent,
canManage,
},
}
}
function 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) })
return result
}
export async function exportPerformanceSurveyCsv(session: Session | null) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const where = ctx.isSystemAdmin
? {}
: {
OR: [
{ creatorUserId: ctx.user.id },
...(ctx.scopeDepartment ? [{ departmentName: ctx.scopeDepartment }] : []),
],
}
const responses = await prisma.performanceSurveyResponse.findMany({
where: {
survey: where as any,
},
include: {
survey: true,
target: true,
assignment: true,
},
orderBy: { createdAt: 'desc' },
take: 5000,
})
const rows = [
['提交时间', '调研ID', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '总分', '满分', 'KPI分', '加权分', '建议'],
...responses.map((response) => {
const template = response.survey.templateSnapshot as PerformanceSurveyTemplateShape
const respondentSnapshot = response.respondentSnapshot as Record<string, unknown>
const scores = response.scores as Record<string, unknown>
const scoreText = (template.questions || [])
.map((question) => `${question.title}:${scores?.[question.id] ?? ''}/20`)
.join(';')
return [
response.createdAt.toISOString(),
response.surveyId,
template.name || response.survey.templateCode,
response.survey.creatorName,
response.survey.creatorUserId,
response.survey.title,
response.target.targetName,
response.respondentName,
readSnapshotText(respondentSnapshot, 'departmentName') || readSnapshotText(respondentSnapshot, 'departmentPath'),
scoreText,
String(response.totalScore),
String(template.maxTotal || ''),
String(response.kpiScore),
String(response.weightedScore),
response.comment || '',
]
}),
]
return rows.map((row) => row.map(escapeCsv).join(',')).join('\n')
}
export async function listPerformanceSurveyNotifications(session: Session | null, params: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const status = clean(params.get('status'))
const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 200))
const where: any = {}
if (status && status !== 'all') where.status = status
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
where.survey = { departmentName: ctx.scopeDepartment }
}
const notifications = await prisma.performanceSurveyNotification.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
})
return notifications.map((item) => ({
id: item.id,
surveyId: item.surveyId,
recipientName: item.recipientName,
eventType: item.eventType,
title: item.title,
status: item.status,
attemptCount: item.attemptCount,
maxAttempts: item.maxAttempts,
nextRetryAt: item.nextRetryAt,
lastError: item.lastError,
sentAt: item.sentAt,
completedAt: item.completedAt,
createdAt: item.createdAt,
}))
}
export async function getPerformanceSurveyNotificationStatus(session: Session | null) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const where: any = {}
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
where.survey = { departmentName: ctx.scopeDepartment }
}
const grouped = await prisma.performanceSurveyNotification.groupBy({
by: ['status'],
where,
_count: { _all: true },
})
return {
workNotice: getDingtalkWorkNoticeConfigStatus(),
counts: Object.fromEntries(grouped.map((row) => [row.status, row._count._all])),
}
}
export async function retryPerformanceSurveyNotificationById(session: Session | null, notificationId: string) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const notification = await prisma.performanceSurveyNotification.findUnique({
where: { id: notificationId },
include: { survey: true },
})
if (!notification) throw new PerformanceSurveyApiError('通知记录不存在', 404)
if (notification.survey && !(await canManageSurvey(ctx, notification.survey))) {
throw new PerformanceSurveyApiError('无权限补发该通知', 403)
}
await retryPerformanceSurveyNotification(notificationId)
return { message: '已加入补发队列' }
}
export async function runPerformanceSurveyNotificationMaintenance(session: Session | null) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const reminderIds = await queueDuePerformanceSurveyReminders()
const recovered = await recoverDuePerformanceSurveyNotifications()
return { reminderCount: reminderIds.length, recovered }
}
async function resolveSurveyTargets(body: any, surveyKind: string) {
const targetEmployeeIds = normalizeList(body?.targetEmployeeIds)
const itemTargets = Array.isArray(body?.targetItems)
? body.targetItems
.map((item: any) => ({
targetType: 'item',
targetName: clean(item?.name || item?.targetName),
snapshot: {
name: clean(item?.name || item?.targetName),
description: clean(item?.description),
snapshotAt: new Date().toISOString(),
},
}))
.filter((item: any) => item.targetName)
: []
const employeeTargets =
targetEmployeeIds.length > 0
? await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
AND: [
await getPerformanceSurveyAccountProfileWhere(),
{ id: { in: targetEmployeeIds } },
],
},
})
: []
const targets = employeeTargets.map((employee) => ({
targetType: 'employee',
targetName: getEmployeeDisplayName(employee),
employee,
snapshot: getEmployeeSnapshot(employee),
}))
if (surveyKind === 'item_course') {
return [
...itemTargets,
...targets,
]
}
return targets.length > 0 ? targets : itemTargets
}
async function resolveSurveyEvaluators(body: any, ctx: UserContext) {
const evaluatorEmployeeIds = normalizeList(body?.evaluatorEmployeeIds)
const evaluatorDepartmentNames = normalizeList(body?.evaluatorDepartmentNames)
const or: any[] = []
if (evaluatorEmployeeIds.length > 0) {
or.push({ id: { in: evaluatorEmployeeIds } })
}
if (evaluatorDepartmentNames.length > 0) {
or.push(...getDepartmentSelectionWhere(evaluatorDepartmentNames))
}
if (or.length === 0 && ctx.employee) {
or.push({ id: ctx.employee.id })
}
const and: any[] = [
await getPerformanceSurveyAccountProfileWhere(),
{ OR: or },
]
const where: any = { AND: and }
const employees = or.length > 0
? await prisma.performanceSurveyEmployeeProfile.findMany({ where, orderBy: { displayName: 'asc' } })
: []
const dingtalkUserIds = employees.map((employee) => clean(employee.dingtalkUserId)).filter((item): item is string => Boolean(item))
const dingEmails = employees.map((employee) => clean(employee.dingEmail)).filter((item): item is string => Boolean(item))
const userLookupOr = [
...(dingtalkUserIds.length > 0 ? [{ dingtalkUserId: { in: dingtalkUserIds } }] : []),
...(dingEmails.length > 0 ? [{ email: { in: dingEmails } }] : []),
]
const users = userLookupOr.length > 0
? await prisma.user.findMany({
where: { OR: userLookupOr },
select: { id: true, dingtalkUserId: true, email: true },
})
: []
const userByDing = new Map(users.map((user) => [user.dingtalkUserId, user]).filter(([dingtalkUserId]) => Boolean(dingtalkUserId)) as Array<[string, (typeof users)[number]]>)
const userByEmail = new Map(users.map((user) => [user.email, user]))
const deduped = new Map<string, any>()
for (const employee of employees) {
const profileDingtalkUserId = clean(employee.dingtalkUserId)
const matchedUser = (profileDingtalkUserId ? userByDing.get(profileDingtalkUserId) || null : null)
|| (employee.dingEmail ? userByEmail.get(employee.dingEmail) || null : null)
const dingtalkUserId = profileDingtalkUserId || clean(matchedUser?.dingtalkUserId)
const userId = matchedUser?.id || null
const evaluatorKey = getEvaluatorKey({
employeeId: employee.id,
userId,
dingtalkUserId,
})
deduped.set(evaluatorKey, {
employee,
userId,
dingtalkUserId,
evaluatorKey,
name: getEmployeeDisplayName(employee),
snapshot: getEmployeeSnapshot(employee),
})
}
return Array.from(deduped.values())
}
function serializeEmployee(employee: any) {
return {
id: employee.id,
dingtalkUserId: employee.dingtalkUserId,
dingEmail: employee.dingEmail,
jobNumber: employee.jobNumber,
flowerName: employee.flowerName,
realName: employee.realName,
displayName: employee.displayName,
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
positionTitle: employee.positionTitle,
positionLevel: employee.positionLevel,
levelType: employee.levelType,
isLeadership: employee.isLeadership,
managerDingtalkUserId: employee.managerDingtalkUserId,
managerJobNumber: employee.managerJobNumber,
managerName: employee.managerName,
employmentStatus: employee.employmentStatus,
workPlace: employee.workPlace,
syncedAt: employee.syncedAt,
}
}
function readSnapshotText(snapshot: Record<string, unknown> | null | undefined, key: string) {
if (!snapshot || typeof snapshot !== 'object') return ''
return clean(snapshot[key]) || ''
}
function escapeCsv(value: string) {
const text = String(value ?? '')
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}