gongxiangp2/lib/performance-survey-position-config.ts

2655 lines
101 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 '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<T>(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<PerformanceSurveyTemplateShape & {
templateArchivedAt?: Date | null
}>
}
export function getAvailablePerformanceSurveyPositionTemplates(
group: Pick<PositionGroupRuntime, 'templates'>,
) {
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<string, unknown>
: {}
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<string, unknown>
: {}
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<string>()
const stageOrders = new Set<number>()
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<string>()
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<string, { id: string; status: string }>()
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<T extends { status: string; effectiveMonth: string | null; version: number }>(
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<PositionGroupRuntime[]> {
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<T extends {
id: string
employeeProfileId: string
assignmentType: string
positionGroupId?: string | null
}>(assignments: T[]) {
const byEmployeeId = new Map<string, T | null>()
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<string, PerformanceSurveyEmployeeDepartmentMembershipInput[]>()
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<string, number>()
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<any>(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<string, number>()
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<string, number>()
for (const item of memberships) {
if (item.membership.group) {
memberCountByGroup.set(item.membership.group.id, (memberCountByGroup.get(item.membership.group.id) || 0) + 1)
}
}
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,
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<string>()
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<string>()
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('岗位方案包含已归档模板')
}
if (effectiveMonth && (!version.effectiveMonth || version.effectiveMonth > effectiveMonth)) {
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<string, number>()
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
draftPlanId?: string | null
draftVersion?: number | string | null
draftUpdatedAt?: Date | string | null
actorUserId: string
actorName: string
reason?: string | null
}) {
const effectiveMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth)
if (!effectiveMonth) throw new PerformanceSurveyPositionConfigError('生效月份格式无效')
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 && existingVersion.status !== 'draft') {
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 !== 'draft') {
throw new PerformanceSurveyPositionConfigError('已发布模板不可直接修改,请创建新草稿', 409)
}
if (currentVersion.template.code !== definition.code) {
throw new PerformanceSurveyPositionConfigError('模板代码创建后不可在版本草稿中修改', 409)
}
if (currentVersion.template.archivedAt) {
throw new PerformanceSurveyPositionConfigError('已归档模板不能继续编辑', 409)
}
}
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,
},
})
}
let saved
if (currentVersion) {
const updateResult = await tx.performanceSurveyTemplateVersion.updateMany({
where: {
id: currentVersion.id,
status: 'draft',
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)
} else {
const latest = await tx.performanceSurveyTemplateVersion.findFirst({
where: { templateId: template.id },
orderBy: { version: 'desc' },
select: { version: true },
})
saved = await tx.performanceSurveyTemplateVersion.create({
data: {
templateId: template.id,
version: Number(latest?.version || 0) + 1,
status: 'draft',
definition: definition as any,
createdByUserId: input.actorUserId,
createdByName: input.actorName,
},
})
}
await writePerformanceSurveyConfigAuditLog(tx, {
action: '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 },
})
return saved
})
}
export async function createPerformanceSurveyTemplateDraftFromVersion(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 existingDraft = await prisma.performanceSurveyTemplateVersion.findFirst({
where: { templateId: source.templateId, status: 'draft' },
orderBy: { version: 'desc' },
})
if (existingDraft) return existingDraft
const latest = await prisma.performanceSurveyTemplateVersion.findFirst({
where: { templateId: source.templateId },
orderBy: { version: 'desc' },
select: { version: true },
})
return prisma.$transaction(async (tx) => {
const created = await tx.performanceSurveyTemplateVersion.create({
data: {
templateId: source.templateId,
version: Number(latest?.version || 0) + 1,
status: 'draft',
definition: source.definition as any,
createdByUserId: input.actorUserId,
createdByName: input.actorName,
},
})
await writePerformanceSurveyConfigAuditLog(tx, {
action: 'template.version_cloned',
targetType: 'template',
targetId: source.templateId,
actorUserId: input.actorUserId,
actorName: input.actorName,
reason: input.reason,
beforeSnapshot: source,
afterSnapshot: created,
metadata: { sourceVersionId: source.id },
})
return created
})
}
export async function publishPerformanceSurveyTemplateVersion(input: {
versionId: string
effectiveMonth: string
actorUserId: string
actorName: string
reason?: string | null
}) {
const effectiveMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth)
if (!effectiveMonth) throw new PerformanceSurveyPositionConfigError('生效月份格式无效')
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<string, number>()
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
}