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

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