feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
@@ -5,6 +5,7 @@ import { createEvidence } from '../services/evidence.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt, encrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
import { calcSocialInsurance, calcHousingFund } from '../services/payroll.service'
|
||||
import ExcelJS from 'exceljs'
|
||||
|
||||
const router = Router()
|
||||
@@ -120,6 +121,32 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
}),
|
||||
])
|
||||
|
||||
// 获取社保和公积金配置(按城市缓存)
|
||||
const currentMonth = new Date().toISOString().slice(0, 7)
|
||||
const configCache = new Map<string, { social?: any; housing?: any }>()
|
||||
const getConfigsForCity = async (city?: string) => {
|
||||
const key = city || '_default'
|
||||
if (configCache.has(key)) return configCache.get(key)!
|
||||
const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId }
|
||||
const [socialCfg, housingCfg] = await Promise.all([
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
const result = { social: socialCfg, housing: housingCfg }
|
||||
configCache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// 预加载所有涉及城市的配置
|
||||
const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[]
|
||||
await Promise.all(cities.map((c) => getConfigsForCity(c)))
|
||||
|
||||
// 计算动态状态和合同状态
|
||||
let result = employees.map((e) => {
|
||||
const latestContract = e.contracts[0] || null
|
||||
@@ -173,6 +200,20 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
idCardMasked,
|
||||
idCardNumber: safeDecryptStr(e.idCardNumber),
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
socialInsBase: e.socialInsBase,
|
||||
housingFundBase: e.housingFundBase,
|
||||
socialInsCalc: (() => {
|
||||
const cfgs = configCache.get(e.city || '_default')
|
||||
if (!cfgs?.social || !e.socialInsBase) return null
|
||||
const r = calcSocialInsurance(e.socialInsBase, cfgs.social)
|
||||
return { socialEmp: r.socialEmp, socialOrg: r.socialOrg }
|
||||
})(),
|
||||
housingFundCalc: (() => {
|
||||
const cfgs = configCache.get(e.city || '_default')
|
||||
if (!cfgs?.housing || !e.housingFundBase) return null
|
||||
const r = calcHousingFund(e.housingFundBase, cfgs.housing)
|
||||
return { housingEmp: r.housingEmp, housingOrg: r.housingOrg }
|
||||
})(),
|
||||
isPregnant: e.isPregnant,
|
||||
isInMedicalPeriod: e.isInMedicalPeriod,
|
||||
isWorkInjured: e.isWorkInjured,
|
||||
@@ -767,7 +808,11 @@ router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next)
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
const ackStatus = (req.query.ackStatus as string) || ''
|
||||
const where: any = { orgId }
|
||||
if (ackStatus) {
|
||||
where.ackStatus = ackStatus
|
||||
}
|
||||
if (keyword) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, name: { contains: keyword } },
|
||||
@@ -789,6 +834,35 @@ router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 培训记录催办(发送通知给未签收员工)
|
||||
router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId },
|
||||
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
|
||||
})
|
||||
if (!record) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } })
|
||||
}
|
||||
if (record.ackStatus !== 'PENDING') {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } })
|
||||
}
|
||||
// 记录催办通知日志
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId,
|
||||
type: 'TRAINING_REMIND',
|
||||
title: `培训签收催办:${record.topic}`,
|
||||
content: `员工 ${record.employee.name}(${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`,
|
||||
channel: 'SYSTEM',
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}」` } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 绩效记录列表(全员)
|
||||
router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
@@ -1074,6 +1148,33 @@ router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, re
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 批量创建培训记录
|
||||
router.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body
|
||||
if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) {
|
||||
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } })
|
||||
}
|
||||
const results = await Promise.all(employeeIds.map((empId: string) =>
|
||||
prisma.trainingRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: empId,
|
||||
trainingDate: new Date(trainingDate),
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration: duration || 0,
|
||||
ackStatus: 'PENDING',
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
))
|
||||
res.json({ success: true, data: { count: results.length } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||||
@@ -1124,29 +1225,35 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
|
||||
|
||||
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||||
const { period, periodType, score, grade, 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: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
period,
|
||||
periodType: periodType || 'MONTHLY',
|
||||
score: score || 0,
|
||||
grade: grade || 'B',
|
||||
result: result || 'QUALIFIED',
|
||||
summary,
|
||||
improvementPlan,
|
||||
templateId: templateId || null,
|
||||
dimensionScores: dimensionScores || undefined,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
periodType,
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
templateId: templateId || null,
|
||||
dimensionScores: dimensionScores || undefined,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
@@ -1159,7 +1266,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
|
||||
|
||||
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||||
const { period, periodType, score, grade, 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 },
|
||||
})
|
||||
@@ -1168,11 +1275,14 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
period,
|
||||
periodType,
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
templateId: templateId || null,
|
||||
dimensionScores: dimensionScores || undefined,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
@@ -1193,6 +1303,72 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req:
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 绩效模板 CRUD ==========
|
||||
|
||||
// 获取模板列表
|
||||
router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const templates = await prisma.performanceTemplate.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: templates })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 创建模板
|
||||
router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, description, dimensions, gradeRules, isDefault } = req.body
|
||||
if (!name || !dimensions || !Array.isArray(dimensions)) {
|
||||
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } })
|
||||
}
|
||||
// 如果设为默认,先取消其他默认
|
||||
if (isDefault) {
|
||||
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
const template = await prisma.performanceTemplate.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
name,
|
||||
description,
|
||||
dimensions,
|
||||
gradeRules: gradeRules || undefined,
|
||||
isDefault: isDefault || false,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: template })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 更新模板
|
||||
router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, description, dimensions, gradeRules, isDefault } = req.body
|
||||
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
|
||||
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
if (isDefault) {
|
||||
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
|
||||
}
|
||||
const updated = await prisma.performanceTemplate.update({
|
||||
where: { id: req.params.id },
|
||||
data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault },
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 删除模板
|
||||
router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
|
||||
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
await prisma.performanceTemplate.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 调薪/调部门 API ==========
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
|
||||
Reference in New Issue
Block a user