feat: 绩效系数配置化 + 工作台社保公积金评分修复

- Organization 新增 defaultGradeCoefficients 字段,系统设置可配置缺省绩效系数
- 绩效记录编辑时自动带出系数(优先模板 gradeRules,回退组织缺省配置)
- 薪资计算优先用记录 coefficient,回退模板/组织缺省/硬编码默认值
- 绩效模板表单新增等级系数设置区域
- 修复工作台社保公积金评分为0:查询从旧表 SocialInsuranceConfig 改为 SocialYearStandard
- 修复 employeesNoSocial 计算逻辑:只统计有正式劳动合同(FIXED/UNFIXED)但无社保记录的员工
- 社保账户未设置 paymentDay 时回退到 Organization.socialInsCutoffDay 生成月度任务
- 合规健康度各维度补充 todoCount 字段

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-19 15:16:06 +08:00
parent 81e136b707
commit f9324d4299
8 changed files with 245 additions and 39 deletions
+2
View File
@@ -141,6 +141,7 @@ model Organization {
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月
defaultGradeCoefficients Json? @default("{\"A\":1.2,\"B\":1.0,\"C\":0.8,\"D\":0.6}") // 缺省绩效系数(不选模板时使用)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -801,6 +802,7 @@ model PerformanceRecord {
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
score Float @default(0) // 考核得分
grade String @default("B") // A/B/C/D
coefficient Float @default(1.0) // 绩效系数(A=1.2, B=1.0, C=0.8, D=0.6,可手动调整)
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
summary String? // 考核评语
improvementPlan String? // 改进计划(不胜任时)
+41 -9
View File
@@ -324,13 +324,17 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
// 查询批次月份的绩效记录,附加到 entry 上
const perfRecords = await prisma.performanceRecord.findMany({
where: { orgId: req.user!.orgId, period: rawBatch.month, periodType: 'MONTHLY' },
select: { employeeId: true, grade: true, score: true },
select: { employeeId: true, grade: true, score: true, coefficient: true },
})
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
// 等级默认系数:优先从组织缺省配置取,回退到硬编码
const orgForCoeff = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { defaultGradeCoefficients: true } })
const DEFAULT_GRADE_COEFFICIENTS: Record<string, number> = (orgForCoeff?.defaultGradeCoefficients as Record<string, number>) || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
const perfMap = new Map<string, { grade: string; coefficient: number }>()
for (const r of perfRecords) {
if (!perfMap.has(r.employeeId)) {
perfMap.set(r.employeeId, { grade: r.grade, coefficient: gradeCoefficients[r.grade] || 1.0 })
// 优先用记录中保存的 coefficient,回退到等级默认值
const coeff = r.coefficient != null ? r.coefficient : (DEFAULT_GRADE_COEFFICIENTS[r.grade] || 1.0)
perfMap.set(r.employeeId, { grade: r.grade, coefficient: coeff })
}
}
@@ -916,13 +920,29 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
const performanceRecords = await prisma.performanceRecord.findMany({
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
select: { employeeId: true, grade: true, score: true, result: true },
select: { employeeId: true, grade: true, score: true, result: true, coefficient: true, templateId: true },
})
const perfMap = new Map<string, { grade: string; score: number }>()
// 查询关联的绩效模板(用于回退取 gradeRules 中的 coefficient
const templateIds = [...new Set(performanceRecords.map(r => r.templateId).filter(Boolean))] as string[]
const templates = templateIds.length > 0 ? await prisma.performanceTemplate.findMany({
where: { id: { in: templateIds } },
select: { id: true, gradeRules: true },
}) : []
const templateCoeffMap = new Map<string, Record<string, number>>()
for (const t of templates) {
const coeffs: Record<string, number> = {}
if (t.gradeRules && Array.isArray(t.gradeRules)) {
for (const rule of t.gradeRules as any[]) {
if (rule.grade && rule.coefficient != null) coeffs[rule.grade] = Number(rule.coefficient)
}
}
templateCoeffMap.set(t.id, coeffs)
}
const perfMap = new Map<string, { grade: string; score: number; coefficient: number | null }>()
for (const r of performanceRecords) {
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
if (!perfMap.has(r.employeeId)) {
perfMap.set(r.employeeId, { grade: r.grade, score: r.score })
perfMap.set(r.employeeId, { grade: r.grade, score: r.score, coefficient: r.coefficient })
}
}
@@ -957,8 +977,9 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
}
}
// 绩效系数映射
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
// 绩效系数:优先用记录中保存的 coefficient,回退到组织缺省配置,再回退到硬编码
const orgForCoeff = await prisma.organization.findUnique({ where: { id: orgId }, select: { defaultGradeCoefficients: true } })
const DEFAULT_GRADE_COEFFICIENTS: Record<string, number> = (orgForCoeff?.defaultGradeCoefficients as Record<string, number>) || { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
let filled = 0
let totalAmount = 0
@@ -969,7 +990,18 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
const perfRecord = perfMap.get(entry.employeeId)
const grade = perfRecord?.grade || null
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
// 系数优先级:记录中保存的 coefficient > 模板 gradeRules 中的 coefficient > 系统默认值
let coefficient = 1.0
if (perfRecord) {
if (perfRecord.coefficient != null) {
coefficient = perfRecord.coefficient
} else {
// 查绩效记录关联的模板
const perfRecWithTemplate = performanceRecords.find(r => r.employeeId === entry.employeeId && r.templateId)
const templateCoeffs = perfRecWithTemplate?.templateId ? templateCoeffMap.get(perfRecWithTemplate.templateId) : null
coefficient = (templateCoeffs && perfRecord.grade in templateCoeffs) ? templateCoeffs[perfRecord.grade] : (DEFAULT_GRADE_COEFFICIENTS[perfRecord.grade] || 1.0)
}
}
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
await prisma.batchEntry.update({
+5 -2
View File
@@ -1271,7 +1271,7 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const { period, periodType, score, grade, coefficient, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.upsert({
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
create: {
@@ -1281,6 +1281,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
periodType: periodType || 'MONTHLY',
score: score || 0,
grade: grade || 'B',
coefficient: coefficient != null ? Number(coefficient) : 1.0,
result: result || 'QUALIFIED',
summary,
improvementPlan,
@@ -1295,6 +1296,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
periodType,
score,
grade,
coefficient: coefficient != null ? Number(coefficient) : undefined,
result,
summary,
improvementPlan,
@@ -1312,7 +1314,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const { period, periodType, score, grade, coefficient, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
@@ -1324,6 +1326,7 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut
periodType,
score,
grade,
coefficient: coefficient != null ? Number(coefficient) : undefined,
result,
summary,
improvementPlan,
+4 -3
View File
@@ -28,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
try {
const org = await prisma.organization.findUnique({
where: { id: req.user!.orgId },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, createdAt: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, defaultGradeCoefficients: true, createdAt: true },
})
res.json({ success: true, data: org })
} catch (err) {
@@ -39,7 +39,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
// 更新企业信息
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean }
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled, defaultGradeCoefficients } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean; defaultGradeCoefficients?: Record<string, number> }
const updateData: any = {}
if (name) updateData.name = name
if (payrollDays !== undefined) updateData.payrollDays = payrollDays
@@ -51,10 +51,11 @@ router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
if (esignPolicyEnabled !== undefined) updateData.esignPolicyEnabled = esignPolicyEnabled
if (esignPayslipEnabled !== undefined) updateData.esignPayslipEnabled = esignPayslipEnabled
if (esignOnboardingEnabled !== undefined) updateData.esignOnboardingEnabled = esignOnboardingEnabled
if (defaultGradeCoefficients !== undefined) updateData.defaultGradeCoefficients = defaultGradeCoefficients
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, defaultGradeCoefficients: true },
})
res.json({ success: true, data: org })
} catch (err) {
+36 -12
View File
@@ -429,12 +429,14 @@ export async function detectMonthlyTasks(orgId: string) {
}
}
// 社保和公积金按账户 paymentDay 生成提醒
// 社保和公积金按账户 paymentDay 生成提醒,未设置时回退到组织 socialInsCutoffDay
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { socialInsCutoffDay: true } })
const defaultCutoffDay = org?.socialInsCutoffDay || 15
const accounts = await prisma.socialAccount.findMany({
where: { orgId, status: 'ACTIVE', paymentDay: { not: null } },
where: { orgId, status: 'ACTIVE' },
})
for (const acc of accounts) {
const day = acc.paymentDay!
const day = acc.paymentDay || defaultCutoffDay
if (today >= day) {
const deadline = new Date(now.getFullYear(), now.getMonth(), day)
const typeLabel = acc.type === 'SOCIAL' ? '社保' : '公积金'
@@ -721,8 +723,9 @@ export async function getDashboardData(orgId: string) {
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
}),
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
// 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'SOCIAL' } } }).then(async r => r as any || (await prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }) as any)),
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'HOUSING' } } }).then(async r => r as any || (await prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }) as any)),
prisma.laborContract.count({
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
}),
@@ -1498,8 +1501,9 @@ export async function getComplianceScore(orgId: string) {
prisma.attendanceConfirmation.count({ where: { orgId, status: 'PENDING' } }),
prisma.payslip.count({ where: { orgId, confirmedAt: null } }),
prisma.payslip.count({ where: { orgId } }),
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
// 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'SOCIAL' } } }).then(async r => r ? true : !!(await prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'HOUSING' } } }).then(async r => r ? true : !!(await prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
prisma.riskItem.count({
where: {
orgId,
@@ -1519,13 +1523,18 @@ export async function getComplianceScore(orgId: string) {
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
: 100
const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0)
// 社保公积金待办:当月未完成的缴纳任务
const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const socialTodoCount = await prisma.riskItem.count({
where: { orgId, status: 'PENDING', type: 'MONTHLY', title: { startsWith: `${currentMonthStr}月 缴纳` } },
})
const dimensions = [
{ key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + pendingSignContracts + expiringContracts },
{ key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish },
{ key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed },
{ key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips },
{ key: 'social', name: '社保公积金', score: socialScore, todoCount: 0 },
{ key: 'social', name: '社保公积金', score: socialScore, todoCount: socialTodoCount },
]
const overallScore = Math.round(
@@ -1679,10 +1688,17 @@ export async function getHealthCheck(orgId: string) {
// democracyProgress 是 JSONB,用 status 字段判断未完成
prisma.policyDocument.count({ where: { orgId, status: { not: 'PUBLISHED' } } }),
prisma.policyDocument.count({ where: { orgId } }),
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
// 社保记录按员工去重
prisma.employeeSocialInsRecord.groupBy({ by: ['employeeId'], where: { orgId, endMonth: null }, _count: true }).then(r => r.length),
// 社保/公积金配置:优先查 SocialYearStandard(新架构),回退到旧表
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'SOCIAL' } } }).then(async r => r ? true : !!(await prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
prisma.socialYearStandard.findFirst({ where: { orgId, isCurrent: true, account: { type: 'HOUSING' } } }).then(async r => r ? true : !!(await prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }))) as Promise<boolean>,
// 社保记录:计算有正式劳动合同但无社保记录的在职员工数(实习/劳务/外包等不缴社保)
Promise.all([
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } },
select: { id: true },
}),
prisma.employeeSocialInsRecord.groupBy({ by: ['employeeId'], where: { orgId, endMonth: null }, _count: true }).then(r => new Set(r.map(x => x.employeeId))),
]).then(([emps, socialEmpIds]) => emps.filter(e => !socialEmpIds.has(e.id)).length),
prisma.overtimeRecord.count({ where: { orgId, weekdayHours: { gt: 36 } } }),
// 解聘合规
prisma.terminationRecord.count({ where: { orgId } }),
@@ -1703,6 +1719,7 @@ export async function getHealthCheck(orgId: string) {
key: 'contract',
name: '合同管理',
score: Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5),
todoCount: unsignedContracts + pendingSignContracts + expiringContracts,
findings: [
unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已有合同记录',
pendingSignContracts > 0 ? `${pendingSignContracts} 份合同待签署(已创建未完成签署)` : '',
@@ -1720,6 +1737,7 @@ export async function getHealthCheck(orgId: string) {
score: totalPayslips > 0
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
: 100,
todoCount: unconfirmedPayslips,
findings: [
totalPayslips > 0
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
@@ -1737,6 +1755,9 @@ export async function getHealthCheck(orgId: string) {
(housingConfig ? 30 : 0) +
(employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2))
),
todoCount: await prisma.riskItem.count({
where: { orgId, status: 'PENDING', type: 'MONTHLY', title: { startsWith: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}月 缴纳` } },
}),
findings: [
socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置',
housingConfig ? '公积金配置已设置' : '未配置公积金比例',
@@ -1751,6 +1772,7 @@ export async function getHealthCheck(orgId: string) {
key: 'attendance',
name: '考勤加班',
score: Math.max(0, 100 - overtimeExcessive * 10),
todoCount: overtimeExcessive,
findings: [
overtimeExcessive > 0 ? `${overtimeExcessive} 条加班记录月超时36小时,存在违法风险` : '加班时长合规',
attendanceRecords > 0 ? `已记录 ${attendanceRecords} 条考勤数据` : '暂无考勤记录,建议建立考勤制度',
@@ -1766,6 +1788,7 @@ export async function getHealthCheck(orgId: string) {
score: totalPolicies > 0
? Math.max(0, 100 - policiesWithoutPublish * 12)
: 60,
todoCount: policiesWithoutPublish,
findings: [
totalPolicies === 0 ? '尚未上传任何规章制度,建议建立基础制度体系' : `已管理 ${totalPolicies} 项制度`,
policiesWithoutPublish > 0 ? `${policiesWithoutPublish} 项制度未完成民主程序,仲裁中可能无效` : '所有制度已完成民主公示',
@@ -1784,6 +1807,7 @@ export async function getHealthCheck(orgId: string) {
(terminationsWithChecklist / Math.max(1, completedTerminations)) * 40
)
: 100,
todoCount: totalTerminations - completedTerminations,
findings: [
totalTerminations > 0
? `${totalTerminations} 起解聘,${completedTerminations} 起已完成流程`