feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
@@ -170,6 +170,7 @@ model Organization {
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
performanceTemplates PerformanceTemplate[]
|
||||
retirementPolicies RetirementPolicy[]
|
||||
socialMonthlyProcesses SocialMonthlyProcess[]
|
||||
evidenceChains EvidenceChain[]
|
||||
@@ -644,11 +645,14 @@ model PerformanceRecord {
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
period String // 考核周期 YYYY-MM 或 YYYY-Q1
|
||||
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
|
||||
score Float @default(0) // 考核得分
|
||||
grade String @default("B") // A/B/C/D
|
||||
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
|
||||
summary String? // 考核评语
|
||||
improvementPlan String? // 改进计划(不胜任时)
|
||||
templateId String? // 关联绩效模板(选填)
|
||||
dimensionScores Json? // 各维度得分明细 { dimensionName: score }
|
||||
employeeAck Boolean @default(false)
|
||||
ackDate DateTime?
|
||||
reviewer String?
|
||||
@@ -659,6 +663,22 @@ model PerformanceRecord {
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model PerformanceTemplate {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String // 模板名称
|
||||
description String? // 模板说明
|
||||
dimensions Json // 考核维度 [{ name, weight, maxScore, description }]
|
||||
gradeRules Json? // 等级规则 [{ grade, result, minScore, maxScore }]
|
||||
isDefault Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId])
|
||||
}
|
||||
|
||||
// ========== 员工端表 ==========
|
||||
|
||||
model Payslip {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { sha256 } from '../lib/crypto'
|
||||
import {
|
||||
createEmployeeSchema,
|
||||
updateEmployeeSchema,
|
||||
@@ -97,6 +98,24 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 身份证查重
|
||||
router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const idCard = req.query.idCard as string
|
||||
if (!idCard || idCard.length < 18) {
|
||||
return res.json({ success: true, data: { exists: false } })
|
||||
}
|
||||
const hash = sha256(idCard)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { orgId: req.user!.orgId, idCardHash: hash },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
res.json({ success: true, data: { exists: !!employee, employee } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createEmployeeSchema.parse(req.body)
|
||||
|
||||
@@ -146,10 +146,17 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 包装为 HTML 格式以确保 Word 正确打开
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(template.content)
|
||||
res.send(htmlContent)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -359,9 +359,16 @@ router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
const status = req.query.status as string | undefined
|
||||
const department = req.query.department as string | undefined
|
||||
const search = req.query.search as string | undefined
|
||||
const dateFrom = req.query.dateFrom as string | undefined
|
||||
const dateTo = req.query.dateTo as string | undefined
|
||||
|
||||
const where: any = { orgId }
|
||||
if (status) where.status = status
|
||||
if (dateFrom || dateTo) {
|
||||
where.terminationDate = {}
|
||||
if (dateFrom) where.terminationDate.gte = new Date(dateFrom)
|
||||
if (dateTo) where.terminationDate.lte = new Date(dateTo + 'T23:59:59')
|
||||
}
|
||||
if (department || search) {
|
||||
where.employee = {}
|
||||
if (department) where.employee.department = department
|
||||
|
||||
@@ -541,12 +541,22 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
}
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
|
||||
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
|
||||
const result: any = { month, attendance: 0, overtime: 0, discipline: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '违纪记录': '追加(同员工同日可多条)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
|
||||
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
|
||||
const empByName = new Map(employees.map(e => [e.name, e]))
|
||||
|
||||
// 获取加班费配置,用于自动计算 totalPay
|
||||
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
|
||||
function calcOvertimePay(monthlyWage: number, wdHours: number, weHours: number, hoHours: number) {
|
||||
const hourlyWage = (monthlyWage || 0) / otConfig.monthlyDays / otConfig.dailyHours
|
||||
const weekdayPay = hourlyWage * otConfig.weekdayRate * wdHours
|
||||
const weekendPay = hourlyWage * otConfig.weekendRate * weHours
|
||||
const holidayPay = hourlyWage * otConfig.holidayRate * hoHours
|
||||
return Math.round((weekdayPay + weekendPay + holidayPay) * 100) / 100
|
||||
}
|
||||
|
||||
function findEmp(r: any) {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
if (idCard) {
|
||||
@@ -556,56 +566,109 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
return empByName.get(val(getField(r, '姓名')))
|
||||
}
|
||||
|
||||
// 考勤记录
|
||||
// 考勤记录 + 加班记录(支持合并Sheet"考勤与加班"或独立Sheet)
|
||||
const mergedSheet = wb.Sheets['考勤与加班']
|
||||
const attSheet = wb.Sheets['考勤记录']
|
||||
if (attSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
|
||||
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
|
||||
await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: emp.id, date } },
|
||||
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
|
||||
update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
|
||||
})
|
||||
result.attendance++
|
||||
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
// 加班记录
|
||||
const otSheet = wb.Sheets['加班记录']
|
||||
if (otSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(otSheet)
|
||||
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
|
||||
|
||||
if (mergedSheet) {
|
||||
// 合并Sheet:每行同时处理考勤和加班
|
||||
const rows = XLSX.utils.sheet_to_json(mergedSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
if (!emp) { result.errors.push(`第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
|
||||
const otMonth = dateToMonth(date)
|
||||
const hours = num(getField(r, '加班时长'))
|
||||
const otType = val(getField(r, '加班类型')) || '工作日加班'
|
||||
const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0)
|
||||
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
|
||||
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
|
||||
update: {
|
||||
weekdayHours: { increment: wdHours },
|
||||
weekendHours: { increment: weHours },
|
||||
holidayHours: { increment: hoHours },
|
||||
},
|
||||
})
|
||||
result.overtime++
|
||||
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
if (!date) { result.errors.push(`第${i + 2}行:日期格式错误`); continue }
|
||||
|
||||
// 考勤部分
|
||||
const attStatus = val(getField(r, '考勤状态'))
|
||||
if (attStatus || val(getField(r, '上班时间')) || val(getField(r, '下班时间'))) {
|
||||
await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: emp.id, date } },
|
||||
create: { orgId, employeeId: emp.id, date, status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
|
||||
update: { status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
|
||||
})
|
||||
result.attendance++
|
||||
}
|
||||
|
||||
// 加班部分
|
||||
const wdHours = num(getField(r, '工作日加班时长'))
|
||||
const weHours = num(getField(r, '休息日加班时长'))
|
||||
const hoHours = num(getField(r, '法定节假日加班时长'))
|
||||
if (wdHours > 0 || weHours > 0 || hoHours > 0) {
|
||||
const otMonth = dateToMonth(date)
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
|
||||
await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
|
||||
update: {
|
||||
weekdayHours: { increment: wdHours },
|
||||
weekendHours: { increment: weHours },
|
||||
holidayHours: { increment: hoHours },
|
||||
totalPay: { increment: totalPay },
|
||||
},
|
||||
})
|
||||
result.overtime++
|
||||
}
|
||||
} catch (e: any) { result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
} else {
|
||||
// 向后兼容:独立Sheet
|
||||
if (attSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
|
||||
await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: emp.id, date } },
|
||||
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
|
||||
update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
|
||||
})
|
||||
result.attendance++
|
||||
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
if (otSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(otSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
|
||||
const otMonth = dateToMonth(date)
|
||||
const hours = num(getField(r, '加班时长'))
|
||||
const otType = val(getField(r, '加班类型')) || '工作日加班'
|
||||
const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0)
|
||||
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
|
||||
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
|
||||
await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
|
||||
update: {
|
||||
weekdayHours: { increment: wdHours },
|
||||
weekendHours: { increment: weHours },
|
||||
holidayHours: { increment: hoHours },
|
||||
totalPay: { increment: totalPay },
|
||||
},
|
||||
})
|
||||
result.overtime++
|
||||
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,6 +748,27 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
}
|
||||
}
|
||||
|
||||
// 违纪记录
|
||||
const discSheet = wb.Sheets['违纪记录']
|
||||
if (discSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(discSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
if (!date) { result.errors.push(`违纪第${i + 2}行:日期格式错误`); continue }
|
||||
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
|
||||
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
|
||||
await prisma.disciplinaryRecord.create({
|
||||
data: { orgId, employeeId: emp.id, violationDate: date, violationType: typeMap[val(getField(r, '违纪类型'))] || 'OTHER', description: val(getField(r, '描述')) || '', action: actMap[val(getField(r, '处罚'))] || 'ORAL_WARNING', createdBy: userId },
|
||||
})
|
||||
result.discipline++
|
||||
} catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -694,11 +778,25 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
|
||||
|
||||
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
|
||||
const attOtData = [{
|
||||
'姓名': '张三',
|
||||
'身份证号': '110101199001011234',
|
||||
'日期': '2024-06-01',
|
||||
'考勤状态': '正常',
|
||||
'上班时间': '09:00',
|
||||
'下班时间': '18:00',
|
||||
'工作日加班时长': 0,
|
||||
'休息日加班时长': 0,
|
||||
'法定节假日加班时长': 0,
|
||||
'备注': '',
|
||||
}]
|
||||
const attOtWs = XLSX.utils.json_to_sheet(attOtData)
|
||||
attOtWs['!cols'] = [
|
||||
{ wch: 10 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 8 },
|
||||
{ wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 12 },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
|
||||
|
||||
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
|
||||
@@ -709,9 +807,12 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
|
||||
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
|
||||
|
||||
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx'))
|
||||
res.setHeader('Content-Disposition', contentDisposition('考勤月度导入模板.xlsx'))
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
|
||||
@@ -129,6 +129,88 @@ router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFu
|
||||
}
|
||||
})
|
||||
|
||||
// 从考勤记录同步加班工时
|
||||
router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { month } = req.body as { month: string }
|
||||
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
|
||||
return res.status(400).json({ success: false, message: '请提供有效的月份(YYYY-MM)' })
|
||||
}
|
||||
|
||||
const monthStart = new Date(month + '-01')
|
||||
const monthEnd = new Date(monthStart)
|
||||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||||
|
||||
// 获取该月所有考勤记录(含加班工时)
|
||||
const records = await prisma.attendanceRecord.findMany({
|
||||
where: { orgId, date: { gte: monthStart, lt: monthEnd }, overtimeHours: { gt: 0 } },
|
||||
})
|
||||
|
||||
if (records.length === 0) {
|
||||
return res.json({ success: false, message: '该月考勤记录中无加班工时' })
|
||||
}
|
||||
|
||||
// 按员工汇总加班工时,按日期类型分类
|
||||
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
|
||||
for (const r of records) {
|
||||
const day = new Date(r.date)
|
||||
const dayOfWeek = day.getDay() // 0=周日, 6=周六
|
||||
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
type = 'weekend'
|
||||
}
|
||||
// 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置
|
||||
// 如果有 holidayHours 字段在 attendanceRecord 中,优先使用
|
||||
|
||||
if (!empMap.has(r.employeeId)) {
|
||||
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
|
||||
}
|
||||
const entry = empMap.get(r.employeeId)!
|
||||
entry[type] += r.overtimeHours || 0
|
||||
}
|
||||
|
||||
// 获取员工月工资用于计算加班费
|
||||
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
|
||||
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
|
||||
|
||||
let synced = 0
|
||||
for (const [employeeId, hours] of empMap) {
|
||||
const emp = await prisma.employee.findFirst({ where: { id: employeeId }, select: { monthlySalary: true } })
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = emp?.monthlySalary ? Number(decrypt(emp.monthlySalary)) : 0 } catch { monthlyWage = Number(emp?.monthlySalary) || 0 }
|
||||
|
||||
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
|
||||
const weekdayPay = hourlyWage * config.weekdayRate * hours.weekday
|
||||
const weekendPay = hourlyWage * config.weekendRate * hours.weekend
|
||||
const holidayPay = hourlyWage * config.holidayRate * hours.holiday
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
weekdayHours: hours.weekday,
|
||||
weekendHours: hours.weekend,
|
||||
holidayHours: hours.holiday,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId, employeeId, month,
|
||||
weekdayHours: hours.weekday,
|
||||
weekendHours: hours.weekend,
|
||||
holidayHours: hours.holiday,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
})
|
||||
synced++
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { synced, totalEmployees: empMap.size } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工资条管理 ==========
|
||||
|
||||
const payslipSchema = z.object({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../lib/prisma'
|
||||
import {
|
||||
@@ -90,24 +91,26 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, ne
|
||||
router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } })
|
||||
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
|
||||
if (!policy) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
|
||||
}
|
||||
const [totalEmployees, readRecords] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
const [allEmployees, readRecords] = await Promise.all([
|
||||
prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, select: { id: true, name: true, department: true }, orderBy: { name: 'asc' } }),
|
||||
prisma.policyReadRecord.findMany({
|
||||
where: { policyId: req.params.id, orgId },
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { readAt: 'desc' },
|
||||
}),
|
||||
])
|
||||
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
|
||||
const unreadEmployees = allEmployees.filter(e => !readEmpIds.has(e.id))
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total: totalEmployees,
|
||||
total: allEmployees.length,
|
||||
readCount: readRecords.length,
|
||||
unreadCount: totalEmployees - readRecords.length,
|
||||
unreadCount: unreadEmployees.length,
|
||||
records: readRecords.map(r => ({
|
||||
employeeId: r.employeeId,
|
||||
employeeName: r.employee.name,
|
||||
@@ -115,6 +118,11 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
|
||||
readAt: r.readAt.toISOString(),
|
||||
ip: r.ip,
|
||||
})),
|
||||
unreadEmployees: unreadEmployees.map(e => ({
|
||||
employeeId: e.id,
|
||||
employeeName: e.name,
|
||||
department: e.department,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -122,4 +130,43 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
|
||||
}
|
||||
})
|
||||
|
||||
/** 催办未签收员工 */
|
||||
router.post('/:id/remind', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
|
||||
if (!policy) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
|
||||
}
|
||||
const { employeeIds } = req.body as { employeeIds?: string[] }
|
||||
const readRecords = await prisma.policyReadRecord.findMany({ where: { policyId: req.params.id, orgId }, select: { employeeId: true } })
|
||||
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
|
||||
const targetEmployees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId, status: 'ACTIVE',
|
||||
id: employeeIds && employeeIds.length > 0 ? { in: employeeIds } : undefined,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
const unreadEmployees = targetEmployees.filter(e => !readEmpIds.has(e.id))
|
||||
// 创建催办通知
|
||||
for (const emp of unreadEmployees) {
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: emp.id,
|
||||
type: 'POLICY_REMIND',
|
||||
title: `制度签收提醒:${policy.title}`,
|
||||
content: `您有一项制度「${policy.title}」尚未签收,请尽快完成阅读确认。`,
|
||||
channel: 'IN_APP',
|
||||
status: 'SENT',
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
res.json({ success: true, data: { reminded: unreadEmployees.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,10 +49,16 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(template.content)
|
||||
res.send(htmlContent)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -340,4 +340,24 @@ router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest,
|
||||
}
|
||||
})
|
||||
|
||||
// 删除草稿(仅允许 DRAFT 和 CANCELLED 状态)
|
||||
router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.terminationRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
}
|
||||
if (record.status !== 'DRAFT' && record.status !== 'CANCELLED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅草稿或已撤销的记录可以删除' } })
|
||||
}
|
||||
await prisma.terminationRecord.delete({ where: { id: req.params.id } })
|
||||
await auditLog(req, 'DELETE_DRAFT', 'TERMINATION_RECORD', req.params.id, { employeeId: record.employeeId })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -14,6 +14,7 @@ export const createEmployeeSchema = z.object({
|
||||
isWorkInjured: z.boolean().default(false),
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
position: z.string().max(50).optional(),
|
||||
contract: z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
@@ -48,6 +49,7 @@ export const updateEmployeeSchema = z.object({
|
||||
specialDeduction: z.number().min(0).optional(),
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
position: z.string().max(50).optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
|
||||
@@ -279,8 +279,8 @@ export async function manualCorrectAttendance(orgId: string, data: {
|
||||
where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } },
|
||||
})
|
||||
|
||||
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}`).toISOString() : null
|
||||
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}`).toISOString() : null
|
||||
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}:00Z`).toISOString() : null
|
||||
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}:00Z`).toISOString() : null
|
||||
|
||||
let workHours = 0
|
||||
if (checkInTime && checkOutTime) {
|
||||
@@ -388,10 +388,11 @@ export async function getMonthlyReport(orgId: string, month: string) {
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
const otMap = new Map<string, number>()
|
||||
const otMap = new Map<string, { hours: number; pay: number }>()
|
||||
for (const ot of overtimes) {
|
||||
const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0)
|
||||
otMap.set(ot.employeeId, (otMap.get(ot.employeeId) || 0) + totalHours)
|
||||
const prev = otMap.get(ot.employeeId) || { hours: 0, pay: 0 }
|
||||
otMap.set(ot.employeeId, { hours: prev.hours + totalHours, pay: prev.pay + (ot.totalPay || 0) })
|
||||
}
|
||||
|
||||
const leaveMap = new Map<string, number>()
|
||||
@@ -412,8 +413,8 @@ export async function getMonthlyReport(orgId: string, month: string) {
|
||||
earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length,
|
||||
absentDays: empRecords.filter(r => r.status === 'ABSENT').length,
|
||||
leaveDays: leaveMap.get(emp.id) || 0,
|
||||
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id) || 0),
|
||||
overtimePay: confirmation?.overtimePay || 0,
|
||||
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id)?.hours || 0),
|
||||
overtimePay: confirmation?.overtimePay || otMap.get(emp.id)?.pay || 0,
|
||||
confirmationStatus: confirmation?.status || null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,24 @@ function dateToMonth(date: Date): string {
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, ...(city ? { city } : {}) },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
return base
|
||||
}
|
||||
|
||||
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, ...(city ? { city } : {}) },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
return base
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
@@ -191,6 +209,17 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
// 身份证号查重
|
||||
if (data.idCardNumber) {
|
||||
const existing = await prisma.employee.findFirst({
|
||||
where: { orgId, idCardHash: sha256(data.idCardNumber) },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
if (existing) {
|
||||
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}(${existing.department},${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
|
||||
}
|
||||
}
|
||||
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId } })
|
||||
if (org && org.maxEmployees > 0) {
|
||||
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
|
||||
@@ -202,8 +231,11 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const hireDate = new Date(data.hireDate)
|
||||
const hireMonth = dateToMonth(hireDate)
|
||||
const salaryNum = Number(data.monthlySalary) || 0
|
||||
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const city = data.city || '北京'
|
||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
|
||||
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
|
||||
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
|
||||
|
||||
@@ -230,6 +262,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
education: data.education || null,
|
||||
position: data.position || null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -346,8 +379,11 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
|
||||
const newHireMonth = dateToMonth(newHireDate)
|
||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const city = data.city || employee.city || '北京'
|
||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
|
||||
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
|
||||
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
|
||||
const prevHireMonth = prevMonth(newHireMonth)
|
||||
@@ -543,11 +579,18 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
|
||||
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
|
||||
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
|
||||
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
|
||||
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
|
||||
if (data.socialInsBase !== undefined) {
|
||||
const city = data.city || employee.city || '北京'
|
||||
updateData.socialInsBase = await clampSocialInsBase(orgId, Number(data.socialInsBase), city)
|
||||
}
|
||||
if (data.housingFundBase !== undefined) {
|
||||
const city = data.city || employee.city || '北京'
|
||||
updateData.housingFundBase = await clampHousingFundBase(orgId, Number(data.housingFundBase), city)
|
||||
}
|
||||
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
if (data.education !== undefined) updateData.education = data.education
|
||||
if (data.position !== undefined) updateData.position = data.position
|
||||
|
||||
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
|
||||
if (data.city !== undefined && data.city !== employee.city) {
|
||||
|
||||
@@ -185,6 +185,7 @@ export async function verifyAllEvidence(orgId: string) {
|
||||
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
const invalidItems: any[] = []
|
||||
for (const r of records) {
|
||||
const events = r.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
@@ -198,8 +199,16 @@ export async function verifyAllEvidence(orgId: string) {
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
|
||||
if (sortedHash === r.hash) valid++
|
||||
else invalid++
|
||||
if (sortedHash === r.hash) { valid++; continue }
|
||||
invalid++
|
||||
invalidItems.push({
|
||||
id: r.id,
|
||||
category: r.category,
|
||||
refId: r.refId,
|
||||
employeeId: r.employeeId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`,
|
||||
})
|
||||
}
|
||||
return { total: records.length, valid, invalid }
|
||||
return { total: records.length, valid, invalid, invalidItems }
|
||||
}
|
||||
|
||||
@@ -260,29 +260,31 @@ export async function generateDocument(type: string, formData: any, orgName: str
|
||||
}
|
||||
}
|
||||
|
||||
const wrapHtml = (title: string, body: string) => `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
|
||||
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
|
||||
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="title">${title}</div>
|
||||
${body}
|
||||
</body></html>`
|
||||
|
||||
const templates: Record<string, (data: any, org: string) => string> = {
|
||||
INCOME_CERT: (data, org) => `收入证明
|
||||
|
||||
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。
|
||||
|
||||
该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。
|
||||
|
||||
本证明仅用于 ${data.purpose || '___'},不作其他用途。
|
||||
|
||||
特此证明。
|
||||
|
||||
${org}
|
||||
${new Date().toLocaleDateString('zh-CN')}`,
|
||||
LEAVING_CERT: (data, org) => `离职证明
|
||||
|
||||
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。
|
||||
|
||||
该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。
|
||||
|
||||
特此证明。
|
||||
|
||||
${org}
|
||||
${new Date().toLocaleDateString('zh-CN')}`,
|
||||
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
|
||||
<div class="body">该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。</div>
|
||||
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
|
||||
<div class="body">特此证明。</div>
|
||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
|
||||
<div class="body">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
|
||||
<div class="body">特此证明。</div>
|
||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||
}
|
||||
const generator = templates[type]
|
||||
if (!generator) return { name: '', content: '' }
|
||||
|
||||
Reference in New Issue
Block a user