Files
TurboHR/backend/src/routes/import.routes.ts
T
selfrelease 94b85d5c36 fix: 同企业内员工身份证和手机号唯一性完整校验
补充以下场景的查重:
1. updateEmployee:编辑员工信息时手机号/身份证查重
   (排除自身,同组织内不可与其他员工重复)
2. import.routes.ts:Excel 批量导入时身份证和手机号查重
   (重复则跳过并记录错误日志)

至此同企业内身份证和手机号唯一性校验覆盖全部入口:
- createEmployee(新增员工)
- updateEmployee(编辑员工)
- Excel 导入(批量导入)
- work-process(走 createEmployee)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 10:59:38 +08:00

1017 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router, Response, NextFunction } from 'express'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import prisma from '../lib/prisma'
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from '../services/retirement.service'
import { calcBatchEntry } from '../services/payroll.service'
import { createEvidence } from '../services/evidence.service'
// RFC 5987 编码中文文件名
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
// 证件号码格式校验(18位正则 + 校验位算法)
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
if (!idCard) return { valid: true }
const s = idCard.trim()
// 15位证件号码升级为18位
if (/^\d{15}$/.test(s)) {
const upgraded = upgrade15To18(s)
return { valid: true, upgraded }
}
if (!/^\d{17}[\dXx]$/.test(s)) {
return { valid: false, error: '证件号码格式错误(应为18位)' }
}
// 校验位算法
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
const expected = checkCodes[sum % 11]
if (s.charAt(17).toUpperCase() !== expected) {
return { valid: false, error: '证件号码校验位错误' }
}
return { valid: true }
}
function upgrade15To18(s15: string): string {
const born = '19' + s15.substring(6, 12)
const body = s15.substring(0, 6) + born + s15.substring(12)
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = body.split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
return body + checkCodes[sum % 11]
}
// 社保基数范围校验
const SOCIAL_INS_LIMITS: Record<string, { min: number; max: number }> = {
'北京': { min: 6326, max: 33891 },
'上海': { min: 7310, max: 36549 },
'广州': { min: 5284, max: 27501 },
'深圳': { min: 3523, max: 27501 },
'杭州': { min: 4812, max: 24060 },
}
function validateSocialBase(base: number, city?: string): { valid: boolean; warning?: string } {
if (!city || !SOCIAL_INS_LIMITS[city]) return { valid: true }
const limits = SOCIAL_INS_LIMITS[city]
if (base < limits.min) return { valid: true, warning: `基数${base}低于${city}下限${limits.min}` }
if (base > limits.max) return { valid: true, warning: `基数${base}高于${city}上限${limits.max}` }
return { valid: true }
}
function dateToMonth(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function parseDate(v: any): Date | null {
if (!v) return null
if (v instanceof Date) return v
if (typeof v === 'number') {
const d = XLSX.SSF.parse_date_code(v)
if (d) return new Date(d.y, d.m - 1, d.d)
}
const s = String(v).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s)
if (/^\d{4}\/\d{2}\/\d{2}/.test(s)) return new Date(s.replace(/\//g, '-'))
return null
}
function val(v: any): string {
if (v == null) return ''
return String(v).trim()
}
function num(v: any): number {
const n = Number(v)
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) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const preview: any = { employees: [], contracts: [], overtime: [], disciplinary: [], attendance: [], errors: [] as any[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
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(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, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
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) {
const idCheck = validateIdCard(row.idCard)
if (!idCheck.valid) { row.status = row.status === 'normal' ? 'warning' : row.status; row.warnings.push(idCheck.error!) }
if (idCheck.upgraded) { row.idCard = idCheck.upgraded; row.warnings.push('15位身份证已升级为18位') }
}
if (row.status === 'error') preview.errors.push({ sheet: '员工信息', row: i + 2, name: row.name, errors: row.errors })
preview.employees.push(row)
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
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(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(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)
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
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(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(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)
}
}
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
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)
}
}
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
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(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)
}
}
const summary = {
totalRows: preview.employees.length + preview.contracts.length + preview.overtime.length + preview.disciplinary.length + preview.attendance.length,
normalRows: 0,
warningRows: 0,
errorRows: preview.errors.length,
sheets: Object.keys(wb.Sheets).filter(s => !s.startsWith('!')),
}
summary.normalRows = summary.totalRows - summary.errorRows
preview.summary = summary
res.json({ success: true, data: preview })
} catch (err) {
next(err)
}
})
// ========== 错误日志导出 ==========
router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const { errors } = req.body as { errors: any[] }
if (!errors || !errors.length) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无错误数据' } })
}
const data = errors.map(e => ({
'Sheet': e.sheet || '',
'行号': e.row || '',
'员工姓名': e.name || '',
'错误类型': Array.isArray(e.errors) ? e.errors.join('; ') : (e.error || ''),
}))
const ws = XLSX.utils.json_to_sheet(data)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
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)
} catch (err) {
next(err)
}
})
router.post('/excel', 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 wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
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) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
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(getField(r, '证件号码'))
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
if (idCard) {
const idCheck = validateIdCard(idCard)
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
}
// 同企业内身份证查重
if (idCard) {
const idCardExists = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(idCard) },
select: { id: true, name: true },
})
if (idCardExists) {
result.skipped++
result.errors.push(`员工第${i + 2}行:证件号码已存在(${idCardExists.name}),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码已存在' })
continue
}
}
// 同企业内手机号查重
const importPhone = val(getField(r, '手机号'))
if (importPhone) {
const phoneExists = await prisma.employee.findFirst({
where: { orgId, phone: importPhone },
select: { id: true, name: true },
})
if (phoneExists) {
result.skipped++
result.errors.push(`员工第${i + 2}行:手机号已存在(${phoneExists.name}),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '手机号已存在' })
continue
}
}
// 社保基数:值为 0 或「无」表示不参保
const socialInsBaseVal = num(getField(r, '社保基数'))
const socialInsOptOut = val(getField(r, '社保基数')) === '无' || val(getField(r, '社保基数')) === '不缴'
const socialInsBase = socialInsOptOut ? 0 : (socialInsBaseVal || num(salary))
// 公积金基数:值为 0 或「无」表示不缴纳
const housingFundBaseVal = num(getField(r, '公积金基数'))
const housingFundOptOut = val(getField(r, '公积金基数')) === '无' || val(getField(r, '公积金基数')) === '不缴'
const housingFundBase = housingFundOptOut ? 0 : (housingFundBaseVal || num(salary))
const emp = await prisma.employee.create({
data: {
orgId, name, department: dept, hireDate,
monthlySalary: encrypt(salary),
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(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,
housingFundBase,
specialDeduction: num(getField(r, '专项附加扣除')) || 0,
city: val(getField(r, '参保城市')) || null,
isPregnant: val(getField(r, '孕期')) === '是',
isInMedicalPeriod: val(getField(r, '医疗期')) === '是',
isWorkInjured: val(getField(r, '工伤')) === '是',
socialInsStartMonth: socialInsOptOut ? null : dateToMonth(hireDate),
housingFundStartMonth: housingFundOptOut ? null : dateToMonth(hireDate),
createdBy: userId,
},
})
// 仅在未 opt-out 时创建社保记录
if (!socialInsOptOut) {
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: socialInsBase, changeType: 'ONBOARDING', createdBy: userId } })
}
// 仅在未 opt-out 时创建公积金记录
if (!housingFundOptOut) {
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: housingFundBase, 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 } })
await createEvidence({
orgId, category: 'ONBOARD', refId: emp.id, employeeId: emp.id,
events: [{ action: '员工入职登记(批量导入)', timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
createdBy: userId,
})
result.employees++
result.details.push({ sheet: '员工信息', row: i + 2, name, employeeId: emp.id, status: 'success', message: '导入成功' })
} catch (e: any) {
const msg = e?.message || ''
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 || '未知错误' })
}
}
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
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]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
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(getField(r, '合同类型'))] || 'FIXED'
if (contractType !== 'UNSIGNED') {
await prisma.laborContract.create({
data: {
orgId, employeeId: empId,
signDate: parseDate(getField(r, '签订日期')) || null,
startDate,
endDate: parseDate(getField(r, '合同结束日期')) || null,
contractType,
signMethod: val(getField(r, '签订方式')) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(getField(r, '合同年限')) || 3,
probationMonths: num(getField(r, '试用期月数')) || 0,
probationSalary: num(getField(r, '试用期工资')) || 0,
createdBy: userId,
},
})
result.contracts++
}
await createEvidence({
orgId, category: 'CONTRACT_SIGN', refId: undefined, employeeId: empId,
events: [{ action: '合同签订(批量导入)', timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
createdBy: userId,
})
} catch (e: any) {
const msg = e?.message || ''
if (msg.includes('Unique constraint')) result.errors.push(`合同第${i + 2}行:该员工合同已存在,跳过`)
else result.errors.push(`合同第${i + 2}行:导入失败 — ${msg || '未知错误'}`)
}
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
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]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
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(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) {
const msg = e?.message || ''
if (msg.includes('Unique constraint')) result.errors.push(`加班第${i + 2}行:该员工此月份已有加班记录,同一员工同一月只能导入一条,请合并后重试`)
else result.errors.push(`加班第${i + 2}行:导入失败 — ${msg || '未知错误'}`)
}
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
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]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
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(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(getField(r, '违纪类型')) || '其他'}`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
createdBy: userId,
})
result.disciplinary++
} catch (e: any) {
const msg = e?.message || ''
result.errors.push(`违纪第${i + 2}行:导入失败 — ${msg || '未知错误'}`)
}
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
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]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
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(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(getField(r, '考勤状态')) || '正常'}`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }],
createdBy: userId,
})
result.attendance++
} catch (e: any) {
const msg = e?.message || ''
if (msg.includes('Unique constraint')) result.errors.push(`考勤第${i + 2}行:该员工此日期已有考勤记录,跳过`)
else result.errors.push(`考勤第${i + 2}行:导入失败 — ${msg || '未知错误'}`)
}
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
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', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
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 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名*': '张三', '证件号码': '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', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
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('/monthly', 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 = val(req.body.month) || dateToMonth(new Date())
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '月份格式应为 YYYY-MM' } })
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
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) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
}
return empByName.get(val(getField(r, '姓名')))
}
// 考勤记录 + 加班记录(支持合并Sheet"考勤与加班"或独立Sheet
const mergedSheet = wb.Sheets['考勤与加班']
const attSheet = wb.Sheets['考勤记录']
const otSheet = wb.Sheets['加班记录']
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 }
const date = parseDate(getField(r, '日期'))
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 || '导入失败'}`) }
}
}
}
// 薪资调整
const salarySheet = wb.Sheets['薪资调整']
if (salarySheet) {
const rows = XLSX.utils.sheet_to_json(salarySheet)
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 newSalary = num(getField(r, '调整后月薪'))
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
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(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 || '导入失败'}`) }
}
}
// 社保增减员
const socialSheet = wb.Sheets['社保变动']
if (socialSheet) {
const rows = XLSX.utils.sheet_to_json(socialSheet)
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 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}`)
// 关闭之前有效记录
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsBase: base || 0, socialInsStartMonth: month, socialInsEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsEndMonth: month } })
}
result.socialInsChanges++
} catch (e: any) { result.errors.push(`社保第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 公积金增减员
const hfSheet = wb.Sheets['公积金变动']
if (hfSheet) {
const rows = XLSX.utils.sheet_to_json(hfSheet)
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 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 } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundBase: base || 0, housingFundStartMonth: month, housingFundEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundEndMonth: month } })
}
result.housingFundChanges++
} catch (e: any) { result.errors.push(`公积金第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 违纪记录
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)
}
})
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
// 合并考勤+加班为一个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), '薪资调整')
const socialData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
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.send(buf)
})
// 工资表导入 — 批量更新批次条目的薪酬输入项
router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' })
const orgId = req.user!.orgId
const batchId = req.body.batchId as string
if (!batchId) return res.status(400).json({ success: false, message: '缺少批次ID' })
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可导入' })
const wb = XLSX.read(req.file.buffer, { type: 'buffer' })
const sheet = wb.Sheets[wb.SheetNames[0]]
if (!sheet) return res.status(400).json({ success: false, message: 'Excel 文件无有效 Sheet' })
const rows = XLSX.utils.sheet_to_json(sheet)
const result = { total: rows.length, updated: 0, errors: [] as string[] }
// 构建员工查找索引
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByName = new Map(employees.map(e => [e.name, e.id]))
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash!, e.id]))
// 查询现有批次条目
const entries = await prisma.batchEntry.findMany({ where: { batchId }, select: { id: true, employeeId: true } })
const entryByEmp = new Map(entries.map(e => [e.employeeId, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
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(getField(r, '姓名'))}」不在本批次中`); continue }
const inputs = {
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,
}
// 重新计算税费
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
result.updated++
} catch (e: any) {
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`)
}
}
// 更新批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.totalPay,
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// 工资表导入模板下载
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 },
]
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.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