diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index f5caa32..452d77d 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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: '该账号未绑定企业' } }) diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 3a3e98b..d9918ec 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -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 diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 9b9d717..b00847a 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -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) } diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 600ee40..5043c23 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -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)[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 = { 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 = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } + const actionMap: Record = { 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 = { 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 = { 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 = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } + const termStatusMap: Record = { 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) => { diff --git a/backend/src/routes/template.routes.ts b/backend/src/routes/template.routes.ts index f73ad48..7ddecfe 100644 --- a/backend/src/routes/template.routes.ts +++ b/backend/src/routes/template.routes.ts @@ -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 diff --git a/docs/20260729-优化需求梳理-1.md b/docs/20260729-优化需求梳理-1.md new file mode 100644 index 0000000..302432d --- /dev/null +++ b/docs/20260729-优化需求梳理-1.md @@ -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)。* diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx index 77f313f..0d698f5 100644 --- a/frontend/src/pages/Attendance.tsx +++ b/frontend/src/pages/Attendance.tsx @@ -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(null) + const [importResult, setImportResult] = useState(null) + const [importing, setImporting] = useState(false) + const fileInputRef = useRef(null) const { data: list, isLoading } = useQuery({ queryKey: ['attendance', month, filterDepartment], @@ -120,6 +127,9 @@ function ConfirmTab() { return (
+ { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} /> + +
+ + {importResult && ( +
+
导入完成
+ {importResult.attendance > 0 &&
考勤记录:{importResult.attendance} 条
} + {importResult.overtime > 0 &&
加班记录:{importResult.overtime} 条
} + {importResult.employees > 0 &&
员工:{importResult.employees} 人
} + {importResult.contracts > 0 &&
合同:{importResult.contracts} 份
} + {importResult.skipped > 0 &&
跳过 {importResult.skipped} 条
} + {importResult.errors?.length > 0 && ( +
+ {importResult.errors.slice(0, 5).map((e: string, i: number) =>
{e}
)} + {importResult.errors.length > 5 &&
...还有 {importResult.errors.length - 5} 条
} +
+ )} +
+ )} + +
+ + +
+
+ + + + )} ) } diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index 8aba022..6ee9769 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -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({ + 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({ + 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 ( +
+
+ + 已选 {selectedIds.length} 人 +
+
+ setSearch(e.target.value)} className="!w-40" /> + + {employees && employees.length > 0 && ( + + )} +
+
+ {employees && employees.length > 0 ? ( + + + {employees.map((e: any) => ( + toggle(e.id)}> + + + + + + ))} + +
+ toggle(e.id)} /> + {e.name}{e.department}¥{fmt(e.monthlySalary)}
+ ) : ( +
暂无员工
+ )} +
+
+ ) +} + function BatchManager() { const queryClient = useQueryClient() const confirm = useConfirm() @@ -73,8 +156,9 @@ function BatchManager() { const [selectedBatchId, setSelectedBatchId] = useState(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('') + const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) @@ -206,11 +290,6 @@ function BatchManager() { )}
- { setCreateMode(e.target.value as any); setSourceBatchId(''); setSelectedEmployeeIds([]) }}> +
@@ -255,16 +335,20 @@ function BatchManager() { )} )} + {createMode === 'custom' && ( + + )}
{createMode === 'copy_last' &&

拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动计算社保个税

} {createMode === 'blank_employees' &&

拉入在职员工,所有金额为0,需手动填写

} {createMode === 'blank_all' &&

创建空批次,不拉入员工,后续手动添加人员和填写数据

} {createMode === 'copy_batch' &&

从指定的已归档批次复制员工和薪资数据

} + {createMode === 'custom' &&

按部门筛选、搜索、勾选指定员工创建批次

}
diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 19c7d19..cef0ba7 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -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() { > + @@ -388,6 +393,15 @@ export default function Roster() { }`}> {e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'} + {e.probationInfo?.isProbation && ( + + 试用期{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `剩${e.probationInfo.daysToConfirm}天`} + + )} {e.hireDate?.toString().slice(0, 10)} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 71fc2ab..1dd36ca 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1115,16 +1115,50 @@ function InitImport() { {result && (
导入完成
-
员工:{result.employees} 人
-
合同:{result.contracts} 份
- {result.overtime > 0 &&
加班记录:{result.overtime} 条
} - {result.disciplinary > 0 &&
违纪记录:{result.disciplinary} 条
} - {result.attendance > 0 &&
考勤记录:{result.attendance} 条
} +
+ 成功导入:员工 {result.employees} 人、合同 {result.contracts} 份 + {result.overtime > 0 && 加班 {result.overtime} 条} + {result.disciplinary > 0 && 违纪 {result.disciplinary} 条} + {result.attendance > 0 && 考勤 {result.attendance} 条} +
+ {result.skipped > 0 && ( +
跳过 {result.skipped} 条(数据不完整或格式错误)
+ )} + {result.duplicates > 0 && ( +
重复 {result.duplicates} 条(身份证号已存在)
+ )} {result.errors?.length > 0 && (
-
部分错误({result.errors.length}条):
+
+ 错误详情({result.errors.length}条): + +
{result.errors.slice(0, 10).map((e: string, i: number) => (
{e}
))} - {result.errors.length > 10 &&
...还有 {result.errors.length - 10} 条
} + {result.errors.length > 10 &&
...还有 {result.errors.length - 10} 条,请导出错误日志查看全部
}
)}
diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index 7587dbc..4c86974 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -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(null) const [editForm, setEditForm] = useState(null) + const [showImport, setShowImport] = useState(false) + const [importFile, setImportFile] = useState(null) + const [importResult, setImportResult] = useState(null) + const [importing, setImporting] = useState(false) + const fileInputRef = useRef(null) // 查询当月所有员工的专项附加扣除 const { data: records = [], isLoading } = useQuery({ @@ -1326,6 +1332,13 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: > {batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`} + setMonth(e.target.value)} className="!w-32" />
@@ -1439,6 +1452,91 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: )} )} + + {/* 批量导入弹窗 */} + {showImport && ( +
setShowImport(false)}> + +
e.stopPropagation()} className="p-4"> +
+

