import { randomUUID } from 'node:crypto' import { prisma } from '@/lib/prisma' import { getActivePerformanceSurveyEmployeeProfileWhere } from '@/lib/performance-survey-report-sync' import { DEFAULT_PERFORMANCE_SURVEY_TEMPLATES, PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS, ensurePerformanceSurveyTemplates, getPerformanceSurveyTemplateVariant, type PerformanceSurveyTemplateShape, type PerformanceSurveyWorkflowDefinition, type PerformanceSurveyWorkflowReviewer, } from '@/lib/performance-survey-templates' export const PERFORMANCE_SURVEY_TEMPLATE_PAGE_SIZE = 4 export function paginatePerformanceSurveyTemplates(items: T[], requestedPage: unknown) { const parsedPage = Number.parseInt(String(requestedPage ?? '1'), 10) const pageCount = Math.max(1, Math.ceil(items.length / PERFORMANCE_SURVEY_TEMPLATE_PAGE_SIZE)) const page = Math.min(Math.max(Number.isFinite(parsedPage) ? parsedPage : 1, 1), pageCount) const startIndex = (page - 1) * PERFORMANCE_SURVEY_TEMPLATE_PAGE_SIZE return { items: items.slice(startIndex, startIndex + PERFORMANCE_SURVEY_TEMPLATE_PAGE_SIZE), pager: { page, pageSize: PERFORMANCE_SURVEY_TEMPLATE_PAGE_SIZE, totalPages: pageCount, totalItems: items.length, start: items.length === 0 ? 0 : startIndex + 1, end: Math.min(startIndex + PERFORMANCE_SURVEY_TEMPLATE_PAGE_SIZE, items.length), }, } } export const PERFORMANCE_SURVEY_POSITION_GROUP_SEEDS = [ { id: 'pspg-ai-planning-creative', code: 'ai_planning_creative', name: 'AI策划-创意部', sortOrder: 10 }, { id: 'pspg-ai-planning-general', code: 'ai_planning_general', name: 'AI策划-综合部', sortOrder: 20 }, { id: 'pspg-ai-technical-designer', code: 'ai_technical_designer', name: 'AI技术设计师', sortOrder: 30 }, { id: 'pspg-brand-designer', code: 'brand_designer', name: '品牌设计师', sortOrder: 40 }, { id: 'pspg-director', code: 'director', name: '编导', sortOrder: 50 }, { id: 'pspg-camera', code: 'camera', name: '摄像', sortOrder: 60 }, { id: 'pspg-editing', code: 'editing', name: '剪辑', sortOrder: 70 }, { id: 'pspg-retouching', code: 'retouching', name: '修图', sortOrder: 80 }, ] as const export type PerformanceSurveyPositionRuleInput = { departmentPathPrefix?: string | null positionTitle?: string | null } export type PerformanceSurveyEmployeeDepartmentMembershipInput = { departmentName?: string | null departmentPath?: string | null isPrimary?: boolean | null } export type PerformanceSurveyPositionEmployeeInput = { id?: string | null departmentPath?: string | null departmentName?: string | null positionTitle?: string | null departmentMemberships?: PerformanceSurveyEmployeeDepartmentMembershipInput[] | null } export type PerformanceSurveyPositionMembership = { status: 'matched' | 'unmatched' | 'conflict' | 'excluded' group: PositionGroupRuntime | null matches: PositionGroupRuntime[] source: 'automatic' | 'manual' manualAssignmentId: string | null } export type PositionGroupRuntime = { id: string code: string name: string sortOrder: number isActive: boolean isSystem: boolean archivedAt: Date | null status: 'ready' | 'pending' | 'disabled' planId: string | null planVersion: number | null effectiveMonth: string | null rules: PerformanceSurveyPositionRuleInput[] templates: Array } export function getAvailablePerformanceSurveyPositionTemplates( group: Pick, ) { return group.templates.filter((template) => !template.templateArchivedAt) } type PositionGroupPlanConfiguration = { name: string sortOrder: number isActive: boolean } type ManualPositionAssignmentRuntime = { id: string assignmentType: string positionGroupId?: string | null } type AuditInput = { action: string targetType: string targetId?: string | null actorUserId: string actorName: string reason?: string | null beforeSnapshot?: unknown afterSnapshot?: unknown metadata?: unknown } export class PerformanceSurveyPositionConfigError extends Error { status: number constructor(message: string, status = 400) { super(message) this.name = 'PerformanceSurveyPositionConfigError' this.status = status } } function clean(value: unknown) { const text = String(value ?? '').trim() return text || null } function toAuditJson(value: unknown) { if (value === undefined || value === null) return undefined return JSON.parse(JSON.stringify(value)) } async function writePerformanceSurveyConfigAuditLog(db: any, input: AuditInput) { return db.performanceSurveyConfigAuditLog.create({ data: { action: input.action, targetType: input.targetType, targetId: input.targetId || null, actorUserId: input.actorUserId, actorName: input.actorName, reason: clean(input.reason), beforeSnapshot: toAuditJson(input.beforeSnapshot), afterSnapshot: toAuditJson(input.afterSnapshot), metadata: toAuditJson(input.metadata), }, }) } function normalizePositionGroupCode(value: unknown) { return String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') } export function createPerformanceSurveyPositionGroupIdentity() { const id = randomUUID() return { id, code: `position_${id.replace(/-/g, '')}`, } } function readPositionGroupPlanConfiguration( value: unknown, fallback: { name: string; sortOrder: number; isActive: boolean }, ): PositionGroupPlanConfiguration { const record = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} return { name: clean(record.name) || fallback.name, sortOrder: Number.isFinite(Number(record.sortOrder)) ? Number(record.sortOrder) : fallback.sortOrder, isActive: typeof record.isActive === 'boolean' ? record.isActive : fallback.isActive, } } export function performanceSurveyMonthRangesOverlap( left: { startMonth: string; endMonth?: string | null }, right: { startMonth: string; endMonth?: string | null }, ) { const leftEnd = left.endMonth || '9999-12' const rightEnd = right.endMonth || '9999-12' return left.startMonth <= rightEnd && right.startMonth <= leftEnd } function normalizeMatchText(value: unknown) { return String(value ?? '').trim().toLocaleLowerCase('zh-CN') } function normalizeDepartmentPath(value: unknown) { return normalizeMatchText(value) .replace(/[\\>||]+/g, '/') .split('/') .map((part) => part.trim()) .filter(Boolean) .join('/') } export function normalizePerformanceSurveyAssessmentMonth(value: unknown) { const month = String(value ?? '').trim() return /^\d{4}-(0[1-9]|1[0-2])$/.test(month) ? month : null } function getCurrentAssessmentMonth() { const now = new Date() return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` } function getPerformanceSurveyBusinessMonth(date: Date) { const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', }).formatToParts(date) const year = parts.find((part) => part.type === 'year')?.value const month = parts.find((part) => part.type === 'month')?.value return year && month ? `${year}-${month.padStart(2, '0')}` : date.toISOString().slice(0, 7) } export function matchesPerformanceSurveyPositionRule( employee: PerformanceSurveyPositionEmployeeInput, rule: PerformanceSurveyPositionRuleInput, ) { const departmentPrefix = normalizeDepartmentPath(rule.departmentPathPrefix) const positionTitle = normalizeMatchText(rule.positionTitle) if (!departmentPrefix && !positionTitle) return false const employeeDepartmentPaths = Array.from(new Set([ normalizeDepartmentPath(employee.departmentPath || employee.departmentName), ...(employee.departmentMemberships || []).map((membership) => ( normalizeDepartmentPath(membership.departmentPath || membership.departmentName) )), ].filter((path): path is string => Boolean(path)))) const employeePositionTitle = normalizeMatchText(employee.positionTitle) const departmentMatches = !departmentPrefix || employeeDepartmentPaths.some((employeeDepartmentPath) => ( employeeDepartmentPath === departmentPrefix || employeeDepartmentPath.startsWith(`${departmentPrefix}/`) )) const positionMatches = !positionTitle || employeePositionTitle === positionTitle return departmentMatches && positionMatches } export function resolvePerformanceSurveyPositionMembership( employee: PerformanceSurveyPositionEmployeeInput, groups: PositionGroupRuntime[], manualAssignment?: ManualPositionAssignmentRuntime | null, ): PerformanceSurveyPositionMembership { if (manualAssignment?.assignmentType === 'excluded') { return { status: 'excluded', group: null, matches: [], source: 'manual', manualAssignmentId: manualAssignment.id, } } if (manualAssignment?.assignmentType === 'position') { const manualGroup = groups.find((group) => group.id === manualAssignment.positionGroupId) || null return { status: manualGroup && manualGroup.status !== 'disabled' ? 'matched' : 'unmatched', group: manualGroup && manualGroup.status !== 'disabled' ? manualGroup : null, matches: manualGroup ? [manualGroup] : [], source: 'manual', manualAssignmentId: manualAssignment.id, } } const matches = groups.filter((group) => ( group.isActive && group.rules.length > 0 && group.rules.some((rule) => matchesPerformanceSurveyPositionRule(employee, rule)) )) return { status: matches.length === 1 ? 'matched' : matches.length === 0 ? 'unmatched' : 'conflict', group: matches.length === 1 ? matches[0] : null, matches, source: 'automatic', manualAssignmentId: null, } } function readRules(value: unknown): PerformanceSurveyPositionRuleInput[] { if (!Array.isArray(value)) return [] return value .map((item) => { const record = item && typeof item === 'object' && !Array.isArray(item) ? item as Record : {} return { departmentPathPrefix: clean(record.departmentPathPrefix), positionTitle: clean(record.positionTitle), } }) .filter((rule) => Boolean(rule.departmentPathPrefix || rule.positionTitle)) } function readTemplateDefinition(value: unknown): PerformanceSurveyTemplateShape { return value as PerformanceSurveyTemplateShape } function validateWorkflow(workflow: PerformanceSurveyWorkflowDefinition | undefined) { if (!workflow || !Array.isArray(workflow.stages) || workflow.stages.length === 0) { throw new PerformanceSurveyPositionConfigError('模板至少需要 1 个评审阶段') } const stageKeys = new Set() const stageOrders = new Set() for (const stage of workflow.stages) { const key = clean(stage.key) if (!key) throw new PerformanceSurveyPositionConfigError('评审阶段缺少标识') if (stageKeys.has(key)) throw new PerformanceSurveyPositionConfigError(`评审阶段标识重复:${key}`) stageKeys.add(key) if (!Number.isInteger(stage.order) || stage.order < 0) { throw new PerformanceSurveyPositionConfigError(`评审阶段「${stage.label || key}」顺序无效`) } if (stageOrders.has(stage.order)) throw new PerformanceSurveyPositionConfigError(`评审阶段顺序重复:${stage.order}`) stageOrders.add(stage.order) if (!clean(stage.label)) throw new PerformanceSurveyPositionConfigError(`评审阶段 ${key} 缺少名称`) if (!Array.isArray(stage.reviewers) || stage.reviewers.length === 0) { throw new PerformanceSurveyPositionConfigError(`评审阶段「${stage.label}」至少需要 1 类评分人`) } for (const reviewer of stage.reviewers) { if (!['self', 'direct_manager', 'fixed_employee', 'manual_selection'].includes(reviewer.source)) { throw new PerformanceSurveyPositionConfigError(`评审阶段「${stage.label}」包含无效评分人来源`) } if (reviewer.source === 'fixed_employee' && !clean(reviewer.employeeId)) { throw new PerformanceSurveyPositionConfigError(`评审阶段「${stage.label}」的固定评分人尚未选择`) } } } const scoring = workflow.scoring if (!scoring || !['final_reviewer', 'average_reviewers'].includes(scoring.mode)) { throw new PerformanceSurveyPositionConfigError('模板缺少有效计分规则') } if (scoring.mode === 'final_reviewer') { const stage = workflow.stages.find((item) => item.key === scoring.stageKey) if (!stage) throw new PerformanceSurveyPositionConfigError('终审计分阶段不存在') const scoringReviewers = stage.reviewers.filter((reviewer) => reviewer.source === scoring.reviewerSource) if (scoringReviewers.length === 0) { throw new PerformanceSurveyPositionConfigError('终审计分人员不属于所选阶段') } if (scoringReviewers.length > 1) throw new PerformanceSurveyPositionConfigError('终审计分人员必须唯一') } else { const invalidStage = (scoring.stageKeys || []).find((key) => !stageKeys.has(key)) if (invalidStage) throw new PerformanceSurveyPositionConfigError(`平均计分阶段不存在:${invalidStage}`) } } export function validatePerformanceSurveyTemplateDefinition(definition: PerformanceSurveyTemplateShape) { if (!clean(definition.code)) throw new PerformanceSurveyPositionConfigError('模板代码不能为空') if (!clean(definition.name)) throw new PerformanceSurveyPositionConfigError('模板名称不能为空') if (!['person_period', 'visual_case', 'item_course'].includes(definition.surveyType)) { throw new PerformanceSurveyPositionConfigError('模板类型无效') } if (!Array.isArray(definition.questions) || definition.questions.length === 0) { throw new PerformanceSurveyPositionConfigError('模板至少需要 1 个评分项') } const questionIds = new Set() let allocatedTotal = 0 for (const question of definition.questions) { const id = clean(question.id) if (!id || !clean(question.title)) throw new PerformanceSurveyPositionConfigError('评分项缺少名称或标识') if (questionIds.has(id)) throw new PerformanceSurveyPositionConfigError(`评分项标识重复:${id}`) questionIds.add(id) const maxScore = Number(question.maxScore ?? definition.questionMaxScore ?? 20) if (!Number.isFinite(maxScore) || maxScore <= 0) throw new PerformanceSurveyPositionConfigError(`评分项「${question.title}」满分无效`) allocatedTotal += maxScore } const requiredWorkItemCount = definition.surveyType === 'visual_case' ? Number(definition.requiredWorkItemCount || 0) : 1 if (definition.surveyType === 'visual_case' && (!Number.isInteger(requiredWorkItemCount) || requiredWorkItemCount < 1)) { throw new PerformanceSurveyPositionConfigError('视觉案例模板必须配置作品数量') } const calculatedTotal = allocatedTotal * requiredWorkItemCount const maxTotal = Number(definition.maxTotal) if (!Number.isFinite(maxTotal) || maxTotal <= 0) { throw new PerformanceSurveyPositionConfigError('模板总分必须大于 0') } if (Math.round(calculatedTotal * 100) !== Math.round(maxTotal * 100)) { const calculationLabel = definition.surveyType === 'visual_case' ? `每个作品 ${allocatedTotal} 分 × ${requiredWorkItemCount} 个作品 = ${calculatedTotal} 分` : `评分项合计 ${allocatedTotal} 分` throw new PerformanceSurveyPositionConfigError(`${calculationLabel},与模板总分 ${maxTotal} 分不一致`) } validateWorkflow(definition.workflow) } function withResolvedFixedReviewers( definition: PerformanceSurveyTemplateShape, fixedEmployee: { id: string; displayName: string } | null, ) { if (!definition.workflow) return definition return { ...definition, workflow: { ...definition.workflow, stages: definition.workflow.stages.map((stage) => ({ ...stage, reviewers: stage.reviewers.map((reviewer) => ( reviewer.source === 'fixed_employee' && fixedEmployee ? { ...reviewer, employeeId: fixedEmployee.id, employeeName: fixedEmployee.displayName } : reviewer )), })), }, } } function buildPlaceholderDefinition(code: string, name: string): PerformanceSurveyTemplateShape { return { code, name, shortName: name, description: '待管理员补充评分项、评审流程和计分规则。', surveyType: 'person_period', weight: 1, maxTotal: 120, questions: [], scoreBands: [], workflow: { stages: [{ key: 'review', label: '评价', order: 0, reviewers: [{ source: 'manual_selection', label: '评价人' }], }], scoring: { mode: 'average_reviewers', stageKeys: ['review'] }, }, } } async function ensureTemplateVersionSeed(input: { definition: PerformanceSurveyTemplateShape status: 'draft' | 'published' effectiveMonth: string | null }) { const definition = input.definition const template = await prisma.performanceSurveyTemplate.upsert({ where: { code: definition.code }, update: {}, create: { code: definition.code, name: definition.name, description: definition.description || '', surveyType: definition.surveyType, weight: Number(definition.weight || 1), questions: definition.questions as any, scoreBands: definition.scoreBands as any, isActive: true, }, }) return prisma.performanceSurveyTemplateVersion.upsert({ where: { templateId_version: { templateId: template.id, version: 1 } }, update: {}, create: { templateId: template.id, version: 1, status: input.status, effectiveMonth: input.status === 'published' ? input.effectiveMonth : null, definition: definition as any, publishedAt: input.status === 'published' ? new Date() : null, createdByName: '系统初始化', }, include: { template: true }, }) } async function ensurePositionPlanSeed(input: { groupCode: string templateVersionIds: string[] status: 'draft' | 'published' effectiveMonth: string }) { const group = await prisma.performanceSurveyPositionGroup.findUnique({ where: { code: input.groupCode } }) if (!group) return const existing = await prisma.performanceSurveyPositionPlan.findUnique({ where: { positionGroupId_version: { positionGroupId: group.id, version: 1 } }, }) if (existing) return await prisma.performanceSurveyPositionPlan.create({ data: { positionGroupId: group.id, version: 1, status: input.status, effectiveMonth: input.status === 'published' ? input.effectiveMonth : null, matchRules: [], groupConfig: { name: group.name, sortOrder: group.sortOrder, isActive: group.isActive, }, publishedAt: input.status === 'published' ? new Date() : null, createdByName: '系统初始化', items: { create: input.templateVersionIds.map((templateVersionId, index) => ({ templateVersionId, sortOrder: index, isEnabled: true, })), }, }, }) } export async function seedPerformanceSurveyPositionConfiguration() { await ensurePerformanceSurveyTemplates() for (const seed of PERFORMANCE_SURVEY_POSITION_GROUP_SEEDS) { await prisma.performanceSurveyPositionGroup.upsert({ where: { code: seed.code }, update: { isSystem: true }, create: { ...seed, isActive: true, isSystem: true }, }) } const fixedEmployees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { OR: [ { displayName: '元泽' }, { flowerName: '元泽' }, { realName: '元泽' }, ], }, select: { id: true, displayName: true }, take: 2, }) const fixedEmployee = fixedEmployees.length === 1 ? fixedEmployees[0] : null const initialMonth = getCurrentAssessmentMonth() const defaults = new Map(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.map((item) => [item.code, item])) const versionByCode = new Map() for (const template of DEFAULT_PERFORMANCE_SURVEY_TEMPLATES) { const resolved = withResolvedFixedReviewers(template, fixedEmployee) const hasUnresolvedFixedReviewer = resolved.workflow?.stages.some((stage) => ( stage.reviewers.some((reviewer) => reviewer.source === 'fixed_employee' && !reviewer.employeeId) )) const version = await ensureTemplateVersionSeed({ definition: resolved, status: hasUnresolvedFixedReviewer ? 'draft' : 'published', effectiveMonth: initialMonth, }) versionByCode.set(template.code, version) } const visual = defaults.get('visual_brand_tone') if (visual) { const withoutCopy = withResolvedFixedReviewers({ ...getPerformanceSurveyTemplateVariant(visual, PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS.WITHOUT_COPY), code: 'visual_brand_tone_without_copy', name: '品牌调性视觉评分(不含文案)', shortName: '视觉评分(无文案)', templateOptionKey: 'visual_brand_tone_without_copy', templateVariant: undefined, templateVariantName: undefined, }, fixedEmployee) const hasUnresolvedFixedReviewer = withoutCopy.workflow?.stages.some((stage) => ( stage.reviewers.some((reviewer) => reviewer.source === 'fixed_employee' && !reviewer.employeeId) )) const version = await ensureTemplateVersionSeed({ definition: withoutCopy, status: hasUnresolvedFixedReviewer ? 'draft' : 'published', effectiveMonth: initialMonth, }) versionByCode.set(withoutCopy.code, version) } for (const placeholder of [ buildPlaceholderDefinition('ai_work_review', 'AI作品评审'), buildPlaceholderDefinition('project_result_validation', '项目成果验证'), ]) { const version = await ensureTemplateVersionSeed({ definition: placeholder, status: 'draft', effectiveMonth: null }) versionByCode.set(placeholder.code, version) } const ids = (codes: string[]) => codes .map((code) => versionByCode.get(code)?.id) .filter((id): id is string => Boolean(id)) const statuses = (codes: string[]) => codes.map((code) => versionByCode.get(code)?.status) const knownPlans = [ { groupCode: 'ai_planning_creative', codes: ['visual_brand_tone', 'cross_department', 'work_cooperation', 'course_share'], }, { groupCode: 'ai_planning_general', codes: ['visual_brand_tone_without_copy', 'cross_department', 'work_cooperation', 'course_share'], }, { groupCode: 'brand_designer', codes: ['visual_brand_tone', 'cross_department', 'work_cooperation', 'course_share'], }, ] for (const plan of knownPlans) { const allPublished = statuses(plan.codes).every((status) => status === 'published') await ensurePositionPlanSeed({ groupCode: plan.groupCode, templateVersionIds: ids(plan.codes), status: allPublished ? 'published' : 'draft', effectiveMonth: initialMonth, }) } await ensurePositionPlanSeed({ groupCode: 'ai_technical_designer', templateVersionIds: ids(['ai_work_review', 'project_result_validation', 'work_cooperation', 'course_share']), status: 'draft', effectiveMonth: initialMonth, }) } function chooseEffectivePlan( plans: T[], month: string, ) { return plans .filter((plan) => plan.status === 'published' && plan.effectiveMonth && plan.effectiveMonth <= month) .sort((left, right) => ( String(right.effectiveMonth).localeCompare(String(left.effectiveMonth)) || right.version - left.version ))[0] || null } export async function listPerformanceSurveyPositionGroups( monthInput?: string | null, options?: { includeArchived?: boolean }, ): Promise { const month = normalizePerformanceSurveyAssessmentMonth(monthInput) || getCurrentAssessmentMonth() const groups = await prisma.performanceSurveyPositionGroup.findMany({ where: options?.includeArchived ? undefined : { archivedAt: null }, orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], include: { plans: { include: { items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' }, include: { templateVersion: { include: { template: true } } }, }, }, }, }, }) return groups.map((group) => { const plan = chooseEffectivePlan(group.plans, month) const configuration = readPositionGroupPlanConfiguration(plan?.groupConfig, group) const templates = (plan?.items || []) .filter((item) => item.templateVersion.status === 'published') .map((item) => ({ ...readTemplateDefinition(item.templateVersion.definition), templateArchivedAt: item.templateVersion.template.archivedAt, templateVersionId: item.templateVersion.id, templateVersion: item.templateVersion.version, templateVersionStatus: item.templateVersion.status as 'draft' | 'published' | 'archived', effectiveMonth: item.templateVersion.effectiveMonth, })) const rules = readRules(plan?.matchRules) const archivedMonth = group.archivedAt ? getPerformanceSurveyBusinessMonth(group.archivedAt) : null const wasActiveInRequestedMonth = !archivedMonth || month < archivedMonth const isActive = Boolean(configuration.isActive && wasActiveInRequestedMonth) const ready = Boolean(plan && templates.length > 0) return { id: group.id, code: group.code, name: configuration.name, sortOrder: configuration.sortOrder, isActive, isSystem: group.isSystem, archivedAt: group.archivedAt, status: !isActive ? 'disabled' : ready ? 'ready' : 'pending', planId: plan?.id || null, planVersion: plan?.version || null, effectiveMonth: plan?.effectiveMonth || null, rules, templates, } }) } async function listEffectiveManualPositionAssignments(month: string, employeeProfileIds?: string[]) { return prisma.performanceSurveyEmployeePositionAssignment.findMany({ where: { status: 'active', startMonth: { lte: month }, OR: [{ endMonth: null }, { endMonth: { gte: month } }], ...(employeeProfileIds ? { employeeProfileId: { in: employeeProfileIds } } : {}), }, orderBy: { createdAt: 'desc' }, }) } function mapEffectiveManualPositionAssignments(assignments: T[]) { const byEmployeeId = new Map() for (const assignment of assignments) { if (!byEmployeeId.has(assignment.employeeProfileId)) { byEmployeeId.set(assignment.employeeProfileId, assignment) } else { // 兼容迁移前或人工写库造成的重叠脏数据:重复有效分配不静默选中。 byEmployeeId.set(assignment.employeeProfileId, null) } } return byEmployeeId } export async function getPerformanceSurveyPositionGroupForEmployee( employee: PerformanceSurveyPositionEmployeeInput, month?: string | null, options?: { includeArchived?: boolean }, ) { const normalizedMonth = normalizePerformanceSurveyAssessmentMonth(month) || getCurrentAssessmentMonth() const [groups, assignments, departmentMemberships] = await Promise.all([ listPerformanceSurveyPositionGroups(normalizedMonth, { includeArchived: options?.includeArchived }), employee.id ? listEffectiveManualPositionAssignments(normalizedMonth, [employee.id]) : Promise.resolve([]), employee.id ? prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ where: { employeeProfileId: employee.id }, orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], select: { departmentName: true, departmentPath: true, isPrimary: true, }, }) : Promise.resolve(employee.departmentMemberships || []), ]) if (assignments.length > 1) { return { status: 'conflict' as const, group: null, matches: [], source: 'manual' as const, manualAssignmentId: null, } } return resolvePerformanceSurveyPositionMembership( { ...employee, departmentMemberships }, groups, assignments[0] || null, ) } export async function getPerformanceSurveyPositionMembershipsForEmployees< T extends PerformanceSurveyPositionEmployeeInput & { id: string }, >(employees: T[], month?: string | null) { const normalizedMonth = normalizePerformanceSurveyAssessmentMonth(month) || getCurrentAssessmentMonth() const employeeIds = employees.map((employee) => employee.id) const hasLoadedDepartmentMemberships = employees.every((employee) => ( Array.isArray(employee.departmentMemberships) )) const [groups, assignments, departmentMemberships] = await Promise.all([ listPerformanceSurveyPositionGroups(normalizedMonth), listEffectiveManualPositionAssignments(normalizedMonth, employeeIds), hasLoadedDepartmentMemberships ? Promise.resolve(employees.flatMap((employee) => ( (employee.departmentMemberships || []).map((membership) => ({ employeeProfileId: employee.id, ...membership, })) ))) : employeeIds.length > 0 ? prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ where: { employeeProfileId: { in: employeeIds } }, orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], select: { employeeProfileId: true, departmentName: true, departmentPath: true, isPrimary: true, }, }) : Promise.resolve([]), ]) const departmentsByEmployeeId = new Map() for (const membership of departmentMemberships) { const existing = departmentsByEmployeeId.get(membership.employeeProfileId) || [] existing.push(membership) departmentsByEmployeeId.set(membership.employeeProfileId, existing) } const assignmentByEmployeeId = mapEffectiveManualPositionAssignments(assignments) const assignmentCountByEmployeeId = new Map() for (const assignment of assignments) { assignmentCountByEmployeeId.set( assignment.employeeProfileId, (assignmentCountByEmployeeId.get(assignment.employeeProfileId) || 0) + 1, ) } return new Map(employees.map((employee) => [ employee.id, (assignmentCountByEmployeeId.get(employee.id) || 0) > 1 ? { status: 'conflict' as const, group: null, matches: [], source: 'manual' as const, manualAssignmentId: null, } : resolvePerformanceSurveyPositionMembership( { ...employee, departmentMemberships: departmentsByEmployeeId.get(employee.id) || employee.departmentMemberships || [], }, groups, assignmentByEmployeeId.get(employee.id) || null, ), ])) } export async function getPerformanceSurveyPositionGroupByCode( code: string, month?: string | null, options?: { includeArchived?: boolean }, ) { const groups = await listPerformanceSurveyPositionGroups(month, { includeArchived: options?.includeArchived }) const group = groups.find((item) => item.code === code) if (!group) throw new PerformanceSurveyPositionConfigError('考核岗位组不存在', 404) return group } export type PerformanceSurveyPositionAdminConfigScope = | 'all' | 'overview' | 'positions' | 'people' | 'templates' function normalizePerformanceSurveyPositionAdminConfigScope( value?: string | null, ): PerformanceSurveyPositionAdminConfigScope { return ['overview', 'positions', 'people', 'templates'].includes(String(value || '')) ? value as PerformanceSurveyPositionAdminConfigScope : 'all' } function serializePerformanceSurveyAdminTemplates(templates: any[]) { return templates.map((template) => ({ id: template.id, code: template.code, name: template.name, description: template.description || '', surveyType: template.surveyType, isActive: template.isActive, archivedAt: template.archivedAt, archiveReason: template.archiveReason, versions: template.versions.map((version: any) => ({ id: version.id, version: version.version, status: version.status, effectiveMonth: version.effectiveMonth, definition: version.definition, publishedAt: version.publishedAt, createdByUserId: version.createdByUserId, createdByName: version.createdByName, createdAt: version.createdAt, updatedAt: version.updatedAt, })), })) } function buildPerformanceSurveyPositionRuntimeGroups( groups: any[], month: string, ): PositionGroupRuntime[] { return groups.map((group) => { const plan = chooseEffectivePlan(group.plans, month) const configuration = readPositionGroupPlanConfiguration(plan?.groupConfig, group) const templates = (plan?.items || []) .filter((item: any) => item.isEnabled && item.templateVersion.status === 'published') .map((item: any) => ({ ...readTemplateDefinition(item.templateVersion.definition), templateArchivedAt: item.templateVersion.template.archivedAt, templateVersionId: item.templateVersion.id, templateVersion: item.templateVersion.version, templateVersionStatus: item.templateVersion.status as 'draft' | 'published' | 'archived', effectiveMonth: item.templateVersion.effectiveMonth, })) const rules = readRules(plan?.matchRules) const archivedMonth = group.archivedAt ? getPerformanceSurveyBusinessMonth(group.archivedAt) : null const wasActiveInRequestedMonth = !archivedMonth || month < archivedMonth const isActive = Boolean(configuration.isActive && wasActiveInRequestedMonth) return { id: group.id, code: group.code, name: configuration.name, sortOrder: configuration.sortOrder, isActive, isSystem: group.isSystem, archivedAt: group.archivedAt, status: !isActive ? 'disabled' : plan && templates.length > 0 ? 'ready' : 'pending', planId: plan?.id || null, planVersion: plan?.version || null, effectiveMonth: plan?.effectiveMonth || null, rules, templates, } }) } export async function getPerformanceSurveyPositionAdminConfig( monthInput?: string | null, scopeInput?: string | null, ) { const month = normalizePerformanceSurveyAssessmentMonth(monthInput) || getCurrentAssessmentMonth() const scope = normalizePerformanceSurveyPositionAdminConfigScope(scopeInput) if (scope === 'templates') { const templates = await prisma.performanceSurveyTemplate.findMany({ where: { archivedAt: null }, orderBy: [{ createdAt: 'asc' }], include: { versions: { orderBy: { version: 'desc' } } }, }) return { month, groups: [], templates: serializePerformanceSurveyAdminTemplates(templates), issueCounts: { unmatched: 0, conflicts: 0, excluded: 0 }, issues: { unmatched: [], conflicts: [], excluded: [] }, } } if (scope === 'people') { const runtimeGroups = await listPerformanceSurveyPositionGroups(month) return { month, groups: runtimeGroups.map((group) => ({ id: group.id, code: group.code, name: group.name, sortOrder: group.sortOrder, isActive: group.isActive, status: group.status, memberCount: 0, rules: [], effectivePlan: null, draftPlan: null, })), templates: [], issueCounts: { unmatched: 0, conflicts: 0, excluded: 0 }, issues: { unmatched: [], conflicts: [], excluded: [] }, } } const [groups, templates, employees] = await Promise.all([ prisma.performanceSurveyPositionGroup.findMany({ where: { archivedAt: null }, orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], include: { rules: { orderBy: { sortOrder: 'asc' } }, plans: { orderBy: { version: 'desc' }, include: { items: { orderBy: { sortOrder: 'asc' }, include: { templateVersion: { include: { template: true } } }, }, }, }, _count: { select: { surveys: true, manualAssignments: true } }, }, }), prisma.performanceSurveyTemplate.findMany({ where: { archivedAt: null }, orderBy: [{ createdAt: 'asc' }], include: { versions: { orderBy: { version: 'desc' } } }, }), prisma.performanceSurveyEmployeeProfile.findMany({ where: getActivePerformanceSurveyEmployeeProfileWhere(), orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], include: { departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], }, }, }), ]) const runtimeGroups = buildPerformanceSurveyPositionRuntimeGroups(groups, month) const manualAssignments = await listEffectiveManualPositionAssignments(month, employees.map((employee) => employee.id)) const manualAssignmentByEmployeeId = mapEffectiveManualPositionAssignments(manualAssignments) const manualAssignmentCountByEmployeeId = new Map() for (const assignment of manualAssignments) { manualAssignmentCountByEmployeeId.set( assignment.employeeProfileId, (manualAssignmentCountByEmployeeId.get(assignment.employeeProfileId) || 0) + 1, ) } const runtimeById = new Map(runtimeGroups.map((group) => [group.id, group])) const previewGroups: PositionGroupRuntime[] = groups.map((group) => { const runtime = runtimeById.get(group.id)! const draft = group.plans.find((plan) => plan.status === 'draft') || null if (!draft) return runtime const configuration = readPositionGroupPlanConfiguration(draft.groupConfig, runtime) const rules = readRules(draft.matchRules) const draftTemplates = draft.items .filter((item) => item.isEnabled && !item.templateVersion.template.archivedAt && item.templateVersion.status === 'published') .map((item) => ({ ...readTemplateDefinition(item.templateVersion.definition), templateVersionId: item.templateVersion.id, templateVersion: item.templateVersion.version, templateVersionStatus: item.templateVersion.status as 'draft' | 'published' | 'archived', effectiveMonth: item.templateVersion.effectiveMonth, })) const isActive = Boolean(configuration.isActive && !group.archivedAt) return { ...runtime, name: configuration.name, sortOrder: configuration.sortOrder, isActive, status: !isActive ? 'disabled' : draftTemplates.length > 0 ? 'ready' : 'pending', planId: draft.id, planVersion: draft.version, effectiveMonth: draft.effectiveMonth, rules, templates: draftTemplates, } }) const memberships = employees.map((employee) => ({ employee, membership: manualAssignmentCountByEmployeeId.get(employee.id)! > 1 ? { status: 'conflict' as const, group: null, matches: [], source: 'manual' as const, manualAssignmentId: null, } : resolvePerformanceSurveyPositionMembership( employee, previewGroups, manualAssignmentByEmployeeId.get(employee.id) || null, ), })) const memberCountByGroup = new Map() const membersByGroup = new Map[]>() for (const item of memberships) { if (item.membership.group) { const groupId = item.membership.group.id memberCountByGroup.set(groupId, (memberCountByGroup.get(groupId) || 0) + 1) membersByGroup.set(groupId, [ ...(membersByGroup.get(groupId) || []), serializePositionEmployee(item.employee), ]) } } const unmatched = memberships .filter((item) => item.membership.status === 'unmatched') .map((item) => serializePositionEmployee(item.employee)) const conflicts = memberships .filter((item) => item.membership.status === 'conflict') .map((item) => ({ ...serializePositionEmployee(item.employee), groupNames: item.membership.matches.map((group) => group.name), source: item.membership.source, })) const excluded = memberships .filter((item) => item.membership.status === 'excluded') .map((item) => ({ ...serializePositionEmployee(item.employee), manualAssignmentId: item.membership.manualAssignmentId, })) const includeIssueDetails = scope === 'all' return { month, groups: groups.map((group) => { const runtime = runtimeById.get(group.id)! const draft = group.plans.find((plan) => plan.status === 'draft') || null const editableConfiguration = readPositionGroupPlanConfiguration(draft?.groupConfig, runtime) const editableRules = draft ? readRules(draft.matchRules) : runtime.rules const hasPublishedHistory = group.plans.some((plan) => plan.status !== 'draft') return { id: group.id, code: group.code, name: editableConfiguration.name, sortOrder: editableConfiguration.sortOrder, isActive: editableConfiguration.isActive, isSystem: group.isSystem, archivedAt: group.archivedAt, archiveReason: group.archiveReason, status: runtime.status, memberCount: memberCountByGroup.get(group.id) || 0, members: membersByGroup.get(group.id) || [], canHardDelete: !group.isSystem && group._count.surveys === 0 && group._count.manualAssignments === 0 && !hasPublishedHistory, rules: editableRules.map((rule, index) => ({ id: `plan-rule-${index}`, departmentPathPrefix: rule.departmentPathPrefix, positionTitle: rule.positionTitle, isActive: true, sortOrder: index, })), effectivePlan: runtime.planId ? { id: runtime.planId, version: runtime.planVersion, effectiveMonth: runtime.effectiveMonth, groupConfig: { name: runtime.name, sortOrder: runtime.sortOrder, isActive: runtime.isActive, }, matchRules: runtime.rules, templateVersionIds: runtime.templates.map((template) => template.templateVersionId), } : null, draftPlan: draft ? { id: draft.id, version: draft.version, effectiveMonth: draft.effectiveMonth, groupConfig: readPositionGroupPlanConfiguration(draft.groupConfig, runtime), matchRules: readRules(draft.matchRules), templateVersionIds: draft.items.filter((item) => item.isEnabled).map((item) => item.templateVersionId), } : null, planHistory: group.plans.map((plan) => ({ id: plan.id, version: plan.version, status: plan.status, effectiveMonth: plan.effectiveMonth, groupConfig: readPositionGroupPlanConfiguration(plan.groupConfig, group), matchRules: readRules(plan.matchRules), templateVersions: plan.items.filter((item) => item.isEnabled).map((item) => ({ id: item.templateVersion.id, templateId: item.templateVersion.templateId, templateCode: item.templateVersion.template.code, templateName: item.templateVersion.template.name, version: item.templateVersion.version, status: item.templateVersion.status, effectiveMonth: item.templateVersion.effectiveMonth, })), createdByUserId: plan.createdByUserId, createdByName: plan.createdByName, createdAt: plan.createdAt, updatedAt: plan.updatedAt, publishedAt: plan.publishedAt, })), } }), templates: serializePerformanceSurveyAdminTemplates(templates), issueCounts: { unmatched: unmatched.length, conflicts: conflicts.length, excluded: excluded.length, }, issues: { unmatched: includeIssueDetails ? unmatched : [], conflicts: includeIssueDetails ? conflicts : [], excluded: includeIssueDetails ? excluded : [], }, } } function serializePositionEmployee(employee: any) { return { id: employee.id, displayName: employee.displayName, departmentName: employee.departmentName, departmentPath: employee.departmentPath, positionTitle: employee.positionTitle, } } function normalizeRuleInputs(value: unknown): PerformanceSurveyPositionRuleInput[] { const rules = readRules(value) const keys = new Set() for (const rule of rules) { const key = `${normalizeDepartmentPath(rule.departmentPathPrefix)}|${normalizeMatchText(rule.positionTitle)}` if (keys.has(key)) throw new PerformanceSurveyPositionConfigError('岗位匹配规则不能重复') keys.add(key) } return rules } export function validatePerformanceSurveyPositionPlanTemplateVersions( versions: Array<{ id: string templateId: string status: string effectiveMonth?: string | null template?: { archivedAt?: Date | string | null } }>, _effectiveMonth?: string | null, ) { const templateIds = new Set() for (const version of versions) { if (templateIds.has(version.templateId)) { throw new PerformanceSurveyPositionConfigError('同一岗位方案中,同一模板只能选择一个版本') } templateIds.add(version.templateId) if (version.status !== 'published') { throw new PerformanceSurveyPositionConfigError('岗位方案只能选择已发布模板版本') } if (version.template?.archivedAt) { throw new PerformanceSurveyPositionConfigError('岗位方案包含已归档模板') } } } async function lockPerformanceSurveyAdminScope(db: any, scope: string) { await db.$queryRawUnsafe( 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))::text AS locked', scope, ) } export function assertPerformanceSurveyPositionPlanPreviewMatches( draft: { id: string; version: number; updatedAt: Date | string }, expected: { draftPlanId?: string | null draftVersion?: number | string | null draftUpdatedAt?: Date | string | null }, ) { const expectedPlanId = clean(expected.draftPlanId) const expectedVersion = Number(expected.draftVersion) const expectedUpdatedAt = expected.draftUpdatedAt ? new Date(expected.draftUpdatedAt).getTime() : Number.NaN const actualUpdatedAt = new Date(draft.updatedAt).getTime() if ( !expectedPlanId || !Number.isInteger(expectedVersion) || !Number.isFinite(expectedUpdatedAt) || expectedPlanId !== draft.id || expectedVersion !== draft.version || expectedUpdatedAt !== actualUpdatedAt ) { throw new PerformanceSurveyPositionConfigError( '岗位方案草稿已发生变化,请重新执行发布前检查', 409, ) } } export async function savePerformanceSurveyPositionGroupDraft(input: { groupId: string name?: string sortOrder?: number isActive?: boolean rules?: unknown templateVersionIds?: unknown effectiveMonth?: string | null reason?: string | null actorUserId: string actorName: string }) { const group = await prisma.performanceSurveyPositionGroup.findUnique({ where: { id: input.groupId }, }) if (!group) throw new PerformanceSurveyPositionConfigError('考核岗位组不存在', 404) if (group.archivedAt) throw new PerformanceSurveyPositionConfigError('已归档岗位组不能继续编辑', 409) const rulesProvided = input.rules !== undefined const templateVersionIdsProvided = input.templateVersionIds !== undefined const rules = rulesProvided ? normalizeRuleInputs(input.rules) : null const requestedEffectiveMonth = clean(input.effectiveMonth) const effectiveMonth = requestedEffectiveMonth ? normalizePerformanceSurveyAssessmentMonth(requestedEffectiveMonth) : null if (requestedEffectiveMonth && !effectiveMonth) { throw new PerformanceSurveyPositionConfigError('计划生效月份格式无效') } const rawTemplateVersionIds = input.templateVersionIds if (templateVersionIdsProvided && !Array.isArray(rawTemplateVersionIds)) { throw new PerformanceSurveyPositionConfigError('岗位方案考核项格式无效') } const templateVersionIds: string[] | null = Array.isArray(rawTemplateVersionIds) ? Array.from(new Set(rawTemplateVersionIds.map((id: unknown) => clean(id)).filter((id): id is string => Boolean(id)))) : null const versions = templateVersionIds && templateVersionIds.length > 0 ? await prisma.performanceSurveyTemplateVersion.findMany({ where: { id: { in: templateVersionIds } }, include: { template: true }, }) : [] if (templateVersionIds && versions.length !== templateVersionIds.length) { throw new PerformanceSurveyPositionConfigError('岗位方案包含不存在的模板版本') } if (templateVersionIds) { validatePerformanceSurveyPositionPlanTemplateVersions(versions, effectiveMonth) } return prisma.$transaction(async (tx) => { await lockPerformanceSurveyAdminScope(tx, `performance-survey-position-group:${group.id}`) const currentGroup = await tx.performanceSurveyPositionGroup.findUnique({ where: { id: group.id }, }) if (!currentGroup) throw new PerformanceSurveyPositionConfigError('考核岗位组不存在', 404) if (currentGroup.archivedAt) { throw new PerformanceSurveyPositionConfigError('已归档岗位组不能继续编辑', 409) } let draft = await tx.performanceSurveyPositionPlan.findFirst({ where: { positionGroupId: currentGroup.id, status: 'draft' }, orderBy: { version: 'desc' }, include: { items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' } } }, }) const beforeSnapshot = draft if (!draft) { const [latest, effectivePlan] = await Promise.all([ tx.performanceSurveyPositionPlan.findFirst({ where: { positionGroupId: currentGroup.id }, orderBy: { version: 'desc' }, select: { version: true }, }), tx.performanceSurveyPositionPlan.findFirst({ where: { positionGroupId: currentGroup.id, status: 'published', ...(effectiveMonth ? { effectiveMonth: { lte: effectiveMonth } } : {}), }, orderBy: [{ effectiveMonth: 'desc' }, { version: 'desc' }], include: { items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' } } }, }), ]) const inheritedTemplateVersionIds = templateVersionIds ?? effectivePlan?.items.map((item) => item.templateVersionId) ?? [] const inheritedConfiguration = readPositionGroupPlanConfiguration(effectivePlan?.groupConfig, currentGroup) const configuration: PositionGroupPlanConfiguration = { name: clean(input.name) || inheritedConfiguration.name, sortOrder: Number.isFinite(Number(input.sortOrder)) ? Number(input.sortOrder) : inheritedConfiguration.sortOrder, isActive: typeof input.isActive === 'boolean' ? input.isActive : inheritedConfiguration.isActive, } draft = await tx.performanceSurveyPositionPlan.create({ data: { positionGroupId: currentGroup.id, version: Number(latest?.version || 0) + 1, status: 'draft', effectiveMonth, matchRules: (rules ?? readRules(effectivePlan?.matchRules)) as any, groupConfig: configuration as any, createdByUserId: input.actorUserId, createdByName: input.actorName, items: inheritedTemplateVersionIds.length > 0 ? { create: inheritedTemplateVersionIds.map((templateVersionId, index) => ({ templateVersionId, sortOrder: index, isEnabled: true, })), } : undefined, }, include: { items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' } } }, }) } else { const currentConfiguration = readPositionGroupPlanConfiguration(draft.groupConfig, currentGroup) draft = await tx.performanceSurveyPositionPlan.update({ where: { id: draft.id }, data: { ...(rulesProvided ? { matchRules: rules as any } : {}), ...(requestedEffectiveMonth ? { effectiveMonth } : {}), groupConfig: { name: clean(input.name) || currentConfiguration.name, sortOrder: Number.isFinite(Number(input.sortOrder)) ? Number(input.sortOrder) : currentConfiguration.sortOrder, isActive: typeof input.isActive === 'boolean' ? input.isActive : currentConfiguration.isActive, } as any, createdByUserId: input.actorUserId, createdByName: input.actorName, }, include: { items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' } } }, }) } if (templateVersionIds) { await tx.performanceSurveyPositionPlanItem.deleteMany({ where: { positionPlanId: draft.id } }) if (templateVersionIds.length > 0) { await tx.performanceSurveyPositionPlanItem.createMany({ data: templateVersionIds.map((templateVersionId, index) => ({ positionPlanId: draft!.id, templateVersionId, sortOrder: index, isEnabled: true, })), }) } } const afterSnapshot = await tx.performanceSurveyPositionPlan.findUnique({ where: { id: draft.id }, include: { items: { orderBy: { sortOrder: 'asc' } } }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_group.draft_saved', targetType: 'position_group', targetId: currentGroup.id, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, beforeSnapshot, afterSnapshot, }) return { groupId: currentGroup.id, draftPlanId: draft.id, version: draft.version, effectiveMonth: draft.effectiveMonth, } }) } async function assertPositionPlanHasNoConflicts( positionGroupId: string, month: string, rules: PerformanceSurveyPositionRuleInput[], configuration: PositionGroupPlanConfiguration, ) { const [groups, employees] = await Promise.all([ listPerformanceSurveyPositionGroups(month), prisma.performanceSurveyEmployeeProfile.findMany({ where: getActivePerformanceSurveyEmployeeProfileWhere(), include: { departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], }, }, }), ]) const manualAssignments = await listEffectiveManualPositionAssignments(month, employees.map((employee) => employee.id)) const manualAssignmentByEmployeeId = mapEffectiveManualPositionAssignments(manualAssignments) const manualAssignmentCounts = new Map() for (const assignment of manualAssignments) { manualAssignmentCounts.set( assignment.employeeProfileId, (manualAssignmentCounts.get(assignment.employeeProfileId) || 0) + 1, ) } const candidateGroups = groups.map((group) => ( group.id === positionGroupId ? { ...group, name: configuration.name, sortOrder: configuration.sortOrder, isActive: configuration.isActive, rules, status: configuration.isActive ? 'ready' as const : 'disabled' as const, } : group )) const conflicts = employees .map((employee) => ({ employee, membership: (manualAssignmentCounts.get(employee.id) || 0) > 1 ? { status: 'conflict' as const } : resolvePerformanceSurveyPositionMembership( employee, candidateGroups, manualAssignmentByEmployeeId.get(employee.id) || null, ), })) .filter((item) => item.membership.status === 'conflict') if (conflicts.length > 0) { throw new PerformanceSurveyPositionConfigError( `岗位规则存在 ${conflicts.length} 名重复匹配人员:${conflicts.slice(0, 5).map((item) => item.employee.displayName).join('、')}`, 409, ) } } export async function publishPerformanceSurveyPositionPlan(input: { groupId: string effectiveMonth?: string | null draftPlanId?: string | null draftVersion?: number | string | null draftUpdatedAt?: Date | string | null actorUserId: string actorName: string reason?: string | null }) { const effectiveMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth) || getCurrentAssessmentMonth() return prisma.$transaction(async (tx) => { await lockPerformanceSurveyAdminScope(tx, `performance-survey-position-group:${input.groupId}`) const draft = await tx.performanceSurveyPositionPlan.findFirst({ where: { positionGroupId: input.groupId, status: 'draft' }, orderBy: { version: 'desc' }, include: { positionGroup: true, items: { where: { isEnabled: true }, include: { templateVersion: { include: { template: true } } }, }, }, }) if (!draft) throw new PerformanceSurveyPositionConfigError('没有可发布的岗位方案草稿', 404) assertPerformanceSurveyPositionPlanPreviewMatches(draft, { draftPlanId: input.draftPlanId, draftVersion: input.draftVersion, draftUpdatedAt: input.draftUpdatedAt, }) if (draft.positionGroup.archivedAt) { throw new PerformanceSurveyPositionConfigError('已归档岗位组不能发布', 409) } const rules = readRules(draft.matchRules) const configuration = readPositionGroupPlanConfiguration(draft.groupConfig, draft.positionGroup) if (configuration.isActive && draft.items.length === 0) { throw new PerformanceSurveyPositionConfigError('岗位方案至少需要 1 个考核项') } validatePerformanceSurveyPositionPlanTemplateVersions( draft.items.map((item) => item.templateVersion), effectiveMonth, ) if (configuration.isActive) { await assertPositionPlanHasNoConflicts(input.groupId, effectiveMonth, rules, configuration) } const archivedPlans = await tx.performanceSurveyPositionPlan.findMany({ where: { positionGroupId: input.groupId, status: 'published', effectiveMonth, }, }) await tx.performanceSurveyPositionPlan.updateMany({ where: { positionGroupId: input.groupId, status: 'published', effectiveMonth, }, data: { status: 'archived' }, }) const publishResult = await tx.performanceSurveyPositionPlan.updateMany({ where: { id: draft.id, status: 'draft', updatedAt: draft.updatedAt, }, data: { status: 'published', effectiveMonth, publishedAt: new Date(), createdByUserId: input.actorUserId, createdByName: input.actorName, }, }) if (publishResult.count !== 1) { throw new PerformanceSurveyPositionConfigError( '岗位方案草稿已发生变化,请重新执行发布前检查', 409, ) } const published = await tx.performanceSurveyPositionPlan.findUnique({ where: { id: draft.id }, }) if (!published) throw new PerformanceSurveyPositionConfigError('岗位方案发布失败', 409) await tx.performanceSurveyPositionGroup.update({ where: { id: input.groupId }, data: { name: configuration.name, sortOrder: configuration.sortOrder, isActive: configuration.isActive, }, }) await tx.performanceSurveyPositionRule.deleteMany({ where: { positionGroupId: input.groupId } }) if (rules.length > 0) { await tx.performanceSurveyPositionRule.createMany({ data: rules.map((rule, index) => ({ positionGroupId: input.groupId, departmentPathPrefix: rule.departmentPathPrefix, positionTitle: rule.positionTitle, sortOrder: index, isActive: true, })), }) } await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_group.plan_published', targetType: 'position_group', targetId: input.groupId, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, beforeSnapshot: { draft, replacedPlans: archivedPlans, }, afterSnapshot: published, metadata: { effectiveMonth }, }) return published }) } function normalizeTemplateDefinitionInput(value: unknown): PerformanceSurveyTemplateShape { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new PerformanceSurveyPositionConfigError('模板定义格式无效') } const definition = value as PerformanceSurveyTemplateShape return { ...definition, code: String(definition.code || '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'), name: String(definition.name || '').trim(), shortName: String(definition.shortName || definition.name || '').trim(), description: String(definition.description || '').trim(), surveyType: String(definition.surveyType || 'person_period').trim(), weight: Number(definition.weight || 1), maxTotal: Number(definition.maxTotal || 0), questions: Array.isArray(definition.questions) ? definition.questions : [], scoreBands: Array.isArray(definition.scoreBands) ? definition.scoreBands : [], } } export async function savePerformanceSurveyTemplateDraft(input: { versionId?: string | null definition: unknown actorUserId: string actorName: string reason?: string | null }) { const definition = normalizeTemplateDefinitionInput(input.definition) if (!definition.code || !definition.name) throw new PerformanceSurveyPositionConfigError('模板名称不能为空') const existingVersion = input.versionId ? await prisma.performanceSurveyTemplateVersion.findUnique({ where: { id: input.versionId }, include: { template: true }, }) : null if (existingVersion?.status === 'archived') { throw new PerformanceSurveyPositionConfigError('已归档模板版本不能修改', 409) } if (existingVersion && existingVersion.template.code !== definition.code) { throw new PerformanceSurveyPositionConfigError('模板代码创建后不可在版本草稿中修改', 409) } if (existingVersion?.template.archivedAt) { throw new PerformanceSurveyPositionConfigError('已归档模板不能继续编辑', 409) } return prisma.$transaction(async (tx) => { let currentVersion = existingVersion if (existingVersion) { await lockPerformanceSurveyAdminScope( tx, `performance-survey-template-version:${existingVersion.id}`, ) currentVersion = await tx.performanceSurveyTemplateVersion.findUnique({ where: { id: existingVersion.id }, include: { template: true }, }) if (!currentVersion) throw new PerformanceSurveyPositionConfigError('模板版本不存在', 404) if (currentVersion.status === 'archived') { throw new PerformanceSurveyPositionConfigError('已归档模板版本不能修改', 409) } if (currentVersion.template.code !== definition.code) { throw new PerformanceSurveyPositionConfigError('模板代码创建后不可在版本草稿中修改', 409) } if (currentVersion.template.archivedAt) { throw new PerformanceSurveyPositionConfigError('已归档模板不能继续编辑', 409) } if (currentVersion.status === 'published') { validatePerformanceSurveyTemplateDefinition(definition) } } let template = currentVersion?.template || await tx.performanceSurveyTemplate.findUnique({ where: { code: definition.code }, }) if (template?.archivedAt) { throw new PerformanceSurveyPositionConfigError('同代码模板已归档,请先复制为新代码', 409) } if (!template) { template = await tx.performanceSurveyTemplate.create({ data: { code: definition.code, name: definition.name, description: definition.description, surveyType: definition.surveyType, weight: definition.weight, questions: definition.questions as any, scoreBands: definition.scoreBands as any, isActive: true, }, }) } else if (!currentVersion) { const existingTemplateVersion = await tx.performanceSurveyTemplateVersion.findFirst({ where: { templateId: template.id }, select: { id: true }, }) if (existingTemplateVersion) { throw new PerformanceSurveyPositionConfigError('模板已存在,请直接修改当前模板', 409) } } let saved if (currentVersion) { const updateResult = await tx.performanceSurveyTemplateVersion.updateMany({ where: { id: currentVersion.id, status: currentVersion.status, updatedAt: currentVersion.updatedAt, }, data: { definition: definition as any, createdByUserId: input.actorUserId, createdByName: input.actorName }, }) if (updateResult.count !== 1) { throw new PerformanceSurveyPositionConfigError( '模板已发生变化,请重新加载后再保存', 409, ) } saved = await tx.performanceSurveyTemplateVersion.findUnique({ where: { id: currentVersion.id }, }) if (!saved) throw new PerformanceSurveyPositionConfigError('模板草稿保存失败', 409) if (currentVersion.status === 'published') { await tx.performanceSurveyTemplate.update({ where: { id: template.id }, data: { name: definition.name, description: definition.description || '', surveyType: definition.surveyType, weight: Number(definition.weight || 1), questions: definition.questions as any, scoreBands: definition.scoreBands as any, isActive: true, }, }) } } else { saved = await tx.performanceSurveyTemplateVersion.create({ data: { templateId: template.id, version: 1, status: 'draft', definition: definition as any, createdByUserId: input.actorUserId, createdByName: input.actorName, }, }) } await writePerformanceSurveyConfigAuditLog(tx, { action: currentVersion?.status === 'published' ? 'template.version_updated' : 'template.draft_saved', targetType: 'template', targetId: template.id, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, beforeSnapshot: currentVersion, afterSnapshot: saved, metadata: { versionId: saved.id, status: currentVersion?.status || 'draft' }, }) return saved }) } export async function copyPerformanceSurveyTemplateVersion(input: { versionId: string actorUserId: string actorName: string reason?: string | null }) { const source = await prisma.performanceSurveyTemplateVersion.findUnique({ where: { id: input.versionId }, include: { template: true }, }) if (!source) throw new PerformanceSurveyPositionConfigError('模板版本不存在', 404) if (source.template.archivedAt) throw new PerformanceSurveyPositionConfigError('已归档模板不能复制', 409) const sourceDefinition = readTemplateDefinition(source.definition) const sourceName = clean(sourceDefinition.name) || source.template.name const copyName = `${sourceName}(副本)` const copyCode = `template_copy_${randomUUID().replace(/-/g, '')}` const copiedDefinition: PerformanceSurveyTemplateShape = { ...sourceDefinition, code: copyCode, name: copyName, shortName: copyName, } return prisma.$transaction(async (tx) => { const template = await tx.performanceSurveyTemplate.create({ data: { code: copyCode, name: copyName, description: copiedDefinition.description, surveyType: copiedDefinition.surveyType, weight: copiedDefinition.weight, questions: copiedDefinition.questions as any, scoreBands: copiedDefinition.scoreBands as any, isActive: true, }, }) const version = await tx.performanceSurveyTemplateVersion.create({ data: { templateId: template.id, version: 1, status: 'draft', definition: copiedDefinition as any, createdByUserId: input.actorUserId, createdByName: input.actorName, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'template.copied', targetType: 'template', targetId: template.id, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, beforeSnapshot: source, afterSnapshot: { template, version }, metadata: { sourceTemplateId: source.templateId, sourceVersionId: source.id, }, }) return { id: version.id, templateId: template.id, name: copyName, } }) } export async function publishPerformanceSurveyTemplateVersion(input: { versionId: string effectiveMonth?: string | null actorUserId: string actorName: string reason?: string | null }) { const effectiveMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth) || getCurrentAssessmentMonth() return prisma.$transaction(async (tx) => { await lockPerformanceSurveyAdminScope( tx, `performance-survey-template-version:${input.versionId}`, ) const version = await tx.performanceSurveyTemplateVersion.findUnique({ where: { id: input.versionId }, include: { template: true }, }) if (!version) throw new PerformanceSurveyPositionConfigError('模板版本不存在', 404) if (version.status !== 'draft') { throw new PerformanceSurveyPositionConfigError('只有草稿模板可以发布', 409) } if (version.template.archivedAt) { throw new PerformanceSurveyPositionConfigError('已归档模板不能发布', 409) } const definition = readTemplateDefinition(version.definition) if (definition.code !== version.template.code) { throw new PerformanceSurveyPositionConfigError('模板版本代码与模板代码不一致', 409) } validatePerformanceSurveyTemplateDefinition(definition) const publishResult = await tx.performanceSurveyTemplateVersion.updateMany({ where: { id: version.id, status: 'draft', updatedAt: version.updatedAt, }, data: { status: 'published', effectiveMonth, publishedAt: new Date(), createdByUserId: input.actorUserId, createdByName: input.actorName, }, }) if (publishResult.count !== 1) { throw new PerformanceSurveyPositionConfigError( '模板草稿已发生变化,请重新加载后再发布', 409, ) } const published = await tx.performanceSurveyTemplateVersion.findUnique({ where: { id: version.id }, }) if (!published) throw new PerformanceSurveyPositionConfigError('模板发布失败', 409) await tx.performanceSurveyTemplate.update({ where: { id: version.templateId }, data: { name: definition.name, description: definition.description || '', surveyType: definition.surveyType, weight: Number(definition.weight || 1), questions: definition.questions as any, scoreBands: definition.scoreBands as any, isActive: true, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'template.version_published', targetType: 'template', targetId: version.templateId, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, beforeSnapshot: version, afterSnapshot: published, metadata: { effectiveMonth }, }) return published }) } export async function createPerformanceSurveyPositionGroup(input: { code?: string | null name: string sortOrder?: number isActive?: boolean rules?: unknown templateVersionIds?: unknown effectiveMonth?: string | null actorUserId: string actorName: string reason?: string | null sourceGroupId?: string | null }) { const identity = createPerformanceSurveyPositionGroupIdentity() const code = normalizePositionGroupCode(input.code) || identity.code const name = clean(input.name) if (!name) throw new PerformanceSurveyPositionConfigError('岗位名称不能为空') const existing = await prisma.performanceSurveyPositionGroup.findUnique({ where: { code } }) if (existing) throw new PerformanceSurveyPositionConfigError('岗位创建失败,请重试', 409) const rules = normalizeRuleInputs(input.rules) const rawTemplateVersionIds = input.templateVersionIds if (rawTemplateVersionIds !== undefined && !Array.isArray(rawTemplateVersionIds)) { throw new PerformanceSurveyPositionConfigError('岗位方案考核项格式无效') } const templateVersionIds = Array.isArray(rawTemplateVersionIds) ? Array.from(new Set(rawTemplateVersionIds.map((id) => clean(id)).filter((id): id is string => Boolean(id)))) : [] const effectiveMonth = clean(input.effectiveMonth) ? normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth) : null if (clean(input.effectiveMonth) && !effectiveMonth) { throw new PerformanceSurveyPositionConfigError('计划生效月份格式无效') } const versions = templateVersionIds.length > 0 ? await prisma.performanceSurveyTemplateVersion.findMany({ where: { id: { in: templateVersionIds } }, include: { template: true }, }) : [] if (versions.length !== templateVersionIds.length) { throw new PerformanceSurveyPositionConfigError('岗位方案包含不存在的模板版本') } validatePerformanceSurveyPositionPlanTemplateVersions(versions, effectiveMonth) return prisma.$transaction(async (tx) => { const group = await tx.performanceSurveyPositionGroup.create({ data: { id: identity.id, code, name, sortOrder: Number.isFinite(Number(input.sortOrder)) ? Number(input.sortOrder) : 0, isActive: true, isSystem: false, }, }) const draft = await tx.performanceSurveyPositionPlan.create({ data: { positionGroupId: group.id, version: 1, status: 'draft', effectiveMonth, matchRules: rules as any, groupConfig: { name, sortOrder: Number.isFinite(Number(input.sortOrder)) ? Number(input.sortOrder) : 0, isActive: typeof input.isActive === 'boolean' ? input.isActive : true, } as any, createdByUserId: input.actorUserId, createdByName: input.actorName, items: templateVersionIds.length > 0 ? { create: templateVersionIds.map((templateVersionId, index) => ({ templateVersionId, sortOrder: index, isEnabled: true, })), } : undefined, }, include: { items: { orderBy: { sortOrder: 'asc' } } }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: input.sourceGroupId ? 'position_group.copied' : 'position_group.created', targetType: 'position_group', targetId: group.id, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, afterSnapshot: { group, draft }, metadata: input.sourceGroupId ? { sourceGroupId: input.sourceGroupId } : undefined, }) return { group, draft } }) } export async function copyPerformanceSurveyPositionGroup(input: { sourceGroupId: string code?: string | null name: string month?: string | null actorUserId: string actorName: string reason?: string | null }) { const month = normalizePerformanceSurveyAssessmentMonth(input.month) || getCurrentAssessmentMonth() const source = await prisma.performanceSurveyPositionGroup.findUnique({ where: { id: input.sourceGroupId }, include: { plans: { include: { items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' } } }, orderBy: { version: 'desc' }, }, }, }) if (!source) throw new PerformanceSurveyPositionConfigError('来源岗位组不存在', 404) const sourcePlan = source.plans.find((plan) => plan.status === 'draft') || chooseEffectivePlan(source.plans, month) const configuration = readPositionGroupPlanConfiguration(sourcePlan?.groupConfig, source) return createPerformanceSurveyPositionGroup({ code: input.code, name: input.name, sortOrder: configuration.sortOrder, isActive: configuration.isActive, rules: readRules(sourcePlan?.matchRules), templateVersionIds: sourcePlan?.items.map((item) => item.templateVersionId) || [], effectiveMonth: sourcePlan?.effectiveMonth, actorUserId: input.actorUserId, actorName: input.actorName, reason: input.reason, sourceGroupId: source.id, }) } export async function removePerformanceSurveyPositionGroup(input: { groupId: string mode?: 'auto' | 'archive' | 'delete' actorUserId: string actorName: string reason: string }) { const reason = clean(input.reason) if (!reason) throw new PerformanceSurveyPositionConfigError('请填写删除或归档原因') if (reason.length > 500) throw new PerformanceSurveyPositionConfigError('删除或归档原因不能超过500字') const group = await prisma.performanceSurveyPositionGroup.findUnique({ where: { id: input.groupId }, include: { plans: { select: { id: true, status: true, version: true, effectiveMonth: true } }, _count: { select: { surveys: true, manualAssignments: true } }, }, }) if (!group) throw new PerformanceSurveyPositionConfigError('考核岗位组不存在', 404) const canHardDelete = !group.isSystem && group._count.surveys === 0 && group._count.manualAssignments === 0 && group.plans.every((plan) => plan.status === 'draft') const shouldDelete = input.mode !== 'archive' && canHardDelete return prisma.$transaction(async (tx) => { if (shouldDelete) { await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_group.deleted', targetType: 'position_group', targetId: group.id, actorUserId: input.actorUserId, actorName: input.actorName, reason, beforeSnapshot: group, }) await tx.performanceSurveyPositionGroup.delete({ where: { id: group.id } }) return { id: group.id, action: 'deleted' as const } } const archived = await tx.performanceSurveyPositionGroup.update({ where: { id: group.id }, data: { isActive: false, archivedAt: new Date(), archivedByUserId: input.actorUserId, archivedByName: input.actorName, archiveReason: reason, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_group.archived', targetType: 'position_group', targetId: group.id, actorUserId: input.actorUserId, actorName: input.actorName, reason, beforeSnapshot: group, afterSnapshot: archived, metadata: { requestedMode: input.mode || 'auto', hardDeleteAllowed: canHardDelete }, }) return { id: group.id, action: 'archived' as const } }) } export async function removePerformanceSurveyTemplate(input: { templateId: string mode?: 'auto' | 'archive' | 'delete' actorUserId: string actorName: string reason: string }) { const reason = clean(input.reason) if (!reason) throw new PerformanceSurveyPositionConfigError('请填写删除或归档原因') if (reason.length > 500) throw new PerformanceSurveyPositionConfigError('删除或归档原因不能超过500字') const template = await prisma.performanceSurveyTemplate.findUnique({ where: { id: input.templateId }, include: { versions: { orderBy: { version: 'desc' } } }, }) if (!template) throw new PerformanceSurveyPositionConfigError('模板不存在', 404) const versionIds = template.versions.map((version) => version.id) const currentMonth = getCurrentAssessmentMonth() const [planReferenceCount, surveyReferenceCount, activeGroups] = await Promise.all([ versionIds.length > 0 ? prisma.performanceSurveyPositionPlanItem.count({ where: { templateVersionId: { in: versionIds } } }) : 0, prisma.performanceSurvey.count({ where: { OR: [ { templateCode: template.code }, ...(versionIds.length > 0 ? [{ templateVersionId: { in: versionIds } }] : []), ], }, }), versionIds.length > 0 ? prisma.performanceSurveyPositionGroup.findMany({ where: { archivedAt: null }, select: { name: true, plans: { where: { status: { in: ['draft', 'published'] } }, select: { id: true, status: true, effectiveMonth: true, version: true, items: { where: { isEnabled: true, templateVersionId: { in: versionIds } }, select: { id: true }, }, }, }, }, }) : [], ]) const activePositionNames = activeGroups.flatMap((group) => { const currentPlan = chooseEffectivePlan(group.plans, currentMonth) const referenced = group.plans.some((plan) => ( plan.items.length > 0 && ( plan.status === 'draft' || plan.id === currentPlan?.id || (plan.status === 'published' && Boolean(plan.effectiveMonth && plan.effectiveMonth > currentMonth)) ) )) return referenced ? [group.name] : [] }) const canHardDelete = template.versions.every((version) => version.status === 'draft') && planReferenceCount === 0 && surveyReferenceCount === 0 const shouldDelete = input.mode !== 'archive' && canHardDelete if (!shouldDelete && activePositionNames.length > 0) { throw new PerformanceSurveyPositionConfigError( `模板仍被以下岗位使用:${activePositionNames.join('、')}。请先从岗位考核项中移除并发布新方案,再删除模板。`, 409, ) } return prisma.$transaction(async (tx) => { if (shouldDelete) { await writePerformanceSurveyConfigAuditLog(tx, { action: 'template.deleted', targetType: 'template', targetId: template.id, actorUserId: input.actorUserId, actorName: input.actorName, reason, beforeSnapshot: template, }) await tx.performanceSurveyTemplate.delete({ where: { id: template.id } }) return { id: template.id, action: 'deleted' as const } } const archived = await tx.performanceSurveyTemplate.update({ where: { id: template.id }, data: { isActive: false, archivedAt: new Date(), archivedByUserId: input.actorUserId, archivedByName: input.actorName, archiveReason: reason, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'template.archived', targetType: 'template', targetId: template.id, actorUserId: input.actorUserId, actorName: input.actorName, reason, beforeSnapshot: template, afterSnapshot: archived, metadata: { requestedMode: input.mode || 'auto', hardDeleteAllowed: canHardDelete, planReferenceCount, surveyReferenceCount, }, }) return { id: template.id, action: 'archived' as const } }) } function normalizeManualPositionAssignmentInput(input: { assignmentType?: unknown positionGroupId?: unknown effectiveFromMonth?: unknown effectiveToMonth?: unknown reason?: unknown }) { const assignmentType = clean(input.assignmentType) if (assignmentType !== 'position' && assignmentType !== 'excluded') { throw new PerformanceSurveyPositionConfigError('手动分配类型无效') } const positionGroupId = clean(input.positionGroupId) if (assignmentType === 'position' && !positionGroupId) { throw new PerformanceSurveyPositionConfigError('指定岗位时必须选择岗位组') } if (assignmentType === 'excluded' && positionGroupId) { throw new PerformanceSurveyPositionConfigError('不参与考核时不能指定岗位组') } const startMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveFromMonth) const endMonth = clean(input.effectiveToMonth) ? normalizePerformanceSurveyAssessmentMonth(input.effectiveToMonth) : null if (!startMonth || (clean(input.effectiveToMonth) && !endMonth)) { throw new PerformanceSurveyPositionConfigError('手动分配生效月份格式无效') } if (endMonth && endMonth < startMonth) { throw new PerformanceSurveyPositionConfigError('结束月份不能早于开始月份') } const reason = clean(input.reason) || '' if (reason.length > 500) throw new PerformanceSurveyPositionConfigError('手动分配原因不能超过500字') return { assignmentType, positionGroupId, startMonth, endMonth, reason } } async function assertManualPositionAssignmentHasNoOverlap( db: any, employeeProfileId: string, range: { startMonth: string; endMonth?: string | null }, excludeId?: string, ) { const overlap = await db.performanceSurveyEmployeePositionAssignment.findFirst({ where: { employeeProfileId, status: 'active', ...(excludeId ? { id: { not: excludeId } } : {}), startMonth: { lte: range.endMonth || '9999-12' }, OR: [{ endMonth: null }, { endMonth: { gte: range.startMonth } }], }, }) if (overlap) { throw new PerformanceSurveyPositionConfigError('该人员在所选月份范围内已有有效手动分配', 409) } } async function assertManualAssignmentPositionGroup(positionGroupId: string | null) { if (!positionGroupId) return const group = await prisma.performanceSurveyPositionGroup.findUnique({ where: { id: positionGroupId } }) if (!group || group.archivedAt) throw new PerformanceSurveyPositionConfigError('指定岗位组不存在或已归档', 409) } export async function createPerformanceSurveyManualPositionAssignments(input: { employeeIds: unknown positionGroupId?: unknown assignmentType?: unknown effectiveFromMonth?: unknown effectiveToMonth?: unknown reason?: unknown actorUserId: string actorName: string }) { const employeeIds = Array.isArray(input.employeeIds) ? Array.from(new Set(input.employeeIds.map((id) => clean(id)).filter((id): id is string => Boolean(id)))) : [] if (employeeIds.length === 0) throw new PerformanceSurveyPositionConfigError('请至少选择一名人员') if (employeeIds.length > 500) throw new PerformanceSurveyPositionConfigError('单次最多分配500人') const normalized = normalizeManualPositionAssignmentInput(input) await assertManualAssignmentPositionGroup(normalized.positionGroupId) const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ getActivePerformanceSurveyEmployeeProfileWhere(), { id: { in: employeeIds } }, ], }, select: { id: true, displayName: true }, }) if (employees.length !== employeeIds.length) throw new PerformanceSurveyPositionConfigError('部分人员不存在或已不可用') const employeeNameById = new Map(employees.map((employee) => [employee.id, employee.displayName])) return prisma.$transaction(async (tx) => { const created = [] for (const employeeProfileId of [...employeeIds].sort()) { await tx.$queryRawUnsafe( 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))::text AS locked', `performance-survey-position-assignment:${employeeProfileId}`, ) await assertManualPositionAssignmentHasNoOverlap(tx, employeeProfileId, normalized) const assignment = await tx.performanceSurveyEmployeePositionAssignment.create({ data: { employeeProfileId, assignmentType: normalized.assignmentType, positionGroupId: normalized.assignmentType === 'position' ? normalized.positionGroupId : null, startMonth: normalized.startMonth, endMonth: normalized.endMonth, reason: normalized.reason, status: 'active', createdByUserId: input.actorUserId, createdByName: input.actorName, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_assignment.created', targetType: 'position_assignment', targetId: assignment.id, actorUserId: input.actorUserId, actorName: input.actorName, reason: normalized.reason, afterSnapshot: assignment, metadata: { employeeProfileId, employeeName: employeeNameById.get(employeeProfileId), }, }) created.push(assignment) } return { created, count: created.length } }) } export async function updatePerformanceSurveyManualPositionAssignment(input: { assignmentId: string positionGroupId?: unknown assignmentType?: unknown effectiveFromMonth?: unknown effectiveToMonth?: unknown reason?: unknown actorUserId: string actorName: string }) { const existing = await prisma.performanceSurveyEmployeePositionAssignment.findUnique({ where: { id: input.assignmentId }, }) if (!existing) throw new PerformanceSurveyPositionConfigError('手动分配记录不存在', 404) if (existing.status !== 'active') throw new PerformanceSurveyPositionConfigError('已归档手动分配不能修改', 409) const normalized = normalizeManualPositionAssignmentInput({ assignmentType: input.assignmentType ?? existing.assignmentType, positionGroupId: input.positionGroupId !== undefined ? input.positionGroupId : existing.positionGroupId, effectiveFromMonth: input.effectiveFromMonth ?? existing.startMonth, effectiveToMonth: input.effectiveToMonth !== undefined ? input.effectiveToMonth : existing.endMonth, reason: input.reason ?? existing.reason, }) await assertManualAssignmentPositionGroup(normalized.positionGroupId) return prisma.$transaction(async (tx) => { await tx.$queryRawUnsafe( 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))::text AS locked', `performance-survey-position-assignment:${existing.employeeProfileId}`, ) await assertManualPositionAssignmentHasNoOverlap(tx, existing.employeeProfileId, normalized, existing.id) const updated = await tx.performanceSurveyEmployeePositionAssignment.update({ where: { id: existing.id }, data: { assignmentType: normalized.assignmentType, positionGroupId: normalized.assignmentType === 'position' ? normalized.positionGroupId : null, startMonth: normalized.startMonth, endMonth: normalized.endMonth, reason: normalized.reason, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_assignment.updated', targetType: 'position_assignment', targetId: existing.id, actorUserId: input.actorUserId, actorName: input.actorName, reason: normalized.reason, beforeSnapshot: existing, afterSnapshot: updated, }) return updated }) } export async function archivePerformanceSurveyManualPositionAssignment(input: { assignmentId: string actorUserId: string actorName: string reason: string }) { const reason = clean(input.reason) || '' if (reason.length > 500) throw new PerformanceSurveyPositionConfigError('归档原因不能超过500字') const existing = await prisma.performanceSurveyEmployeePositionAssignment.findUnique({ where: { id: input.assignmentId }, }) if (!existing) throw new PerformanceSurveyPositionConfigError('手动分配记录不存在', 404) if (existing.status !== 'active') throw new PerformanceSurveyPositionConfigError('手动分配记录已归档', 409) return prisma.$transaction(async (tx) => { const archived = await tx.performanceSurveyEmployeePositionAssignment.update({ where: { id: existing.id }, data: { status: 'archived', archivedAt: new Date(), archivedByUserId: input.actorUserId, archivedByName: input.actorName, }, }) await writePerformanceSurveyConfigAuditLog(tx, { action: 'position_assignment.archived', targetType: 'position_assignment', targetId: existing.id, actorUserId: input.actorUserId, actorName: input.actorName, reason, beforeSnapshot: existing, afterSnapshot: archived, }) return archived }) } export async function listPerformanceSurveyManualPositionAssignments(params?: URLSearchParams) { const page = Math.max(1, Number(params?.get('page') || 1)) const pageSize = Math.max(1, Math.min(Number(params?.get('pageSize') || 50), 200)) const requestedMonth = clean(params?.get('month')) const month = normalizePerformanceSurveyAssessmentMonth(requestedMonth) || getCurrentAssessmentMonth() if (requestedMonth && requestedMonth !== month) { throw new PerformanceSurveyPositionConfigError('查询月份格式无效') } const employeeId = clean(params?.get('employeeId')) const search = clean(params?.get('search')) const status = clean(params?.get('status')) || 'all' if (!['all', 'assigned', 'unassigned', 'excluded'].includes(status)) { throw new PerformanceSurveyPositionConfigError('人员分配状态筛选无效') } const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ getActivePerformanceSurveyEmployeeProfileWhere(), ...(employeeId ? [{ id: employeeId }] : []), ...(search ? [{ OR: [ { displayName: { contains: search, mode: 'insensitive' } }, { flowerName: { contains: search, mode: 'insensitive' } }, { realName: { contains: search, mode: 'insensitive' } }, { jobNumber: { 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' } }, { departmentMemberships: { some: { OR: [ { departmentName: { contains: search, mode: 'insensitive' } }, { departmentPath: { contains: search, mode: 'insensitive' } }, ], }, }, }, ], }] : []), ], }, orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], select: { id: true, displayName: true, flowerName: true, realName: true, jobNumber: true, departmentName: true, departmentPath: true, positionTitle: true, positionLevel: true, levelType: true, isLeadership: true, isDingtalkAdmin: true, isDingtalkBoss: true, isDingtalkSenior: true, departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], select: { id: true, sourceDeptId: true, departmentName: true, departmentPath: true, isPrimary: true, syncedAt: true, }, }, }, }) const memberships = await getPerformanceSurveyPositionMembershipsForEmployees(employees, month) const rows = employees.map((employee) => { const membership = memberships.get(employee.id)! const participation = membership.status === 'excluded' ? 'excluded' : membership.status === 'matched' ? 'assigned' : 'unassigned' return { ...employee, employeeId: employee.id, positionGroupId: membership.group?.id || null, positionGroupName: membership.group?.name || null, assignmentType: membership.source === 'manual' ? membership.status === 'excluded' ? 'excluded' : 'position' : null, assignmentSource: membership.source, manualAssignmentId: membership.manualAssignmentId, membershipStatus: membership.status, participation, } }).filter((row) => status === 'all' || row.participation === status) const total = rows.length const items = rows.slice((page - 1) * pageSize, page * pageSize) return { items, total, page, pageSize, month } } export async function listPerformanceSurveyConfigAuditLogs(params?: URLSearchParams) { const page = Math.max(1, Number(params?.get('page') || 1)) const pageSize = Math.max(1, Math.min(Number(params?.get('pageSize') || 50), 200)) const targetType = clean(params?.get('targetType')) const targetId = clean(params?.get('targetId')) const action = clean(params?.get('action')) const where = { ...(targetType ? { targetType } : {}), ...(targetId ? { targetId } : {}), ...(action ? { action } : {}), } const [items, total] = await Promise.all([ prisma.performanceSurveyConfigAuditLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.performanceSurveyConfigAuditLog.count({ where }), ]) return { items, total, page, pageSize } } export async function previewPerformanceSurveyPositionPlanImpact(input: { groupId: string effectiveMonth: string }) { const month = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth) if (!month) throw new PerformanceSurveyPositionConfigError('预览月份格式无效') const draft = await prisma.performanceSurveyPositionPlan.findFirst({ where: { positionGroupId: input.groupId, status: 'draft' }, orderBy: { version: 'desc' }, include: { positionGroup: true, items: { where: { isEnabled: true }, orderBy: { sortOrder: 'asc' }, include: { templateVersion: { include: { template: true } } }, }, }, }) if (!draft) throw new PerformanceSurveyPositionConfigError('没有可预览的岗位方案草稿', 404) if (draft.positionGroup.archivedAt) throw new PerformanceSurveyPositionConfigError('已归档岗位组不能预览发布', 409) const rules = readRules(draft.matchRules) const configuration = readPositionGroupPlanConfiguration(draft.groupConfig, draft.positionGroup) const blockingIssues: string[] = [] if (configuration.isActive && draft.items.length === 0) blockingIssues.push('缺少考核模板') try { validatePerformanceSurveyPositionPlanTemplateVersions( draft.items.map((item) => item.templateVersion), month, ) } catch (error) { blockingIssues.push(error instanceof Error ? error.message : '模板版本校验失败') } const [currentGroups, employees] = await Promise.all([ listPerformanceSurveyPositionGroups(month), prisma.performanceSurveyEmployeeProfile.findMany({ where: getActivePerformanceSurveyEmployeeProfileWhere(), orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], include: { departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], }, }, }), ]) const manualAssignments = await listEffectiveManualPositionAssignments(month, employees.map((employee) => employee.id)) const manualByEmployeeId = mapEffectiveManualPositionAssignments(manualAssignments) const manualCounts = new Map() for (const assignment of manualAssignments) { manualCounts.set(assignment.employeeProfileId, (manualCounts.get(assignment.employeeProfileId) || 0) + 1) } const currentGroup = currentGroups.find((group) => group.id === input.groupId) const candidateGroup: PositionGroupRuntime = { ...(currentGroup || { id: draft.positionGroup.id, code: draft.positionGroup.code, planId: draft.id, planVersion: draft.version, effectiveMonth: month, templates: [], rules: [], status: 'pending' as const, isSystem: draft.positionGroup.isSystem, archivedAt: draft.positionGroup.archivedAt, }), name: configuration.name, sortOrder: configuration.sortOrder, isActive: configuration.isActive, status: !configuration.isActive ? 'disabled' : draft.items.length > 0 ? 'ready' : 'pending', rules, templates: draft.items.map((item) => ({ ...readTemplateDefinition(item.templateVersion.definition), templateVersionId: item.templateVersion.id, templateVersion: item.templateVersion.version, templateVersionStatus: item.templateVersion.status as 'draft' | 'published' | 'archived', effectiveMonth: item.templateVersion.effectiveMonth, })), } const candidateGroups = currentGroup ? currentGroups.map((group) => group.id === input.groupId ? candidateGroup : group) : [...currentGroups, candidateGroup] const changes: any[] = [] const afterCounts = { matched: 0, unmatched: 0, conflict: 0, excluded: 0 } let candidateMemberCount = 0 for (const employee of employees) { const duplicateManual = (manualCounts.get(employee.id) || 0) > 1 const manual = manualByEmployeeId.get(employee.id) || null const before = duplicateManual ? { status: 'conflict' as const, group: null, source: 'manual' as const } : resolvePerformanceSurveyPositionMembership(employee, currentGroups, manual) const after = duplicateManual ? { status: 'conflict' as const, group: null, source: 'manual' as const } : resolvePerformanceSurveyPositionMembership(employee, candidateGroups, manual) afterCounts[after.status] += 1 if (after.status === 'matched' && after.group?.id === input.groupId) candidateMemberCount += 1 if (before.status !== after.status || before.group?.id !== after.group?.id) { changes.push({ ...serializePositionEmployee(employee), before: { status: before.status, groupId: before.group?.id || null, groupName: before.group?.name || null }, after: { status: after.status, groupId: after.group?.id || null, groupName: after.group?.name || null }, source: after.source, }) } } if (afterCounts.conflict > 0) blockingIssues.push(`发布后仍有 ${afterCounts.conflict} 名人员岗位冲突`) return { month, groupId: input.groupId, draftPlanId: draft.id, draftVersion: draft.version, draftUpdatedAt: draft.updatedAt, groupConfig: configuration, matchRules: rules, templateVersionIds: draft.items.map((item) => item.templateVersionId), totals: { employees: employees.length, changed: changes.length, ...afterCounts, }, memberCount: candidateMemberCount, affectedEmployees: changes.length, conflictCount: afterCounts.conflict, blockingIssues, changes: changes.slice(0, 200), truncated: changes.length > 200, } } export function getManualReviewerStages(template: PerformanceSurveyTemplateShape) { return (template.workflow?.stages || []).filter((stage) => ( stage.reviewers.some((reviewer) => reviewer.source === 'manual_selection') )) } export function getReviewerSources(template: PerformanceSurveyTemplateShape) { return (template.workflow?.stages || []).flatMap((stage) => stage.reviewers) } export function findWorkflowReviewer( template: PerformanceSurveyTemplateShape, stageKey: string, source: string, ): PerformanceSurveyWorkflowReviewer | null { return template.workflow?.stages .find((stage) => stage.key === stageKey) ?.reviewers.find((reviewer) => reviewer.source === source) || null }