41a2c665ed
- 添加 FemaleWorkerType enum (CADRE/WORKER) 和 Employee.femaleWorkerType 字段 - 新增 calcIndividualRetireAge: 按个人出生日期逐人计算退休年龄 - 重写 calcRetirementDaysLeft: 基于个人出生日期+性别+岗位类型 - updateEmployeesRetirementDays 使用 femaleWorkerType 区分女干部/女工人 - 前端新增/编辑员工表单添加女性岗位类型选择器 - 前端政策展示改为改革规则卡片(基准→目标年龄) - 前端距退休天数改为退休日期显示(XXXX年XX月XX日) - 移除AI退休政策计算相关代码和手动刷新路由 - 导入支持女性岗位类型列
614 lines
34 KiB
TypeScript
614 lines
34 KiB
TypeScript
import { Router, Response } 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'
|
|
|
|
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
|
|
}
|
|
|
|
// ========== 导入预览(不写入数据库) ==========
|
|
|
|
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(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
|
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
|
const hireDate = parseDate(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(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] }
|
|
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
|
const sd = parseDate(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(r['加班类型']) || '工作日加班'
|
|
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] }
|
|
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
|
const dt = parseDate(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(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(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(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] }
|
|
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
|
const dt = parseDate(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', `attachment; filename="import-errors-${Date.now()}.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, errors: [] as string[] }
|
|
|
|
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(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 }
|
|
|
|
let idCard = val(r['身份证号'])
|
|
if (idCard) {
|
|
const idCheck = validateIdCard(idCard)
|
|
if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue }
|
|
if (idCheck.upgraded) idCard = idCheck.upgraded
|
|
}
|
|
|
|
const emp = await prisma.employee.create({
|
|
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,
|
|
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,
|
|
isPregnant: val(r['孕期']) === '是',
|
|
isInMedicalPeriod: val(r['医疗期']) === '是',
|
|
isWorkInjured: val(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.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 } })
|
|
result.employees++
|
|
} catch (e: any) {
|
|
result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
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(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['合同开始日期'])
|
|
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
|
|
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
|
|
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
|
|
if (contractType !== 'UNSIGNED') {
|
|
await prisma.laborContract.create({
|
|
data: {
|
|
orgId, employeeId: empId,
|
|
signDate: parseDate(r['签订日期']) || null,
|
|
startDate,
|
|
endDate: parseDate(r['合同结束日期']) || null,
|
|
contractType,
|
|
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
|
|
contractYears: num(r['合同年限']) || 3,
|
|
probationMonths: num(r['试用期月数']) || 0,
|
|
probationSalary: num(r['试用期工资']) || 0,
|
|
createdBy: userId,
|
|
},
|
|
})
|
|
result.contracts++
|
|
}
|
|
} 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 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
|
|
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['日期'])
|
|
if (!date) 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)
|
|
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
|
|
result.overtime++
|
|
}
|
|
}
|
|
|
|
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
|
|
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['日期'])
|
|
if (!date) 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 } })
|
|
result.disciplinary++
|
|
}
|
|
}
|
|
|
|
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
|
|
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['日期'])
|
|
if (!date) 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 } })
|
|
result.attendance++
|
|
}
|
|
}
|
|
|
|
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', '孕期': '否', '医疗期': '否', '工伤': '否' },
|
|
]
|
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
|
|
|
|
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', 'attachment; filename="import-template.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, 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]))
|
|
|
|
function findEmp(r: any) {
|
|
const idCard = val(r['身份证号'])
|
|
if (idCard) {
|
|
const emp = empByHash.get(sha256(idCard))
|
|
if (emp) return emp
|
|
}
|
|
return empByName.get(val(r['姓名']))
|
|
}
|
|
|
|
// 考勤记录
|
|
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(r['姓名'])}」`); continue }
|
|
const date = parseDate(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 },
|
|
})
|
|
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)
|
|
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(r['姓名'])}」`); continue }
|
|
const date = parseDate(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)
|
|
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 || '导入失败'}`) }
|
|
}
|
|
}
|
|
|
|
// 薪资调整
|
|
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(r['姓名'])}」`); continue }
|
|
const newSalary = num(r['调整后月薪'])
|
|
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
|
|
const effDate = parseDate(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.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(r['姓名'])}」`); continue }
|
|
const changeType = val(r['变动类型'])
|
|
const base = num(r['缴费基数'])
|
|
const city = val(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(r['姓名'])}」`); continue }
|
|
const changeType = val(r['变动类型'])
|
|
const base = num(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 || '导入失败'}`) }
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
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), '加班记录')
|
|
|
|
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 buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
|
|
res.send(buf)
|
|
})
|
|
|
|
export default router
|