feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税

- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化
- 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口
- 所有导入模板表头标注必填项(*后缀)并含示例行
- 导入逻辑统一改用getField兼容*后缀列名
- 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题
- 更新需求梳理文档
This commit is contained in:
freedakgmail
2026-07-29 19:08:45 +08:00
parent 7c24ebe3d9
commit 0372cbe243
14 changed files with 1275 additions and 171 deletions
+214 -114
View File
@@ -93,6 +93,23 @@ function num(v: any): number {
return isNaN(n) ? 0 : n
}
// 兼容带 * 后缀的列名和旧列名
function getField(row: any, ...keys: string[]): any {
for (const k of keys) {
if (row[k] != null && String(row[k]).trim() !== '') return row[k]
// 尝试带 * 后缀
if (row[k + '*'] != null && String(row[k + '*']).trim() !== '') return row[k + '*']
}
// 模糊匹配:遍历 row 的 key,去除 * 后比较
for (const rk of Object.keys(row)) {
const normalized = rk.replace(/\*.*$/, '').replace(/.*$/, '')
for (const k of keys) {
if (normalized === k && row[rk] != null && String(row[rk]).trim() !== '') return row[rk]
}
}
return undefined
}
// ========== 导入预览(不写入数据库) ==========
router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
@@ -107,9 +124,9 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), city: val(r['参保城市']) || '北京', status: 'normal', errors: [] as string[], warnings: [] as string[] }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || '北京', status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(r['入职日期'])
const hireDate = parseDate(getField(r, '入职日期'))
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
if (row.salary === 0) { row.status = 'error'; row.errors.push('月工资为空') }
if (row.idCard) {
@@ -127,9 +144,9 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(contractSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const sd = parseDate(r['合同开始日期'])
const sd = parseDate(getField(r, '合同开始日期'))
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
preview.contracts.push(row)
@@ -141,10 +158,10 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const otType = val(r['加班类型']) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] }
const otType = val(getField(r, '加班类型')) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const dt = parseDate(r['日期'])
const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
preview.overtime.push(row)
@@ -156,7 +173,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(r['描述']), status: 'normal', errors: [] as string[] }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
preview.disciplinary.push(row)
@@ -168,9 +185,9 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const dt = parseDate(r['日期'])
const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
preview.attendance.push(row)
@@ -226,7 +243,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const userId = req.user!.id
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, errors: [] as string[] }
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, skipped: 0, duplicates: 0, errors: [] as string[], details: [] as any[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
@@ -234,18 +251,18 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const name = val(r['姓名'])
if (!name) { result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); continue }
const dept = val(r['部门']) || '未分配'
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { result.errors.push(`员工第${i + 2}行:入职日期格式错误`); continue }
const salary = String(num(r['月工资']))
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
const name = val(getField(r, '姓名'))
if (!name) { result.skipped++; result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name: '', status: 'skipped', message: '姓名为空' }); continue }
const dept = val(getField(r, '部门')) || '未分配'
const hireDate = parseDate(getField(r, '入职日期'))
if (!hireDate) { result.skipped++; result.errors.push(`员工第${i + 2}行:入职日期格式错误`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '入职日期格式错误' }); continue }
const salary = String(num(getField(r, '月工资')))
if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue }
let idCard = val(r['身份证号'])
let idCard = val(getField(r, '身份证号'))
if (idCard) {
const idCheck = validateIdCard(idCard)
if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue }
if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue }
if (idCheck.upgraded) idCard = idCheck.upgraded
}
@@ -253,33 +270,33 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
data: {
orgId, name, department: dept, hireDate,
monthlySalary: encrypt(salary),
gender: val(r['性别']) || (idCard ? extractGenderFromIdCard(idCard) : null),
femaleWorkerType: (val(r['女性岗位类型']) === '工人' || val(r['女性岗位类型']) === 'WORKER') ? 'WORKER'
: (val(r['女性岗位类型']) === '干部' || val(r['女性岗位类型']) === 'CADRE') ? 'CADRE' : null,
phone: val(r['手机号']) || null,
gender: val(getField(r, '性别')) || (idCard ? extractGenderFromIdCard(idCard) : null),
femaleWorkerType: (val(getField(r, '女性岗位类型')) === '工人' || val(getField(r, '女性岗位类型')) === 'WORKER') ? 'WORKER'
: (val(getField(r, '女性岗位类型')) === '干部' || val(getField(r, '女性岗位类型')) === 'CADRE') ? 'CADRE' : null,
phone: val(getField(r, '手机号')) || null,
idCardNumber: idCard ? encrypt(idCard) : null,
idCardHash: idCard ? sha256(idCard) : null,
birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null,
emergencyContact: val(r['紧急联系人']) || null,
emergencyPhone: val(r['紧急联系电话']) || null,
address: val(r['住址']) || null,
bankName: val(r['开户行']) || null,
bankAccount: val(r['银行账号']) ? encrypt(val(r['银行账号'])) : null,
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
city: val(r['参保城市']) || '北京',
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
emergencyContact: val(getField(r, '紧急联系人')) || null,
emergencyPhone: val(getField(r, '紧急联系电话')) || null,
address: val(getField(r, '住址')) || null,
bankName: val(getField(r, '开户行')) || null,
bankAccount: val(getField(r, '银行账号')) ? encrypt(val(getField(r, '银行账号'))) : null,
socialInsBase: num(getField(r, '社保基数')) || num(salary),
housingFundBase: num(getField(r, '公积金基数')) || num(salary),
specialDeduction: num(getField(r, '专项附加扣除')) || 0,
city: val(getField(r, '参保城市')) || '北京',
isPregnant: val(getField(r, '孕期')) === '是',
isInMedicalPeriod: val(getField(r, '医疗期')) === '是',
isWorkInjured: val(getField(r, '工伤')) === '是',
socialInsStartMonth: dateToMonth(hireDate),
housingFundStartMonth: dateToMonth(hireDate),
createdBy: userId,
},
})
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['社保基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(getField(r, '社保基数')) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(getField(r, '公积金基数')) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
@@ -290,11 +307,22 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
})
result.employees++
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'success', message: '导入成功' })
} catch (e: any) {
const msg = e?.message || ''
if (msg.includes('Unique constraint')) result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
else if (msg.includes('invalid') || msg.includes('validation')) result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
else result.errors.push(`员工第${i + 2}行:导入失败 — ${msg || '未知错误'}`)
if (msg.includes('Unique constraint')) {
result.duplicates++
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' })
} else if (msg.includes('invalid') || msg.includes('validation')) {
result.skipped++
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'error', message: '数据格式不正确' })
} else {
result.skipped++
result.errors.push(`员工第${i + 2}行:导入失败 — ${msg || '未知错误'}`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'error', message: msg || '未知错误' })
}
}
}
}
@@ -308,25 +336,25 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const startDate = parseDate(r['合同开始日期'])
const idCard = val(getField(r, '身份证号'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const startDate = parseDate(getField(r, '合同开始日期'))
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
const contractType = typeMap[val(getField(r, '合同类型'))] || 'FIXED'
if (contractType !== 'UNSIGNED') {
await prisma.laborContract.create({
data: {
orgId, employeeId: empId,
signDate: parseDate(r['签订日期']) || null,
signDate: parseDate(getField(r, '签订日期')) || null,
startDate,
endDate: parseDate(r['合同结束日期']) || null,
endDate: parseDate(getField(r, '合同结束日期')) || null,
contractType,
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(r['合同年限']) || 3,
probationMonths: num(r['试用期月数']) || 0,
probationSalary: num(r['试用期工资']) || 0,
signMethod: val(getField(r, '签订方式')) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(getField(r, '合同年限')) || 3,
probationMonths: num(getField(r, '试用期月数')) || 0,
probationSalary: num(getField(r, '试用期工资')) || 0,
createdBy: userId,
},
})
@@ -354,17 +382,17 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
const idCard = val(getField(r, '身份证号'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const month = dateToMonth(date)
const otType = val(r['加班类型']) || '工作日加班'
const hours = num(r['加班时长'])
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
const otType = val(getField(r, '加班类型')) || '工作日加班'
const hours = num(getField(r, '加班时长'))
const weekdayHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0)
const weekendHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
const holidayHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours } })
result.overtime++
} catch (e: any) {
@@ -384,19 +412,19 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
const idCard = val(getField(r, '身份证号'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { 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 sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(getField(r, '违纪类型'))] || 'OTHER', description: val(getField(r, '描述')), severity: sevMap[val(getField(r, '严重程度'))] || 'WARNING', action: actMap[val(getField(r, '处罚'))] || 'ORAL_WARNING', createdBy: userId } })
await createEvidence({
orgId, category: 'DISCIPLINARY', refId: undefined, employeeId: empId,
events: [{ action: `违纪记录创建(${val(r['违纪类型']) || '其他'}`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
events: [{ action: `违纪记录创建(${val(getField(r, '违纪类型')) || '其他'}`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
createdBy: userId,
})
@@ -417,17 +445,17 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
const idCard = val(getField(r, '身份证号'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { 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.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId } })
await createEvidence({
orgId, category: 'ATTENDANCE', refId: undefined, employeeId: empId,
events: [{ action: `考勤记录导入(${val(r['考勤状态']) || '正常'}`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
events: [{ action: `考勤记录导入(${val(getField(r, '考勤状态')) || '正常'}`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
createdBy: userId,
})
@@ -450,27 +478,30 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '身份证号', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
// 设置示例行样式(灰色背景)
empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }]
XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
const contractData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
{ '姓名*': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
@@ -500,12 +531,12 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const empByName = new Map(employees.map(e => [e.name, e]))
function findEmp(r: any) {
const idCard = val(r['身份证号'])
const idCard = val(getField(r, '身份证号'))
if (idCard) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
}
return empByName.get(val(r['姓名']))
return empByName.get(val(getField(r, '姓名')))
}
// 考勤记录
@@ -516,14 +547,14 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(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(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId },
update: { status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null },
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 || '导入失败'}`) }
@@ -538,15 +569,15 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(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(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
const wdHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
const weHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
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,
@@ -569,16 +600,16 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const newSalary = num(r['调整后月薪'])
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const newSalary = num(getField(r, '调整后月薪'))
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
const effDate = parseDate(r['生效日期']) || new Date(month + '-01')
const effDate = parseDate(getField(r, '生效日期')) || new Date(month + '-01')
const effMonth = dateToMonth(effDate)
let oldSalary = 0
try { oldSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { oldSalary = 0 }
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: effMonth } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(r['调薪原因']) || '月度导入', createdBy: userId } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(getField(r, '调薪原因')) || '月度导入', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { monthlySalary: encrypt(String(newSalary)) } })
result.salaryChanges++
} catch (e: any) { result.errors.push(`薪资第${i + 2}行:${e?.message || '导入失败'}`) }
@@ -593,10 +624,10 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
const city = val(r['城市']) || '北京'
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const changeType = val(getField(r, '变动类型'))
const base = num(getField(r, '缴费基数'))
const city = val(getField(r, '城市')) || '北京'
if (changeType === '增员' || changeType === '调基') {
const baseCheck = validateSocialBase(base, city)
if (baseCheck.warning) result.errors.push(`社保第${i + 2}行警告:${baseCheck.warning}`)
@@ -621,9 +652,9 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const changeType = val(getField(r, '变动类型'))
const base = num(getField(r, '缴费基数'))
if (changeType === '增员' || changeType === '调基') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
@@ -699,18 +730,18 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const idCard = val(getField(r, '身份证号'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const entryId = entryByEmp.get(empId)
if (!entryId) { result.errors.push(`${i + 2}行:员工「${val(r['姓名'])}」不在本批次中`); continue }
if (!entryId) { result.errors.push(`${i + 2}行:员工「${val(getField(r, '姓名'))}」不在本批次中`); continue }
const inputs = {
baseSalary: num(r['基本工资']) || 0,
overtimePay: num(r['加班费']) || 0,
allowance: num(r['津贴']) || 0,
deduction: num(r['扣款']) || 0,
bonus: num(r['奖金']) || 0,
baseSalary: num(getField(r, '基本工资')) || 0,
overtimePay: num(getField(r, '加班费')) || 0,
allowance: num(getField(r, '津贴')) || 0,
deduction: num(getField(r, '扣款')) || 0,
bonus: num(getField(r, '奖金')) || 0,
}
// 重新计算税费
@@ -757,8 +788,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const data = [
{ '姓名': '张三', '身份证号': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
{ '姓名': '李四', '身份证号': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
{ '姓名*': '张三', '身份证号*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
{ '姓名*': '李四', '身份证号*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -767,4 +798,73 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
res.send(buf)
})
// ========== 专项附加扣除批量导入 ==========
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const data = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
{ '姓名*': '李四', '身份证号': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
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.send(buf)
})
router.post('/special-deduction', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const month = (req.body.month as string) || new Date().toISOString().slice(0, 7)
const wb = XLSX.read(req.file.buffer, { type: 'buffer' })
const sheet = wb.Sheets['专项附加扣除']
if (!sheet) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未找到「专项附加扣除」Sheet' } })
const rows = XLSX.utils.sheet_to_json(sheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
const result: any = { total: rows.length, updated: 0, skipped: 0, errors: [] as string[], details: [] as any[] }
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const name = val(getField(r, '姓名'))
const idCard = val(getField(r, '身份证号'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(name)
if (!empId) { result.skipped++; result.errors.push(`${i + 2}行:找不到员工「${name}`); result.details.push({ row: i + 2, name, status: 'skipped', message: '找不到员工' }); continue }
const children = num(getField(r, '子女教育'))
const elderly = num(getField(r, '赡养老人'))
const housing = num(getField(r, '住房'))
const education = num(getField(r, '继续教育'))
const infant = num(getField(r, '婴幼儿照护'))
const amount = children + elderly + housing + education + infant
const remark = val(getField(r, '备注')) || null
await prisma.specialDeductionRecord.upsert({
where: { employeeId_month: { employeeId: empId, month } },
create: { orgId, employeeId: empId, month, amount, children, elderly, housing, education, infant, remark, createdBy: userId },
update: { amount, children, elderly, housing, education, infant, remark },
})
await prisma.employee.update({ where: { id: empId }, data: { specialDeduction: amount } })
result.updated++
} catch (e: any) {
result.skipped++
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`)
result.details.push({ row: i + 2, name: val(getField(r, '姓名')), status: 'error', message: e?.message || '导入失败' })
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router