2655 lines
101 KiB
TypeScript
2655 lines
101 KiB
TypeScript
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
|
||
}
|