feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税
- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化 - 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口 - 所有导入模板表头标注必填项(*后缀)并含示例行 - 导入逻辑统一改用getField兼容*后缀列名 - 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题 - 更新需求梳理文档
This commit is contained in:
@@ -2,8 +2,8 @@ import { Request, Response, NextFunction } from 'express'
|
||||
import { verifyAccessToken } from '../lib/jwt'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: { id: string; orgId: string | null; role: string }
|
||||
orgId?: string | null
|
||||
user?: { id: string; orgId: string; role: string }
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -16,7 +16,7 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
|
||||
if (!payload) {
|
||||
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
|
||||
}
|
||||
req.user = payload
|
||||
req.user = { id: payload.id, orgId: payload.orgId || '', role: payload.role }
|
||||
// 非 SUPER_ADMIN 用户必须有 orgId,否则企业端 API 会因 orgId=null 报错
|
||||
if (payload.role !== 'SUPER_ADMIN' && !payload.orgId) {
|
||||
return res.status(403).json({ success: false, error: { code: 'NO_ORG', message: '该账号未绑定企业' } })
|
||||
|
||||
+214
-114
@@ -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
|
||||
|
||||
@@ -217,25 +217,18 @@ router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: Ne
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
employeeIds: z.array(z.string()).optional(),
|
||||
name: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||||
const { month, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 检查当月是否有未归档批次,有则拒绝创建(确保个税按批次累计计算)
|
||||
const draftBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'DRAFT' },
|
||||
})
|
||||
if (draftBatches > 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月存在未归档的批次,请先归档后再创建新批次' } })
|
||||
}
|
||||
|
||||
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
||||
const lastBatch = await prisma.payrollBatch.findFirst({
|
||||
where: { orgId, month },
|
||||
@@ -261,6 +254,12 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
if (mode === 'blank_all') {
|
||||
// 全空白:不拉入员工
|
||||
employees = []
|
||||
} else if (mode === 'custom' && employeeIds && employeeIds.length > 0) {
|
||||
// 自定义选择:仅包含指定员工
|
||||
employees = await prisma.employee.findMany({
|
||||
where: { id: { in: employeeIds }, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
} else if (mode === 'copy_batch' && sourceBatchId) {
|
||||
// 复制指定批次:从源批次复制条目
|
||||
const sourceBatch = await prisma.payrollBatch.findFirst({
|
||||
@@ -655,7 +654,7 @@ router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next:
|
||||
}
|
||||
})
|
||||
|
||||
// 归档批次
|
||||
// 归档批次(归档前重算所有条目,确保累计个税/社保包含先前已归档批次的数据)
|
||||
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
@@ -665,12 +664,73 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次已归档' } })
|
||||
|
||||
// 1. 重算本批次所有条目(此时 calcBatchEntry 会包含所有已归档批次的累计数据)
|
||||
const entries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const recalcErrors: string[] = []
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const inputs = {
|
||||
baseSalary: entry.baseSalary,
|
||||
overtimePay: entry.overtimePay,
|
||||
allowance: entry.allowance,
|
||||
deduction: entry.deduction,
|
||||
bonus: entry.bonus,
|
||||
positionSalary: entry.positionSalary || undefined,
|
||||
performanceSalary: entry.performanceSalary || undefined,
|
||||
senioritySalary: entry.senioritySalary || undefined,
|
||||
transportAllowance: entry.transportAllowance || undefined,
|
||||
mealAllowance: entry.mealAllowance || undefined,
|
||||
housingAllowance: entry.housingAllowance || undefined,
|
||||
communicationAllowance: entry.communicationAllowance || undefined,
|
||||
otherDeduction: entry.otherDeduction || undefined,
|
||||
}
|
||||
// 社保如被手动覆盖,保留覆盖值
|
||||
const overrideSocial: any = {}
|
||||
if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp
|
||||
if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg
|
||||
if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp
|
||||
if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...calcResult },
|
||||
})
|
||||
} catch (e: any) {
|
||||
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 更新批次汇总
|
||||
const recalcedEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = recalcedEntries.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 })
|
||||
|
||||
// 3. 标记为已归档
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'ARCHIVED', archivedAt: new Date() },
|
||||
data: {
|
||||
status: 'ARCHIVED',
|
||||
archivedAt: new Date(),
|
||||
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: { archived: true } })
|
||||
res.json({ success: true, data: { archived: true, recalculated: entries.length, errors: recalcErrors.length > 0 ? recalcErrors : undefined } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Router } from 'express'
|
||||
import { Router, Response } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt, encrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
import ExcelJS from 'exceljs'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -54,7 +55,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
todayEnd.setDate(todayEnd.getDate() + 1)
|
||||
|
||||
// 先查询满足 orgId 和搜索条件的员工
|
||||
const isIdCardSearch = search && /^\d{4}$/.test(search)
|
||||
const isIdCardSearch = search && /^\d{2,}$/.test(search)
|
||||
const whereBase: any = { orgId: req.user!.orgId }
|
||||
if (department) {
|
||||
whereBase.department = department
|
||||
@@ -156,6 +157,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
idCardMasked,
|
||||
idCardNumber: e.idCardNumber,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
isPregnant: e.isPregnant,
|
||||
isInMedicalPeriod: e.isInMedicalPeriod,
|
||||
@@ -164,6 +166,19 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
probationInfo: (() => {
|
||||
if (!latestContract || latestContract.probationMonths === 0) return null
|
||||
const probEnd = new Date(e.hireDate)
|
||||
probEnd.setMonth(probEnd.getMonth() + latestContract.probationMonths)
|
||||
const daysToConfirm = Math.ceil((probEnd.getTime() - today.getTime()) / 86400000)
|
||||
return {
|
||||
months: latestContract.probationMonths,
|
||||
endDate: probEnd.toISOString().slice(0, 10),
|
||||
daysToConfirm,
|
||||
isProbation: daysToConfirm > 0 && dynamicStatus === 'ACTIVE',
|
||||
isExpiring: daysToConfirm <= 7 && daysToConfirm > 0,
|
||||
}
|
||||
})(),
|
||||
counts: e._count,
|
||||
}
|
||||
})
|
||||
@@ -173,11 +188,16 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||
}
|
||||
|
||||
// 身份证号后4位搜索:在内存中过滤
|
||||
// 身份证号后N位搜索:在内存中过滤(解密完整身份证号后匹配)
|
||||
if (isIdCardSearch) {
|
||||
result = result.filter((e: any) => {
|
||||
if (!e.idCardMasked) return false
|
||||
return e.idCardMasked.endsWith(search!)
|
||||
if (!e.idCardNumber) return false
|
||||
try {
|
||||
const fullIdCard = decrypt(e.idCardNumber)
|
||||
return fullIdCard.endsWith(search!)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -535,6 +555,174 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res,
|
||||
}
|
||||
})
|
||||
|
||||
// 仲裁证据链 Excel 导出
|
||||
router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' } },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: { where: { status: { not: 'CANCELLED' } }, orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const empName = employee.name
|
||||
const empDept = employee.department
|
||||
const hireDate = employee.hireDate.toISOString().slice(0, 10)
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
|
||||
// Sheet 1: 员工信息
|
||||
const wsInfo = workbook.addWorksheet('员工信息')
|
||||
wsInfo.columns = [
|
||||
{ header: '项目', key: 'label', width: 16 },
|
||||
{ header: '内容', key: 'value', width: 40 },
|
||||
]
|
||||
wsInfo.getRow(1).font = { bold: true }
|
||||
const empStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? '离职' : '在职'
|
||||
wsInfo.addRows([
|
||||
{ label: '姓名', value: empName },
|
||||
{ label: '部门', value: empDept },
|
||||
{ label: '入职日期', value: hireDate },
|
||||
{ label: '状态', value: empStatus },
|
||||
{ label: '导出时间', value: new Date().toLocaleString('zh-CN') },
|
||||
])
|
||||
|
||||
// Sheet 2: 证据清单
|
||||
const wsEv = workbook.addWorksheet('证据清单')
|
||||
wsEv.columns = [
|
||||
{ header: '序号', key: 'no', width: 6 },
|
||||
{ header: '类别', key: 'category', width: 12 },
|
||||
{ header: '标题', key: 'title', width: 28 },
|
||||
{ header: '日期', key: 'date', width: 12 },
|
||||
{ header: '描述', key: 'description', width: 60 },
|
||||
{ header: '签字状态', key: 'ack', width: 10 },
|
||||
]
|
||||
wsEv.getRow(1).font = { bold: true }
|
||||
|
||||
let evNo = 0
|
||||
// 劳动关系
|
||||
evNo++; wsEv.addRow({ no: evNo, category: '劳动关系', title: '入职登记', date: hireDate, description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`, ack: '' })
|
||||
employee.contracts.forEach((c) => {
|
||||
const typeText = ({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record<string, string>)[c.contractType] || '未签订'
|
||||
evNo++
|
||||
wsEv.addRow({
|
||||
no: evNo, category: '劳动关系', title: `合同(${typeText})`,
|
||||
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
|
||||
description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。${c.signDate ? '' : '⚠ 该合同尚未签订。'}`,
|
||||
ack: c.signDate ? '已签字' : '未签字',
|
||||
})
|
||||
})
|
||||
|
||||
// 薪酬发放
|
||||
employee.payslips.forEach((p) => {
|
||||
evNo++
|
||||
wsEv.addRow({
|
||||
no: evNo, category: '薪酬发放', title: `${p.month}月工资条`, date: p.month,
|
||||
description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`,
|
||||
ack: p.confirmedAt ? '已签字' : '未签字',
|
||||
})
|
||||
})
|
||||
employee.overtimeRecords.forEach((o) => {
|
||||
if (o.totalPay > 0) {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '薪酬发放', title: `${o.month}月加班费记录`, date: o.month, description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`, ack: '' })
|
||||
}
|
||||
})
|
||||
|
||||
// 考勤记录
|
||||
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||||
employee.attendanceRecords.filter((a) => a.status !== 'NORMAL').forEach((a) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '考勤记录', title: `${a.date.toISOString().slice(0, 10)} 考勤异常`, date: a.date.toISOString().slice(0, 10), description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`, ack: '' })
|
||||
})
|
||||
|
||||
// 违纪处理
|
||||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
employee.disciplinaryRecords.forEach((d) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '违纪处理', title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`, date: d.violationDate.toISOString().slice(0, 10), description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`, ack: d.employeeAck ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// 培训签收
|
||||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
employee.trainingRecords.forEach((t) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '培训签收', title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`, date: t.trainingDate.toISOString().slice(0, 10), description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`, ack: t.ackStatus === 'SIGNED' ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// 绩效考核
|
||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
employee.performanceRecords.forEach((p) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '绩效考核', title: `${p.period} 绩效考核`, date: p.period, description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`, ack: p.employeeAck ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// 解聘记录
|
||||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' }
|
||||
const termStatusMap: Record<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||||
employee.terminations.forEach((t) => {
|
||||
const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED' ? '协商一致离职' : t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘'
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '解聘记录', title: `${t.terminationDate.toISOString().slice(0, 10)} ${typeLabel}记录`, date: t.terminationDate.toISOString().slice(0, 10), description: `类型:${typeLabel}。原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。流程状态:${termStatusMap[t.status] || t.status}。${t.resignationReason ? `离职原因:${t.resignationReason}。` : ''}${t.remark || ''}`, ack: t.status === 'COMPLETED' ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// Sheet 3: 风险提醒
|
||||
const risks: any[] = []
|
||||
if (employee.contracts.length === 0) {
|
||||
const days = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000)
|
||||
if (days > 30) risks.push({ level: days > 365 ? '危险' : '高', category: '劳动关系', title: '未签订书面劳动合同', description: `入职已${days}天仍未签订书面劳动合同,超过30天未签合同将面临双倍工资赔偿风险。${days > 365 ? '已满一年未签合同,视为已订立无固定期限劳动合同。' : ''}` })
|
||||
}
|
||||
employee.contracts.forEach((c) => {
|
||||
if (!c.signDate) risks.push({ level: '高', category: '劳动关系', title: '合同未签字', description: `合同期限${c.startDate.toISOString().slice(0, 10)}至${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},尚未签订。` })
|
||||
if (c.endDate && c.endDate < today) {
|
||||
const expiredDays = Math.floor((today.getTime() - c.endDate.getTime()) / 86400000)
|
||||
risks.push({ level: expiredDays > 30 ? '高' : '中', category: '劳动关系', title: '合同已过期', description: `合同已于${c.endDate.toISOString().slice(0, 10)}过期,过期${expiredDays}天。` })
|
||||
} else if (c.endDate) {
|
||||
const daysToExpire = Math.floor((c.endDate.getTime() - today.getTime()) / 86400000)
|
||||
if (daysToExpire <= 30 && daysToExpire >= 0) risks.push({ level: '中', category: '劳动关系', title: '合同即将到期', description: `合同将于${c.endDate.toISOString().slice(0, 10)}到期,剩余${daysToExpire}天。` })
|
||||
}
|
||||
if (c.probationMonths > 0 && c.probationSalary === 0) risks.push({ level: '中', category: '劳动关系', title: '试用期工资为0', description: `合同约定试用期${c.probationMonths}个月但试用期工资为0,违反《劳动合同法》第20条。` })
|
||||
})
|
||||
const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000)
|
||||
if (daysSinceHire > 30 && employee.payslips.length === 0) risks.push({ level: '中', category: '薪酬发放', title: '无工资条记录', description: `入职已${daysSinceHire}天但无任何工资条记录。` })
|
||||
employee.terminations.forEach((t) => {
|
||||
if (t.status !== 'COMPLETED') risks.push({ level: '高', category: '解聘记录', title: '离职流程未完成', description: `流程当前状态为「${termStatusMap[t.status] || t.status}」,尚未完成闭环。` })
|
||||
if (t.reason === 'NEGOTIATED' && t.compensation === 0) risks.push({ level: '中', category: '解聘记录', title: '协商解除但补偿金为0', description: '解聘原因为协商解除但经济补偿金为0,面临2N赔偿风险。' })
|
||||
})
|
||||
|
||||
const wsRisk = workbook.addWorksheet('风险提醒')
|
||||
wsRisk.columns = [
|
||||
{ header: '序号', key: 'no', width: 6 },
|
||||
{ header: '风险等级', key: 'level', width: 10 },
|
||||
{ header: '类别', key: 'category', width: 12 },
|
||||
{ header: '标题', key: 'title', width: 24 },
|
||||
{ header: '描述', key: 'description', width: 60 },
|
||||
]
|
||||
wsRisk.getRow(1).font = { bold: true }
|
||||
risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r }))
|
||||
|
||||
const encodedName = encodeURIComponent(empName)
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}_证据链.xlsx"; filename*=UTF-8''${encodedName}_证据链.xlsx`)
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 违纪记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
|
||||
@@ -42,4 +42,20 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons
|
||||
}
|
||||
})
|
||||
|
||||
/** 下载模板(Word .doc 格式) */
|
||||
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = getTemplateById(req.params.id)
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(template.content)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user