feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)

P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
freedakgmail
2026-08-09 11:59:02 +08:00
parent c355a7d208
commit a2e9ba55c2
43 changed files with 2913 additions and 324 deletions
+19
View File
@@ -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)
}
+7
View File
@@ -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
+151 -50
View File
@@ -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)
})
+82
View File
@@ -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({
+52 -5
View File
@@ -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
+178 -2
View File
@@ -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 {
+8 -2
View File
@@ -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)
}
+20
View File
@@ -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
+2
View File
@@ -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({
+7 -6
View File
@@ -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,
}
})
+49 -6
View File
@@ -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) {
+12 -3
View File
@@ -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 }
}
+24 -22
View File
@@ -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: '' }