feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user