批量导入专项附加扣除 — {month}

+ +
+ +
+
+ +
+ +
+ { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} /> + +
+ + {importResult && ( +
+
导入完成
+
成功 {importResult.updated} 条,跳过 {importResult.skipped} 条,共 {importResult.total} 条
+ {importResult.errors?.length > 0 && ( +
+ {importResult.errors.slice(0, 5).map((e: string, i: number) =>
{e}
)} + {importResult.errors.length > 5 &&
...还有 {importResult.errors.length - 5} 条
} +
+ )} +
+ )} + +
+ + +
+
+
+
+
+ )} ) } diff --git a/frontend/src/pages/Templates.tsx b/frontend/src/pages/Templates.tsx index f578655..0c6bfa6 100644 --- a/frontend/src/pages/Templates.tsx +++ b/frontend/src/pages/Templates.tsx @@ -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 (
@@ -113,6 +143,25 @@ export default function Templates() {

合同、制度、通知等常用文本模板,支持变量替换

+
+ +
+ + {showHelp && ( + +
+
使用说明
+
1. 选择需要的模板分类(合同/制度/通知/协议),点击模板卡片打开详情
+
2. 在弹窗中填写变量字段(如公司名称、员工姓名等),点击「渲染模板」生成完整文本
+
3. 渲染后可「复制」到剪贴板,或「下载 Word」保存为 .doc 文件
+
4. 「复制为新模板」将渲染结果复制到剪贴板,可粘贴到 Word 中进一步编辑
+
5. 变量字段为选填,未填写的变量将保留 {'{{变量名}}'} 占位符
+
+
+ )} +
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => ( +
+ + + +
{rendered}
) : detail?.content ? (
-
模板原文
+
+ 模板原文 +
+ +
+
{detail.content}
) : null} diff --git a/frontend/src/pages/roster/EvidenceChain.tsx b/frontend/src/pages/roster/EvidenceChain.tsx index 396d2e0..eb29293 100644 --- a/frontend/src/pages/roster/EvidenceChain.tsx +++ b/frontend/src/pages/roster/EvidenceChain.tsx @@ -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 = { diff --git a/frontend/src/pages/tools/HealthCheck.tsx b/frontend/src/pages/tools/HealthCheck.tsx index aceffb3..7486b58 100644 --- a/frontend/src/pages/tools/HealthCheck.tsx +++ b/frontend/src/pages/tools/HealthCheck.tsx @@ -85,6 +85,48 @@ export default function HealthCheck() { + {/* 评分标准说明 */} + +
+
+ + 评分标准说明 +
+
+
+ +
+
85 分以上 · 健康
+
用工管理规范,风险低
+
+
+
+ +
+
60-84 分 · 中等风险
+
存在部分合规隐患
+
+
+
+ +
+
60 分以下 · 高风险
+
需立即整改
+
+
+
+
+
6 维度评分构成:
+
- 合同管理:合同签订率、到期预警、试用期合规
+
- 薪酬社保:社保覆盖率、公积金缴纳、薪资合规
+
- 考勤加班:加班时长合规、考勤记录完整性
+
- 规章制度:制度公示、民主程序、员工知情
+
- 解聘合规:解除程序合法、补偿计算准确
+
- 证据链:证据完整性、仲裁可追溯性
+
+
+
+ {/* 总评分 */}