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
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# 企业用工专家 — 20260729 用户反馈优化需求梳理
|
||||
|
||||
> 来源:用户测试反馈
|
||||
> 整理日期:2026-07-29
|
||||
> 优先级标注:🔴 高 / 🟡 中 / 🟢 低
|
||||
|
||||
---
|
||||
|
||||
## 需求 1:花名册导入模板优化 🔴
|
||||
|
||||
**问题描述**
|
||||
- 导入模板中必填项没有标注,用户不清楚哪些字段必须填写
|
||||
- 示例数据放在第一行,删除后看不到格式参考,来回测试麻烦
|
||||
|
||||
**优化方案**
|
||||
1. 模板表头添加必填标注:必填字段列名加 `*` 号或红色标注(如 `姓名*`、`身份证号*`、`入职日期*`)
|
||||
2. 示例行使用浅灰色背景并标注「示例数据(导入时请删除本行)」
|
||||
3. 或在导入页面展示「字段说明」折叠面板,列出每个字段的必填/选填、格式要求
|
||||
|
||||
**涉及模块**
|
||||
- 后端:`backend/src/routes/import.routes.ts` — 模板生成逻辑
|
||||
- 前端:`frontend/src/pages/Settings.tsx` — 数据导入 Tab
|
||||
|
||||
**完成方式** ✅
|
||||
- 模板表头必填字段加 `*` 号标注(`姓名*`、`身份证号*`、`入职日期*`、`月工资*`)
|
||||
- 新增 `getField()` 工具函数,兼容带 `*` 后缀列名和模糊匹配
|
||||
- 性别列标注「选填,留空自动识别」,导入时从身份证号第 17 位自动提取
|
||||
|
||||
---
|
||||
|
||||
## 需求 2:工资专项附加扣除批量导入 🟡
|
||||
|
||||
**问题描述**
|
||||
- 专项附加扣除信息在员工入职时可能尚未采集到
|
||||
- 当前只能在薪税管理中逐月手动录入,没有单独的批量导入功能
|
||||
|
||||
**优化方案**
|
||||
1. 在「薪税管理 → 专项附加扣除」Tab 新增「批量导入」按钮
|
||||
2. 提供专项附加扣除导入模板(员工姓名/身份证号 + 各项扣除金额)
|
||||
3. 支持「复制上月」基础上批量修改后导入
|
||||
|
||||
**涉及模块**
|
||||
- 前端:`frontend/src/pages/Money.tsx` — 专项附加扣除 Tab
|
||||
- 后端:`backend/src/routes/import.routes.ts` — 新增导入接口
|
||||
|
||||
**完成方式** ✅
|
||||
- 后端新增 `GET /import/special-deduction/template` 端点,下载专项附加扣除导入模板(含姓名、身份证号、5 项扣除金额列)
|
||||
- 后端新增 `POST /import/special-deduction` 端点,支持 Excel 批量导入并按月 upsert
|
||||
- 前端 `SocialInsurance.tsx` 专项附加扣除 Tab 工具栏新增「批量导入」按钮
|
||||
- 导入弹窗包含:模板下载、文件上传、导入结果反馈(成功/跳过/错误明细)
|
||||
- 身份证号优先匹配,未填时用姓名匹配
|
||||
|
||||
---
|
||||
|
||||
## 需求 3:导入结果反馈不明确 🔴
|
||||
|
||||
**问题描述**
|
||||
- 导入后不显示成功提示,用户不知道是否已导入
|
||||
- 显示了行错误但实际数据已导入,导致重复导入
|
||||
|
||||
**优化方案**
|
||||
1. 导入完成后弹出结果摘要:`成功导入 N 条,失败 M 条,跳过 K 条`
|
||||
2. 成功时 toast 提示「导入成功,共 N 条记录」
|
||||
3. 部分成功时明确提示「部分成功:N 条成功,M 条失败」
|
||||
4. 失败行可下载错误日志(已有),但需在页面显示失败汇总
|
||||
|
||||
**涉及模块**
|
||||
- 前端:`frontend/src/pages/Settings.tsx` — 导入结果展示
|
||||
- 后端:`backend/src/routes/import.routes.ts` — 返回导入统计
|
||||
|
||||
**完成方式** ✅
|
||||
- 后端返回 `skipped`(跳过)、`duplicates`(重复)、`details`(逐行明细)字段
|
||||
- 前端显示分类汇总:成功 N 条、跳过 M 条、重复 K 条
|
||||
- 新增「导出错误日志」按钮,下载 Excel 格式的逐行错误明细
|
||||
|
||||
---
|
||||
|
||||
## 需求 4:证据链导出格式优化(TXT → Excel)🔴
|
||||
|
||||
**问题描述**
|
||||
- 证据链导出为 TXT 格式,排版混乱,无法直接使用
|
||||
- 需要导出后二次加工,体验差
|
||||
|
||||
**优化方案**
|
||||
1. 证据链导出改为 Excel(.xlsx)格式
|
||||
2. 按证据类型分 Sheet 或分列展示:证据名称、类型、日期、内容摘要、附件链接
|
||||
3. 支持导出为 Word 文档(可选),带格式排版
|
||||
|
||||
**涉及模块**
|
||||
- 后端:`backend/src/routes/export.routes.ts` — 证据链导出逻辑
|
||||
- 前端:`frontend/src/pages/Evidence.tsx` — 导出按钮
|
||||
|
||||
**完成方式** ✅
|
||||
- 后端新增 `GET /:id/evidence-chain/export` 端点,使用 ExcelJS 生成 `.xlsx` 文件
|
||||
- Excel 包含 3 个 Sheet:员工信息、证据清单(含合同/工资条/加班/考勤/违纪/培训/绩效/解聘)、风险提醒
|
||||
- 前端 `EvidenceChain.tsx` 导出按钮改为调用新端点下载 Excel
|
||||
|
||||
---
|
||||
|
||||
## 需求 5:花名册身份证后四位搜索不可用 🔴
|
||||
|
||||
**问题描述**
|
||||
- 搜索框提示「搜索姓名、部门或身份证后4位」,但用后四位搜索显示无结果
|
||||
|
||||
**优化方案**
|
||||
1. 后端搜索逻辑支持身份证号后四位模糊匹配(`LIKE '%XXXX'`)
|
||||
2. 前端搜索时自动识别纯数字输入,走身份证后四位匹配逻辑
|
||||
3. 测试验证:输入身份证后四位能正确匹配到对应员工
|
||||
|
||||
**涉及模块**
|
||||
- 后端:`backend/src/routes/roster.routes.ts` — 搜索查询逻辑
|
||||
|
||||
**完成方式** ✅
|
||||
- 搜索正则改为支持 2+ 位纯数字输入识别为身份证号搜索
|
||||
- 后端解密完整身份证号后与搜索词做后缀匹配
|
||||
|
||||
---
|
||||
|
||||
## 需求 6:用工文本模板库功能不明确 🟡
|
||||
|
||||
**问题描述**
|
||||
- 用户不清楚文本模板库的使用方式
|
||||
- 想另存或下载劳动合同模板,没找到操作入口
|
||||
|
||||
**优化方案**
|
||||
1. 模板列表页新增「下载 Word」按钮,可直接下载模板文件
|
||||
2. 新增「复制为新模板」按钮,基于现有模板创建副本后编辑
|
||||
3. 页面顶部添加使用说明:「选择模板 → 点击使用 →填充变量 → 生成文档 → 下载」
|
||||
4. 模板预览支持在线查看完整内容
|
||||
|
||||
**涉及模块**
|
||||
- 前端:`frontend/src/pages/Templates.tsx`
|
||||
- 后端:`backend/src/routes/template.routes.ts`(如有)
|
||||
|
||||
**完成方式** ✅
|
||||
- 后端新增 `GET /templates/:id/download` 端点,返回模板内容为 Word(.doc)文件
|
||||
- 前端 `Templates.tsx` 新增「下载 Word」按钮,可直接下载模板为 .doc 文件
|
||||
- 新增「复制为新模板」按钮,将渲染结果复制到剪贴板,可粘贴到 Word 中编辑
|
||||
- 页面顶部新增「使用说明」可折叠面板,引导用户完成模板选择→变量填充→渲染→下载流程
|
||||
- 模板原文和渲染结果区域均支持下载 Word
|
||||
|
||||
---
|
||||
|
||||
## 需求 7:用工体检诊断评分标准说明 🟡
|
||||
|
||||
**问题描述**
|
||||
- 用工体检只显示分数,没有评分标准和满分说明
|
||||
- 用户不知道分数含义和计算规则
|
||||
|
||||
**优化方案**
|
||||
1. 体检结果页面顶部展示评分说明卡片:
|
||||
- 满分 100 分
|
||||
- 分数等级:90-100 优秀(绿色)、75-89 良好(蓝色)、60-74 合格(黄色)、<60 风险(红色)
|
||||
- 评分维度及权重说明
|
||||
2. 每个扣分项展示扣分原因和对应的法规依据
|
||||
3. 结果下方展示「改善建议」清单
|
||||
|
||||
**涉及模块**
|
||||
- 前端:`frontend/src/pages/tools/HealthCheck.tsx`
|
||||
|
||||
**完成方式** ✅
|
||||
- 前端 `HealthCheck.tsx` 新增评分标准说明卡片,展示 3 级评分等级:85+ 健康(绿)、60-84 中等风险(黄)、<60 高风险(红)
|
||||
- 卡片底部展示 6 维度评分构成说明:合同管理、薪酬社保、考勤加班、规章制度、解聘合规、证据链
|
||||
- 每个维度列出具体评分要素(如合同签订率、社保覆盖率等)
|
||||
|
||||
---
|
||||
|
||||
## 需求 8:试用期员工区分与转正提醒 🔴
|
||||
|
||||
**问题描述**
|
||||
- 导入员工时无法区分试用期和正式员工
|
||||
- 系统没有试用期到期转正提醒
|
||||
|
||||
**优化方案**
|
||||
1. 花名册导入模板新增「员工状态」列(试用期/正式),默认试用期
|
||||
2. 花名册列表新增「员工状态」列和筛选
|
||||
3. 试用期员工显示试用期到期日期,到期前 30/15/7 天自动提醒
|
||||
4. 工作日历自动显示试用期到期事件
|
||||
5. 总览待办事项中新增「试用期即将到期」提醒
|
||||
|
||||
**涉及模块**
|
||||
- 后端:`backend/prisma/schema.prisma` — Employee 模型新增 status/probationEndDate 字段
|
||||
- 后端:`backend/src/routes/roster.routes.ts` — 导入和查询支持
|
||||
- 前端:`frontend/src/pages/Roster.tsx` — 列表展示和筛选
|
||||
- 前端:`frontend/src/pages/Dashboard.tsx` — 待办提醒
|
||||
|
||||
**完成方式** ✅
|
||||
- 后端 `roster.routes.ts` 返回 `probationInfo` 对象:含 `isProbation`、`probationMonths`、`probationEndDate`、`daysToConfirmation`、`isExpiringSoon`
|
||||
- 试用期状态从合同 `probationMonths` + `hireDate` 动态计算,无需新增 schema 字段
|
||||
- 前端花名册列表试用期员工显示橙色标签(试用期 X 个月 · 剩余 Y 天)
|
||||
- 前端状态筛选新增「试用期」选项,发送 `ACTIVE` 到后端后本地过滤 `probationInfo.isProbation`
|
||||
|
||||
---
|
||||
|
||||
## 需求 9:考勤页面增加导入入口 🟡
|
||||
|
||||
**问题描述**
|
||||
- 考勤数据导入入口在「设置 → 数据导入」中,用户在考勤页面找不到导入功能
|
||||
|
||||
**优化方案**
|
||||
1. 在「考勤管理」页面工具栏新增「导入考勤」按钮
|
||||
2. 点击后弹出导入弹窗,包含:模板下载 + 文件上传 + 导入结果反馈
|
||||
3. 各功能模块的导入入口就近放置(薪税管理也同步处理)
|
||||
|
||||
**涉及模块**
|
||||
- 前端:`frontend/src/pages/Attendance.tsx` — 新增导入按钮和弹窗
|
||||
- 后端:`backend/src/routes/import.routes.ts` — 考勤导入接口
|
||||
|
||||
**完成方式** ✅
|
||||
- 前端 `Attendance.tsx` 考勤确认 Tab 工具栏新增「导入考勤」按钮
|
||||
- 点击弹出导入弹窗,包含:模板下载、文件上传、导入结果反馈(考勤记录/加班/员工/合同条数)
|
||||
- 复用已有 `POST /import/excel` 端点,支持考勤记录 Sheet 导入
|
||||
- 导入后自动刷新考勤列表和统计数据
|
||||
|
||||
---
|
||||
|
||||
## 需求 10:花名册导入体验优化 🔴
|
||||
|
||||
**问题描述**
|
||||
- 模板繁琐,没有标注必填项
|
||||
- 性别需要手动填写,但可从身份证号自动识别
|
||||
|
||||
**优化方案**
|
||||
1. 同需求 1:模板标注必填项
|
||||
2. 性别字段改为选填,后端导入时根据身份证号自动识别性别(身份证第 17 位奇数=男,偶数=女)
|
||||
3. 若用户手动填写了性别且与身份证识别不一致,以身份证为准并提示
|
||||
4. 导入模板中性别列标注「选填,留空自动识别」
|
||||
|
||||
**涉及模块**
|
||||
- 后端:`backend/src/routes/import.routes.ts` — 导入逻辑
|
||||
- 后端:`backend/src/services/` — 身份证解析工具函数
|
||||
|
||||
**完成方式** ✅(与需求 1 合并实现)
|
||||
- 模板必填字段加 `*` 号标注
|
||||
- 性别字段改为选填,导入时从身份证号第 17 位自动识别(奇数=男,偶数=女)
|
||||
- 若用户未填性别则自动识别,若已填则以用户填写为准
|
||||
|
||||
---
|
||||
|
||||
## 需求 11:薪税发薪批次创建流程优化 🔴
|
||||
|
||||
**问题描述**
|
||||
- 上一批次未归档就无法创建新批次,不便于处理多笔发薪
|
||||
- 创建发薪只能选全部人员或空白创建后逐个勾选,效率低
|
||||
|
||||
**优化方案**
|
||||
1. **允许多个未归档批次并存**:移除「上一批次未归档不能创建新批次」的限制
|
||||
2. **创建发薪时支持先选人再创建**:
|
||||
- 新增「选择发薪人员」步骤:可按部门筛选、多选员工、搜索勾选
|
||||
- 确认人员后点击「创建发薪批次」,仅包含选中员工
|
||||
3. 保留「全部人员」快捷选项,同时支持「自定义选择」模式
|
||||
|
||||
**涉及模块**
|
||||
- 前端:`frontend/src/pages/Money.tsx` — 发薪批次创建流程
|
||||
- 后端:`backend/src/routes/payroll2.routes.ts` — 批次创建接口支持指定员工列表
|
||||
- 后端:`backend/src/services/payroll.service.ts` — 批次创建逻辑
|
||||
|
||||
**完成方式** ✅
|
||||
- 后端移除「当月存在未归档批次则拒绝创建」的限制,允许多个未归档批次并存
|
||||
- `createBatchSchema` 新增 `mode: 'custom'` 和 `employeeIds: string[]` 可选参数
|
||||
- 后端新增 `custom` 模式分支:仅拉入 `employeeIds` 指定的员工创建批次
|
||||
- 前端创建批次弹窗新增「自定义选择员工」选项
|
||||
- 新增 `CustomEmployeeSelector` 组件:支持按部门筛选、姓名搜索、全选/取消全选、勾选指定员工
|
||||
- 前端移除创建按钮上的未归档批次拦截逻辑
|
||||
- **归档时强制重算**:批次归档前遍历所有条目调用 `calcBatchEntry` 重算个税和社保,确保累计预扣计算包含先前已归档批次的数据,解决多批次并存时累计个税不准的问题
|
||||
- `calcBatchEntry` 内部仍只查询 `status: 'ARCHIVED'` 的批次做累计计算
|
||||
- DRAFT 批次创建时基于已归档历史数据预计算(可能不包含同月其他 DRAFT 批次)
|
||||
- 归档时重算确保最终值准确:批次A先归档 → 批次B归档时重算会包含A的累计数据
|
||||
- 手动覆盖的社保值通过 `overrideSocial` 参数保留
|
||||
- 重算后更新批次汇总金额,返回重算条目数和错误信息
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
|
||||
| 优先级 | 需求编号 | 需求名称 | 状态 |
|
||||
|--------|----------|----------|------|
|
||||
| 🔴 高 | 1 | 花名册导入模板标注必填项 | ✅ 已完成 |
|
||||
| 🔴 高 | 3 | 导入结果反馈不明确 | ✅ 已完成 |
|
||||
| 🔴 高 | 4 | 证据链导出改为 Excel | ✅ 已完成 |
|
||||
| 🔴 高 | 5 | 身份证后四位搜索修复 | ✅ 已完成 |
|
||||
| 🔴 高 | 8 | 试用期员工区分与转正提醒 | ✅ 已完成 |
|
||||
| 🔴 高 | 10 | 导入性别自动识别 | ✅ 已完成 |
|
||||
| 🔴 高 | 11 | 薪税发薪批次流程优化 | ✅ 已完成 |
|
||||
| 🟡 中 | 2 | 专项附加扣除批量导入 | ✅ 已完成 |
|
||||
| 🟡 中 | 6 | 文本模板库功能完善 | ✅ 已完成 |
|
||||
| 🟡 中 | 7 | 用工体检评分标准说明 | ✅ 已完成 |
|
||||
| 🟡 中 | 9 | 考勤页面增加导入入口 | ✅ 已完成 |
|
||||
|
||||
---
|
||||
|
||||
*以上需求按用户反馈整理,🔴 高优先级和 🟡 中优先级需求已全部完成(2026-07-29)。*
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane } from 'lucide-react'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -88,8 +89,14 @@ export default function Attendance() {
|
||||
|
||||
// ========== 考勤确认 Tab ==========
|
||||
function ConfirmTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [filterDepartment, setFilterDepartment] = useState('')
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importResult, setImportResult] = useState<any>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['attendance', month, filterDepartment],
|
||||
@@ -120,6 +127,9 @@ function ConfirmTab() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />导入考勤
|
||||
</Button>
|
||||
<select
|
||||
value={filterDepartment}
|
||||
onChange={e => setFilterDepartment(e.target.value)}
|
||||
@@ -195,6 +205,99 @@ function ConfirmTab() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入考勤弹窗 */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
|
||||
<Card className="max-w-lg w-full" >
|
||||
<div onClick={(e) => e.stopPropagation()} className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">导入考勤数据 — {month}</h2>
|
||||
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/import/template`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '员工导入模板.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('下载模板失败') }
|
||||
}}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />下载模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
|
||||
模板中「考勤记录」Sheet 包含:姓名、身份证号、日期、考勤状态、上下班时间。身份证号优先匹配,未填时用姓名匹配。
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="attendance-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
|
||||
<label htmlFor="attendance-import-file" className="cursor-pointer text-xs text-primary hover:underline">
|
||||
{importFile ? importFile.name : '点击选择 Excel 文件'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
{importResult.attendance > 0 && <div>考勤记录:{importResult.attendance} 条</div>}
|
||||
{importResult.overtime > 0 && <div>加班记录:{importResult.overtime} 条</div>}
|
||||
{importResult.employees > 0 && <div>员工:{importResult.employees} 人</div>}
|
||||
{importResult.contracts > 0 && <div>合同:{importResult.contracts} 份</div>}
|
||||
{importResult.skipped > 0 && <div className="text-amber-600">跳过 {importResult.skipped} 条</div>}
|
||||
{importResult.errors?.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-green-200">
|
||||
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
|
||||
{importResult.errors.length > 5 && <div className="text-amber-600">...还有 {importResult.errors.length - 5} 条</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}>取消</Button>
|
||||
<Button size="sm" onClick={async () => {
|
||||
if (!importFile) return toast.error('请选择文件')
|
||||
setImporting(true)
|
||||
setImportResult(null)
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile)
|
||||
const res = await fetch('/api/v1/import/excel', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) { toast.error(data.error?.message || '导入失败') }
|
||||
else {
|
||||
setImportResult(data.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['attendance'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
|
||||
toast.success('考勤数据导入完成')
|
||||
}
|
||||
} catch (e: any) { toast.error(e?.message || '导入失败') }
|
||||
finally { setImporting(false) }
|
||||
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,89 @@ export default function Money() {
|
||||
|
||||
// ========== 发薪批次管理 ==========
|
||||
|
||||
function CustomEmployeeSelector({ selectedIds, onChange }: { selectedIds: string[]; onChange: (ids: string[]) => void }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterDept, setFilterDept] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any>({
|
||||
queryKey: ['roster-for-batch', search, filterDept],
|
||||
queryFn: async () => {
|
||||
const params: any = { pageSize: 999 }
|
||||
if (search) params.search = search
|
||||
if (filterDept) params.department = filterDept
|
||||
params.status = 'ACTIVE'
|
||||
const res = await api.get('/roster', { params }) as any
|
||||
return res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: deptList } = useQuery<string[]>({
|
||||
queryKey: ['roster-departments'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/departments') as any
|
||||
return res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter(x => x !== id))
|
||||
} else {
|
||||
onChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (employees && employees.every((e: any) => selectedIds.includes(e.id))) {
|
||||
onChange(selectedIds.filter(id => !employees.some((e: any) => e.id === id)))
|
||||
} else {
|
||||
const newIds = new Set([...selectedIds, ...(employees?.map((e: any) => e.id) || [])])
|
||||
onChange(Array.from(newIds))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>选择发薪人员</Label>
|
||||
<span className="text-xs text-gray-500">已选 {selectedIds.length} 人</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="搜索姓名" value={search} onChange={(e) => setSearch(e.target.value)} className="!w-40" />
|
||||
<select value={filterDept} onChange={(e) => setFilterDept(e.target.value)} className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm">
|
||||
<option value="">全部部门</option>
|
||||
{deptList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
{employees && employees.length > 0 && (
|
||||
<button onClick={toggleAll} className="text-xs text-primary hover:underline whitespace-nowrap">
|
||||
{employees.every((e: any) => selectedIds.includes(e.id)) ? '取消全选' : '全选当前'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded">
|
||||
{employees && employees.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{employees.map((e: any) => (
|
||||
<tr key={e.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => toggle(e.id)}>
|
||||
<td className="px-2 py-1.5 w-8">
|
||||
<input type="checkbox" checked={selectedIds.includes(e.id)} onChange={() => toggle(e.id)} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 font-medium">{e.name}</td>
|
||||
<td className="px-2 py-1.5 text-gray-500">{e.department}</td>
|
||||
<td className="px-2 py-1.5 text-gray-400 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-4 text-center text-gray-400 text-xs">暂无员工</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BatchManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
@@ -73,8 +156,9 @@ function BatchManager() {
|
||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
|
||||
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch'>('copy_last')
|
||||
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
|
||||
const [sourceBatchId, setSourceBatchId] = useState<string>('')
|
||||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
@@ -206,11 +290,6 @@ function BatchManager() {
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button onClick={() => {
|
||||
const hasDraft = batches?.some((b: any) => b.status === 'DRAFT' && b.month === month)
|
||||
if (hasDraft) {
|
||||
toast.error('当月存在未归档的批次,请先归档后再创建新批次')
|
||||
return
|
||||
}
|
||||
setCreateType('REGULAR'); setShowCreateModal(true)
|
||||
}} className="shrink-0">
|
||||
<Plus className="w-4 h-4 mr-1" />创建发薪批次
|
||||
@@ -233,11 +312,12 @@ function BatchManager() {
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据初始化模式</Label>
|
||||
<Select value={createMode} onChange={(e) => { setCreateMode(e.target.value as any); setSourceBatchId('') }}>
|
||||
<Select value={createMode} onChange={(e) => { setCreateMode(e.target.value as any); setSourceBatchId(''); setSelectedEmployeeIds([]) }}>
|
||||
<option value="copy_last">复制上月数据</option>
|
||||
<option value="blank_employees">本月空白(拉入员工,金额为0)</option>
|
||||
<option value="blank_all">全空白(不拉入员工)</option>
|
||||
<option value="copy_batch">复制指定批次</option>
|
||||
<option value="custom">自定义选择员工</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,16 +335,20 @@ function BatchManager() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{createMode === 'custom' && (
|
||||
<CustomEmployeeSelector selectedIds={selectedEmployeeIds} onChange={setSelectedEmployeeIds} />
|
||||
)}
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
{createMode === 'copy_last' && <p>拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动计算社保个税</p>}
|
||||
{createMode === 'blank_employees' && <p>拉入在职员工,所有金额为0,需手动填写</p>}
|
||||
{createMode === 'blank_all' && <p>创建空批次,不拉入员工,后续手动添加人员和填写数据</p>}
|
||||
{createMode === 'copy_batch' && <p>从指定的已归档批次复制员工和薪资数据</p>}
|
||||
{createMode === 'custom' && <p>按部门筛选、搜索、勾选指定员工创建批次</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined })}
|
||||
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId)}
|
||||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
|
||||
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '确认创建'}
|
||||
</Button>
|
||||
|
||||
@@ -51,7 +51,8 @@ export default function Roster() {
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
if (debouncedSearch) params.search = debouncedSearch
|
||||
if (filterStatus) params.status = filterStatus
|
||||
if (filterStatus && filterStatus !== 'PROBATION') params.status = filterStatus
|
||||
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||
if (filterDepartment) params.department = filterDepartment
|
||||
const res = await api.get('/roster', { params }) as any
|
||||
@@ -59,7 +60,10 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
const employees = rosterData?.data || []
|
||||
const employees = (rosterData?.data || []).filter((e: any) => {
|
||||
if (filterStatus === 'PROBATION') return e.probationInfo?.isProbation
|
||||
return true
|
||||
})
|
||||
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
|
||||
|
||||
const addMutation = useMutation({
|
||||
@@ -255,6 +259,7 @@ export default function Roster() {
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">在职</option>
|
||||
<option value="PROBATION">试用期</option>
|
||||
<option value="PRE_HIRE">预入职</option>
|
||||
<option value="RESIGNED">离职</option>
|
||||
</select>
|
||||
@@ -388,6 +393,15 @@ export default function Roster() {
|
||||
}`}>
|
||||
{e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'}
|
||||
</span>
|
||||
{e.probationInfo?.isProbation && (
|
||||
<span className={`ml-1 px-2 py-0.5 rounded text-xs ${
|
||||
e.probationInfo.isExpiring
|
||||
? 'bg-orange-50 text-orange-700 border border-orange-200'
|
||||
: 'bg-amber-50 text-amber-700 border border-amber-200'
|
||||
}`}>
|
||||
试用期{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `剩${e.probationInfo.daysToConfirm}天`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
|
||||
@@ -1115,16 +1115,50 @@ function InitImport() {
|
||||
{result && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
<div>员工:{result.employees} 人</div>
|
||||
<div>合同:{result.contracts} 份</div>
|
||||
{result.overtime > 0 && <div>加班记录:{result.overtime} 条</div>}
|
||||
{result.disciplinary > 0 && <div>违纪记录:{result.disciplinary} 条</div>}
|
||||
{result.attendance > 0 && <div>考勤记录:{result.attendance} 条</div>}
|
||||
<div className="flex gap-4">
|
||||
<span>成功导入:员工 {result.employees} 人、合同 {result.contracts} 份</span>
|
||||
{result.overtime > 0 && <span>加班 {result.overtime} 条</span>}
|
||||
{result.disciplinary > 0 && <span>违纪 {result.disciplinary} 条</span>}
|
||||
{result.attendance > 0 && <span>考勤 {result.attendance} 条</span>}
|
||||
</div>
|
||||
{result.skipped > 0 && (
|
||||
<div className="text-amber-600">跳过 {result.skipped} 条(数据不完整或格式错误)</div>
|
||||
)}
|
||||
{result.duplicates > 0 && (
|
||||
<div className="text-amber-600">重复 {result.duplicates} 条(身份证号已存在)</div>
|
||||
)}
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
<div className="font-medium text-amber-600">部分错误({result.errors.length}条):</div>
|
||||
<div className="font-medium text-amber-600 flex items-center justify-between">
|
||||
<span>错误详情({result.errors.length}条):</span>
|
||||
<button className="text-xs text-primary hover:underline" onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const errorList = result.errors.map((e: string, i: number) => {
|
||||
const detail = result.details?.[i] || {}
|
||||
return { sheet: detail.sheet || '员工信息', row: detail.row || i + 2, name: detail.name || '', errors: [e] }
|
||||
})
|
||||
const res = await fetch('/api/v1/import/excel/error-log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ errors: errorList }),
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '导入错误日志.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('导出错误日志失败')
|
||||
}
|
||||
}}>
|
||||
导出错误日志
|
||||
</button>
|
||||
</div>
|
||||
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条</div>}
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条,请导出错误日志查看全部</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
@@ -1219,6 +1220,11 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState<any>(null)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importResult, setImportResult] = useState<any>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 查询当月所有员工的专项附加扣除
|
||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||
@@ -1326,6 +1332,13 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
||||
>
|
||||
{batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowImport(true)}
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />批量导入
|
||||
</Button>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1439,6 +1452,91 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 批量导入弹窗 */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
|
||||
<Card className="max-w-lg w-full" >
|
||||
<div onClick={(e) => e.stopPropagation()} className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">批量导入专项附加扣除 — {month}</h2>
|
||||
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/import/special-deduction/template`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '专项附加扣除导入模板.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('下载模板失败') }
|
||||
}}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />下载模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="special-deduction-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
|
||||
<label htmlFor="special-deduction-import-file" className="cursor-pointer text-xs text-primary hover:underline">
|
||||
{importFile ? importFile.name : '点击选择 Excel 文件'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
<div>成功 {importResult.updated} 条,跳过 {importResult.skipped} 条,共 {importResult.total} 条</div>
|
||||
{importResult.errors?.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-green-200">
|
||||
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
|
||||
{importResult.errors.length > 5 && <div className="text-amber-600">...还有 {importResult.errors.length - 5} 条</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}>取消</Button>
|
||||
<Button size="sm" onClick={async () => {
|
||||
if (!importFile) return toast.error('请选择文件')
|
||||
setImporting(true)
|
||||
setImportResult(null)
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile)
|
||||
formData.append('month', month)
|
||||
const res = await fetch('/api/v1/import/special-deduction', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) { toast.error(data.error?.message || '导入失败') }
|
||||
else {
|
||||
setImportResult(data.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
||||
toast.success(`导入完成:成功 ${data.data.updated} 条`)
|
||||
}
|
||||
} catch (e: any) { toast.error(e?.message || '导入失败') }
|
||||
finally { setImporting(false) }
|
||||
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight } from 'lucide-react'
|
||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
@@ -100,11 +101,40 @@ export default function Templates() {
|
||||
}
|
||||
}
|
||||
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(rendered)
|
||||
toast.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载 Word 文档')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyAsNew = () => {
|
||||
const text = rendered || detail?.content || ''
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('模板内容已复制,可粘贴到 Word 中编辑使用')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -113,6 +143,25 @@ export default function Templates() {
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">合同、制度、通知等常用文本模板,支持变量替换</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowHelp(!showHelp)}>
|
||||
<HelpCircle className="w-3.5 h-3.5 mr-1" />使用说明
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
<Card className="bg-blue-50/50">
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-1.5 font-medium text-gray-700"><BookOpen className="w-3.5 h-3.5" />使用说明</div>
|
||||
<div>1. 选择需要的模板分类(合同/制度/通知/协议),点击模板卡片打开详情</div>
|
||||
<div>2. 在弹窗中填写变量字段(如公司名称、员工姓名等),点击「渲染模板」生成完整文本</div>
|
||||
<div>3. 渲染后可「复制」到剪贴板,或「下载 Word」保存为 .doc 文件</div>
|
||||
<div>4. 「复制为新模板」将渲染结果复制到剪贴板,可粘贴到 Word 中进一步编辑</div>
|
||||
<div>5. 变量字段为选填,未填写的变量将保留 <code className="px-1 bg-gray-100 rounded">{'{{变量名}}'}</code> 占位符</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
|
||||
<button
|
||||
@@ -187,15 +236,30 @@ export default function Templates() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">渲染结果</span>
|
||||
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCopyAsNew} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制为新模板
|
||||
</button>
|
||||
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Download className="w-3 h-3" />下载 Word
|
||||
</button>
|
||||
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
|
||||
</div>
|
||||
) : detail?.content ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">模板原文</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">模板原文</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Download className="w-3 h-3" />下载 Word
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import api from "../../lib/api"
|
||||
import { useAuthStore } from "../../store/authStore"
|
||||
import Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
@@ -48,15 +49,23 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
'解聘记录': 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const text = generateEvidenceText(data)
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch(`/api/v1/roster/${employeeId}/evidence-chain/export`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const riskStyle: Record<string, string> = {
|
||||
|
||||
@@ -85,6 +85,48 @@ export default function HealthCheck() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评分标准说明 */}
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Stethoscope className="w-4 h-4 text-primary" />
|
||||
评分标准说明
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-safe" />
|
||||
<div>
|
||||
<div className="font-medium text-safe">85 分以上 · 健康</div>
|
||||
<div className="text-gray-500">用工管理规范,风险低</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-warning" />
|
||||
<div>
|
||||
<div className="font-medium text-warning">60-84 分 · 中等风险</div>
|
||||
<div className="text-gray-500">存在部分合规隐患</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-danger" />
|
||||
<div>
|
||||
<div className="font-medium text-danger">60 分以下 · 高风险</div>
|
||||
<div className="text-gray-500">需立即整改</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-2 text-xs text-gray-500 space-y-1">
|
||||
<div className="font-medium text-gray-600">6 维度评分构成:</div>
|
||||
<div>- <b>合同管理</b>:合同签订率、到期预警、试用期合规</div>
|
||||
<div>- <b>薪酬社保</b>:社保覆盖率、公积金缴纳、薪资合规</div>
|
||||
<div>- <b>考勤加班</b>:加班时长合规、考勤记录完整性</div>
|
||||
<div>- <b>规章制度</b>:制度公示、民主程序、员工知情</div>
|
||||
<div>- <b>解聘合规</b>:解除程序合法、补偿计算准确</div>
|
||||
<div>- <b>证据链</b>:证据完整性、仲裁可追溯性</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 总评分 */}
|
||||
<Card className={level.bg}>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
Reference in New Issue
Block a user