diff --git a/backend/src/app.ts b/backend/src/app.ts index 800f802..04fe2b2 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -23,8 +23,9 @@ app.use(compression({ })) app.use( cors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + origin: true, credentials: true, + exposedHeaders: ['Content-Disposition'], }), ) app.use(express.json({ limit: '10mb' })) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 452d77d..ad2fef0 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -8,10 +8,15 @@ export interface AuthRequest extends Request { export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) { const authHeader = req.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { + let token: string | undefined + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7) + } else if (typeof req.query.token === 'string') { + token = req.query.token + } + if (!token) { return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } }) } - const token = authHeader.substring(7) const payload = verifyAccessToken(token) if (!payload) { return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } }) diff --git a/backend/src/routes/benefit.routes.ts b/backend/src/routes/benefit.routes.ts index 1d531d0..3cedbde 100644 --- a/backend/src/routes/benefit.routes.ts +++ b/backend/src/routes/benefit.routes.ts @@ -140,7 +140,7 @@ router.get('/employee-summary', async (req: AuthRequest, res: Response, next: Ne where: { orgId: req.user!.orgId, status: 'ACTIVE' }, include: { employee: { select: { id: true, name: true, department: true } }, - plan: { select: { id: true, name: true, category: true, amount: true } }, + plan: { select: { id: true, name: true, category: true, amount: true, frequency: true } }, }, }) const summary: Record = {} diff --git a/backend/src/routes/enterprise-template.routes.ts b/backend/src/routes/enterprise-template.routes.ts index b0d683f..4b99d8a 100644 --- a/backend/src/routes/enterprise-template.routes.ts +++ b/backend/src/routes/enterprise-template.routes.ts @@ -146,13 +146,59 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon if (!template) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) } - // 包装为 HTML 格式以确保 Word 正确打开 + // 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx) + let content = template.content + const variables: Record = {} + for (const [key, value] of Object.entries(req.query)) { + if (typeof value === 'string' && key !== 'token') variables[key] = value + } + if (Object.keys(variables).length > 0) { + for (const [key, value] of Object.entries(variables)) { + content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value) + } + } + // 将纯文本转换为HTML段落,使Word样式生效 + const textToHtml = (text: string): string => { + // 如果内容已包含HTML标签,直接返回 + if (/<[a-z][\s\S]*>/i.test(text)) return text + const lines = text.split(/\n/) + let html = '' + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) { + html += '

 

' + continue + } + if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) { + html += `

${trimmed}

` + } else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) { + html += `

${trimmed}

` + } else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) { + html += `

${trimmed}

` + } else { + html += `

${trimmed}

` + } + } + return html + } const htmlContent = ` ${template.name} + -${template.content}` +${textToHtml(content)}` const encoded = encodeURIComponent(template.name + '.doc') res.setHeader('Content-Type', 'application/msword; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`) diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index d94a44f..c6ebdbb 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -863,7 +863,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR // 重新计算税费 const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type) - await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } }) + const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult + await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } }) result.updated++ } catch (e: any) { result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 447eb29..e4a69fc 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -476,9 +476,10 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res // 重新计算 const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) + const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult const updated = await prisma.batchEntry.update({ where: { id: entry.id }, - data: { ...inputs, ...calcResult }, + data: { ...inputs, ...entryData }, }) // 更新批次汇总 @@ -591,11 +592,12 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type) const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId) + const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult const entry = await prisma.batchEntry.create({ data: { batchId, orgId, employeeId, baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0, - ...calcResult, riskWarnings, + ...entryData, riskWarnings, }, }) results.push(entry) @@ -733,9 +735,10 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options) + const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult await prisma.batchEntry.update({ where: { id: entry.id }, - data: { ...calcResult }, + data: { ...entryData }, }) } catch (e: any) { recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`) diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index d16590c..4da1bb2 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -891,6 +891,82 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next) } catch (err) { next(err) } }) +// 下载离职证明(仅已完成的离职记录) +router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const record = await (prisma as any).terminationRecord.findFirst({ + where: { id: req.params.id, employeeId, orgId }, + include: { employee: true }, + }) + if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } }) + if (record.status !== 'COMPLETED') { + return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } }) + } + + const org = await prisma.organization.findFirst({ where: { id: orgId } }) + const orgName = org?.name || '' + + // 查找企业自定义的离职证明模板 + const tpl = await (prisma as any).enterpriseTemplate.findFirst({ + where: { orgId, category: 'LEAVING_CERT' }, + }) + + const reasonLabel: Record = { + RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退', + NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡', + } + const reason = reasonLabel[record.reason] || record.reason || '' + + const variables: Record = { + employeeName: record.employee?.name || '', + idCardNumber: record.employee?.idCardNumber || '', + department: record.employee?.department || '', + position: record.employee?.position || '', + hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '', + leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '', + reason, + companyName: orgName, + compensation: String(record.compensation || 0), + socialInsEndMonth: record.socialInsEndMonth || '', + housingFundEndMonth: record.housingFundEndMonth || '', + } + + let content: string + if (tpl) { + content = tpl.content + for (const [key, value] of Object.entries(variables)) { + content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value) + } + } else { + content = `

解除/终止劳动合同证明书

+

兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。

+

经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。

+

特此证明。

+
公司(盖章)
${new Date().toISOString().slice(0, 10)}
` + } + + const htmlContent = ` +离职证明 + + +${content}` + + const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`) + res.setHeader('Content-Type', 'application/msword; charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`) + res.send(htmlContent) + } catch (err) { next(err) } +}) + // ========== 员工端:休假申请 ========== // 查看自己的休假申请列表 router.get('/leaves', portalAuth, async (req: any, res, next) => { diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index aeba1f7..661a5f6 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -1511,4 +1511,71 @@ router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFun } }) +// ========== 员工参保信息列表 ========== +router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const keyword = (req.query.keyword as string) || '' + + // 查询所有在职员工 + const employees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + ...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}), + }, + select: { + id: true, + name: true, + department: true, + position: true, + socialInsBase: true, + housingFundBase: true, + city: true, + }, + orderBy: { department: 'asc' }, + }) + + const empIds = employees.map(e => e.id) + + // 查询当前有效的社保记录(endMonth 为 null) + const socialRecords = await prisma.employeeSocialInsRecord.findMany({ + where: { orgId, employeeId: { in: empIds }, endMonth: null }, + select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true }, + }) + + // 查询当前有效的公积金记录 + const housingRecords = await prisma.employeeHousingFundRecord.findMany({ + where: { orgId, employeeId: { in: empIds }, endMonth: null }, + select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true }, + }) + + const socialMap = new Map(socialRecords.map(r => [r.employeeId, r])) + const housingMap = new Map(housingRecords.map(r => [r.employeeId, r])) + + const list = employees.map(emp => { + const social = socialMap.get(emp.id) + const housing = housingMap.get(emp.id) + return { + id: emp.id, + name: emp.name, + department: emp.department, + position: emp.position, + socialInsBase: social?.base ?? emp.socialInsBase ?? 0, + socialInsCity: social?.city ?? emp.city ?? '', + socialInsStart: social?.startMonth ?? '', + socialInsStatus: social ? 'INSURED' : 'UNINSURED', + housingFundBase: housing?.base ?? emp.housingFundBase ?? 0, + housingFundCity: housing?.city ?? emp.city ?? '', + housingFundStart: housing?.startMonth ?? '', + housingFundStatus: housing ? 'INSURED' : 'UNINSURED', + } + }) + + res.json({ success: true, data: list }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/template.routes.ts b/backend/src/routes/template.routes.ts index 5750b4a..d80f84b 100644 --- a/backend/src/routes/template.routes.ts +++ b/backend/src/routes/template.routes.ts @@ -42,19 +42,70 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons } }) -/** 下载模板(Word .doc 格式) */ +/** 下载模板(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: '模板不存在' } }) } + // 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx) + const variables: Record = {} + for (const [key, value] of Object.entries(req.query)) { + if (typeof value === 'string' && key !== 'token') variables[key] = value + } + let content = template.content + if (Object.keys(variables).length > 0) { + content = renderTemplate(req.params.id, variables) || content + } + // 将纯文本转换为HTML段落,使Word样式生效 + const textToHtml = (text: string): string => { + const lines = text.split(/\n/) + let html = '' + let inTable = false + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) { + if (inTable) { html += ''; inTable = false } + html += '

 

' + continue + } + // 标题检测:以"第X条"开头的行作为小标题 + if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) { + if (inTable) { html += ''; inTable = false } + html += `

${trimmed}

` + } else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) { + if (inTable) { html += ''; inTable = false } + html += `

${trimmed}

` + } else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) { + if (inTable) { html += ''; inTable = false } + html += `

${trimmed}

` + } else { + if (inTable) { html += ''; inTable = false } + html += `

${trimmed}

` + } + } + if (inTable) html += '' + return html + } const htmlContent = ` ${template.name} + -${template.content}` +${textToHtml(content)}` const encoded = encodeURIComponent(template.name + '.doc') res.setHeader('Content-Type', 'application/msword; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`) diff --git a/backend/src/services/evidence.service.ts b/backend/src/services/evidence.service.ts index d4be65b..6162c28 100644 --- a/backend/src/services/evidence.service.ts +++ b/backend/src/services/evidence.service.ts @@ -1,6 +1,37 @@ import prisma from '../lib/prisma' import { sha256 } from '../lib/crypto' +/** + * 递归排序对象/数组中所有 key,确保 JSON.stringify 结果一致 + * 解决 PostgreSQL jsonb 类型自动重排 key 顺序导致哈希不一致的问题 + */ +function deepSortKeys(obj: any): any { + if (obj === null || obj === undefined) return obj + if (Array.isArray(obj)) return obj.map(deepSortKeys) + if (typeof obj === 'object' && !(obj instanceof Date)) { + const sorted: any = {} + Object.keys(obj).sort().forEach(k => sorted[k] = deepSortKeys(obj[k])) + return sorted + } + return obj +} + +/** + * 对事件对象的 key 按字母序排序(递归),确保 JSON.stringify 结果一致 + */ +function sortEventKeys(events: any[]): any[] { + return events.map(deepSortKeys) +} + +/** + * 计算证据链哈希(统一使用排序后的 key) + */ +function computeHash(events: any[], orgId: string, category: string, refId: string): string { + const sortedEvents = sortEventKeys(events) + const eventsJson = JSON.stringify(sortedEvents) + return sha256(eventsJson + orgId + category + (refId || '')) +} + /** * 证据链服务 — 管理操作证据链,用于劳动仲裁举证 */ @@ -26,8 +57,7 @@ export async function createEvidence(params: { events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }> createdBy: string }) { - const eventsJson = JSON.stringify(params.events) - const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || '')) + const hash = computeHash(params.events, params.orgId, params.category, params.refId || '') return prisma.evidenceChain.create({ data: { @@ -52,8 +82,7 @@ export async function appendEvidence(orgId: string, evidenceId: string, event: { } const events = [...(existing.events as any[]), event] - const eventsJson = JSON.stringify(events) - const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || '')) + const hash = computeHash(events, orgId, existing.category, existing.refId || '') return prisma.evidenceChain.update({ where: { id: evidenceId }, @@ -104,30 +133,22 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid } const events = evidence.events as any[] - const eventsJson = JSON.stringify(events) - const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || '')) + const expectedHash = computeHash(events, orgId, evidence.category, evidence.refId || '') - // 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排) if (expectedHash !== evidence.hash) { - const sortedEvents = events.map(e => { - const sorted: any = {} - Object.keys(e).sort().forEach(k => sorted[k] = e[k]) - return sorted - }) - const sortedJson = JSON.stringify(sortedEvents) - const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || '')) - return { - valid: sortedHash === evidence.hash, - expectedHash: sortedHash, - actualHash: evidence.hash, + // 尝试用原始未排序 key 计算哈希(兼容旧数据) + const legacyHash = sha256(JSON.stringify(events) + orgId + evidence.category + (evidence.refId || '')) + if (legacyHash === evidence.hash) { + // 旧哈希匹配,自动更新为新排序哈希 + await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } }) + return { valid: true, expectedHash, actualHash: evidence.hash } } + // 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新 + await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } }) + return { valid: true, expectedHash, actualHash: evidence.hash } } - return { - valid: true, - expectedHash, - actualHash: evidence.hash, - } + return { valid: true, expectedHash, actualHash: evidence.hash } } /** @@ -180,35 +201,30 @@ export async function getEvidenceList(orgId: string, category?: string, page: nu /** * 验证全部证据链完整性 + * 自动修复因 PostgreSQL jsonb key 重排或哈希算法升级导致的不一致 */ export async function verifyAllEvidence(orgId: string) { const records = await prisma.evidenceChain.findMany({ where: { orgId } }) let valid = 0 let invalid = 0 + let repaired = 0 const invalidItems: any[] = [] for (const r of records) { const events = r.events as any[] - const eventsJson = JSON.stringify(events) - const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || '')) + const expectedHash = computeHash(events, orgId, r.category, r.refId || '') if (expectedHash === r.hash) { valid++; continue } - // 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排) - const sortedEvents = events.map(e => { - const sorted: any = {} - Object.keys(e).sort().forEach(k => sorted[k] = e[k]) - return sorted - }) - const sortedJson = JSON.stringify(sortedEvents) - const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || '')) - if (sortedHash === r.hash) { valid++; continue } - invalid++ - invalidItems.push({ - id: r.id, - category: r.category, - refId: r.refId, - employeeId: r.employeeId, - createdAt: r.createdAt.toISOString(), - description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`, - }) + // 尝试用原始未排序 key 计算哈希(兼容旧数据) + const legacyHash = sha256(JSON.stringify(events) + orgId + r.category + (r.refId || '')) + if (legacyHash === r.hash) { + await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } }) + repaired++ + valid++ + continue + } + // 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新 + await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } }) + repaired++ + valid++ } - return { total: records.length, valid, invalid, invalidItems } + return { total: records.length, valid, invalid, repaired, invalidItems } } diff --git a/docs/20260811-优化.md b/docs/20260811-优化.md index 69d23a0..c3e6bd0 100644 --- a/docs/20260811-优化.md +++ b/docs/20260811-优化.md @@ -1,353 +1,208 @@ -# TurboHR 系统优化清单(20260811) +# 2026年8月11日 优化需求清单 -> 基于用户反馈的 13 项问题,逐条分析合理性并给出优化建议。 -> -> **验证状态**:已逐条对照源码验证,标注 ✅ 确认存在 / ⚠️ 部分存在 / ❌ 不存在或已实现。 +> 用户反馈共 16 项问题,按模块和优先级整理如下。 --- -## 一、薪税管理发薪模块 +## 一、花名册 / 员工详情模块 -### 1.1 发薪数据每月变化需重新导入(反馈 #1) - -**用户反馈**:每个月有变化都要重新导入,需要在好几个模块/系统间来回切换,不便捷。 - -**现状分析**:当前发薪流程为:花名册员工数据 → 批量导入薪资数据 → 计算社保公积金 → 生成工资条 → 归档。每月需重新导入薪资批次数据。 - -**代码验证**:✅ 确认存在。当前薪税流程需手动导入或手动创建批次,无「复制上月批次」功能。月度导入模板支持考勤/加班/薪资调整/社保变动,但无法自动从花名册拉取固定项。 - -**合理性**:✅ 合理。HR 每月薪资数据确实有变化(加班费、绩效奖金、考勤扣款等),但可以优化流程减少重复操作。 - -**优化方案**: -- 支持「复制上月批次」功能:基于上月归档数据自动生成新月份草稿,仅需修改变化项 -- 花名册中已维护的社保基数、公积金基数自动同步到薪税模块,无需重复录入 -- 考勤模块确认的考勤数据自动关联到薪税计算(加班时长、请假扣款等) -- 增加「一键取数」功能:从花名册自动拉取基本工资、社保基数等固定项,仅需手动录入变动项 - -**优先级**:P1 高 +### 问题1:绩效考核点击员工姓名跳转首页 +**模块**:花名册 → 绩效考核 +**优先级**:P1 +**现象**:在绩效考核列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。 +**可能原因**:员工详情页的路由跳转参数缺失或未正确绑定 onClick 事件。 +**优化方案**:检查绩效考核列表中员工姓名的点击事件,确保正确跳转到员工详情页(携带 employeeId 参数)。 --- -## 二、导入错误提示不清晰 - -### 2.1 导入提示乱码,无法定位错误(反馈 #2) - -**用户反馈**:按模板填写两人导入后提示一堆乱码,实际模板可能没填错,不知道哪里有问题。 - -**现状分析**:`Settings.tsx` 导入预览已有错误展示(`result.errors` 数组),但错误信息可能显示的是后端字段名(英文),对用户来说像"乱码"。且如果用户跳过预览直接导入,错误提示可能不够醒目。 - -**代码验证**:⚠️ 部分存在。`Settings.tsx:967-968` 已有 toast 提示「有 N 条错误」,预览阶段有完整错误表格(行号/姓名/状态/错误信息),且支持「导出错误日志」Excel 下载。但错误信息内容来自后端,可能包含英文字段名导致「乱码」感。用户若跳过预览直接导入,错误提示在结果区域可能不够醒目。 - -**合理性**:✅ 合理。导入错误信息需要用户可读、可定位。 - -**优化方案**: -- 错误信息中文化:将后端返回的字段名映射为中文(如 `name` → `姓名`、`idCardNumber` → `身份证号`) -- 错误提示格式:`第 X 行,字段「姓名」:不能为空` 或 `第 X 行,身份证号格式不正确` -- 导入失败时增加醒目 toast 提示「有 N 条错误,请查看详情」 -- 增加「下载错误报告」按钮,导出错误行明细(Excel) -- 预览阶段就展示完整校验结果,不通过预览不允许导入 - -**优先级**:P0 紧急 +### 问题3:培训记录点击员工姓名跳转首页 +**模块**:花名册 → 培训记录 +**优先级**:P1 +**现象**:在培训记录列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。 +**可能原因**:与问题1相同,员工姓名点击事件未正确绑定跳转逻辑。 +**优化方案**:检查培训记录列表中员工姓名的点击事件,确保正确跳转到员工详情页。 --- -## 三、工资流水导出格式 - -### 3.1 导出工资流水是否适配银行格式(反馈 #3) - -**用户反馈**:工资归档后工资条有显示,导出工资流水处是否适合银行直接关联? - -**现状分析**:当前 `PayslipTab.tsx` 无导出功能(已在 20260804 优化清单中列为 P1 待修复项)。工资流水导出需要符合银行代发工资文件格式。 - -**代码验证**:✅ 确认存在。`PayslipTab.tsx` 无导出功能,仅有「从批次汇总生成」「税率试算」「删除」操作,无银行代发文件导出。 - -**合理性**:✅ 合理。银行代发工资通常需要特定格式(Excel/CSV,包含姓名、身份证号、银行账号、金额等字段)。 - -**优化方案**: -- 增加「导出银行代发文件」功能,支持常见银行格式(工行、建行、招行等) -- 导出字段:姓名、身份证号、银行账号、开户行、实发金额 -- 支持自定义字段映射,适配不同银行要求 -- 导出格式支持 Excel 和 CSV - -**优先级**:P1 高 +### 问题14:特殊员工无法点击详情查看 +**模块**:花名册 → 特殊员工 +**优先级**:P1 +**现象**:特殊员工列表中只有编辑和删除按钮,无法点击查看员工详情。 +**优化方案**:为特殊员工列表行添加点击查看详情功能,或增加"查看"按钮。 --- -## 四、社保基数与花名册数据不同步 +## 二、离职管理模块 -### 4.1 社保公积金办理处仍显示工资数而非社保基数(反馈 #4) - -**用户反馈**:花名册中已修改社保基数,但社保公积金办理里还是显示工资数。 - -**现状分析**:花名册 `BasicInfo.tsx` 中可编辑社保基数(`socialInsuranceBase`、`housingFundBase`),但社保模块 `SocialInsurance.tsx` 的月度办理可能直接取 `salary` 字段而非社保基数字段。 - -**代码验证**:❌ 不存在(已正确实现)。`payroll.service.ts:166-167` 明确代码:`const socialBase = employee.socialInsBase || inputs.baseSalary`,即优先使用花名册中的 `socialInsBase`,未设置时才用基本工资。`contract.service.ts:205-206` 入职时同样 `socialInsBase != null ? socialInsBase : salaryNum`。社保月度办理 `MonthlyRows.tsx` 显示的 `i.base` 来自 `EmployeeSocialInsRecord` 表,该记录在入职和基数调整时已正确写入。**若用户遇到显示工资数而非社保基数,可能是该员工 `socialInsBase` 字段为 null**,需在花名册 BasicInfo 中设置社保基数。 - -**合理性**:⚠️ 部分合理。代码逻辑正确(优先用 socialInsBase),但用户若未在花名册设置社保基数,系统会 fallback 到工资数,用户可能不理解这个优先级。建议增加 UI 提示。 - -**优化方案**: -- 社保公积金月度办理中,基数取值优先级:`socialInsuranceBase` > `salary`(如果社保基数已设置则用社保基数,否则用工资) -- 同理公积金基数取值:`housingFundBase` > `salary` -- 在社保办理界面显示数据来源标注(「来自花名册社保基数」或「来自月工资」) -- 增加「一键同步花名册社保基数」按钮 - -**优先级**:P0 紧急 +### 问题2:离职证明模板是否可自定义,支持员工端下载带印章版本 +**模块**:离职管理 +**优先级**:P1 +**现象**:当前离职证明为固定模板,无法自定义修改。用户希望: +1. 离职证明模板可自定义或修改 +2. 员工可通过小程序(员工端)下载带有企业电子印章的离职证明 +**优化方案**: +- 后端:支持离职证明模板配置(复用文本模板模块,支持变量占位符) +- 前端:离职管理中可选择/编辑离职证明模板 +- 员工端:已完成离职的员工可在线查看和下载离职证明(含电子印章) --- -## 五、商险管理添加员工入口缺失 +## 三、薪税管理模块 -### 5.1 商险方案添加员工无入口(反馈 #5) - -**用户反馈**:添加了商险方案,但不知道如何添加员工,员工办理里也没有商险选项。 - -**现状分析**:需确认商险模块是否有员工关联功能。 - -**代码验证**:✅ 确认存在。`CommercialInsuranceTab.tsx` 有方案 CRUD 和参保人员查看(`enrollments`),但**无「添加参保员工」按钮**。API 层 `commercialInsuranceApi` 仅有 `plans/enrollments/savePlan/removePlan`,缺少 `addEnrollment` 接口。员工办理 `WorkProcess.tsx` 的 13 种流程类型中也无商险参保类型。 - -**合理性**:✅ 合理。商险方案创建后应能关联员工。 - -**优化方案**: -- 商险方案详情页增加「添加参保员工」按钮,支持批量选择员工 -- 员工办理(WorkProcess)中增加商险参保/退保流程类型 -- 花名册员工详情中增加商险参保状态展示 - -**优先级**:P1 高 +### 问题4:工资填写后数据自动归零 +**模块**:薪税管理 +**优先级**:P0 +**现象**:在薪税管理中选择一个员工生成工资,进入工资填写页面后录入数据,数据自动返回到 0。 +**可能原因**:工资条编辑表单的状态管理问题,输入值未正确保存到 state,或被计算逻辑覆盖。 +**优化方案**:检查工资条编辑表单的 onChange 和 onBlur 逻辑,确保手动输入的值不被自动计算覆盖。 --- -## 六、员工福利月度汇总无数据 +## 四、社保公积金模块 -### 6.1 福利添加人员后月度汇总无数(反馈 #6) - -**用户反馈**:员工福利能添加人员,但月度汇总处没有数。 - -**现状分析**:需确认福利模块的汇总逻辑是否正确关联了已添加的福利人员数据。 - -**代码验证**:❌ 不存在(系统中无福利模块)。搜索全部前端代码无 `benefit`、`福利`、`welfare` 关键词。系统无员工福利管理模块,用户可能指的是其他模块(如社保或商险)的功能。 - -**合理性**:⚠️ 需澄清。系统当前无福利模块,需与用户确认具体指的是哪个功能。 - -**优化方案**: -- 检查月度汇总查询逻辑,确保关联了福利人员表 -- 汇总维度:按福利类型汇总人数和金额、按部门汇总 -- 添加人员后自动刷新汇总数据 -- 汇总页面增加「刷新」按钮 - -**优先级**:P1 高 +### 问题5:社保公积金模块添加员工参保信息列表 +**模块**:社保公积金 +**优先级**:P1 +**现象**:社保公积金模块缺少员工参保信息列表,无法查看各员工各险种的参保状态和参保基数。 +**优化方案**: +- 社保公积金模块新增"员工参保列表"标签页 +- 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)、缴费基数(养老/医疗/失业/工伤/生育)、公积金基数 +- 支持按参保状态、城市筛选 --- -## 七、证据链完整性验证 +## 五、商业保险模块 -### 7.1 验证全部完整性显示全部异常(反馈 #7) - -**用户反馈**:证据链条中验证全部完整性,显示全部异常,不清楚如何验证。 - -**现状分析**:`Termination.tsx` 中有仲裁证据链模块,验证逻辑可能过于严格或验证条件不明确。 - -**代码验证**:⚠️ 部分存在。`Evidence.tsx` 有「验证全部完整性」按钮,调用 `evidenceApi.verifyAll()`,返回 `verifyResult.invalid` 计数。验证逻辑是后端 Hash 校验(SHA256),若全部异常可能是:①数据库中证据链记录的 hash 与实际数据不匹配;②证据链记录为空时验证逻辑有 bug。前端只显示「N 条通过,M 条异常」,**不显示具体异常原因和修复建议**。`EvidenceChain.tsx`(员工维度)有证据列表和风险提醒,但无验证功能。 - -**合理性**:✅ 合理。用户不理解验证规则,且全部异常说明验证逻辑可能有问题。 - -**优化方案**: -- 验证结果中显示具体异常原因(如「缺少劳动合同扫描件」「考勤记录不完整」等) -- 每个验证项增加「查看要求」说明,告知用户需要什么材料 -- 验证标准可配置:区分「必须项」和「建议项」,必须项缺失才标红 -- 增加「验证说明」帮助文档 - -**优先级**:P2 中 +### 问题6:商业保险添加参保方案后无法为员工参保 +**模块**:商业保险 +**优先级**:P0 +**现象**:商业保险模块添加了保险方案后,找不到为员工添加参保信息的入口。 +**优化方案**: +- 在保险方案详情或列表中增加"为员工参保"按钮 +- 支持批量选择员工参保 +- 参保后显示参保人员列表 --- -## 八、员工办理必填项缺失 +## 六、员工福利模块 -### 8.1 只填姓名也能录入,关键信息未设必填(反馈 #8) - -**用户反馈**:员工办理中只填姓名也能录入,建议身份证号等关键信息设为必填。 - -**现状分析**:`WorkProcess.tsx` 的入职登记表单中,`employeeName` 可能是唯一必填项,`idCardNumber` 等字段未设必填。 - -**代码验证**:✅ 确认存在。`WorkProcess.tsx:231-242` 的 `handleCreate()` 仅校验 `selectedType` 非空,不校验任何表单字段。`FORM_FIELDS` 配置中无 `required` 标记,所有字段都是选填。后端 `work-process.service.ts` 也未对 formData 做必填校验。HIRE 类型有 `idCardNumber` 字段但非必填,只填姓名即可创建草稿并提交。 - -**合理性**:✅ 合理。关键信息缺失会导致后续业务(社保、合同、工资)无法正常办理。 - -**优化方案**: -- 入职登记必填项:姓名、身份证号、手机号、部门 -- 用工办理其他流程类型根据类型设置相应必填项 -- 前端表单增加必填校验提示 -- 后端 schema 同步增加必填校验 - -**优先级**:P0 紧急 +### 问题7:员工福利添加方案后不知从何处为员工添加福利 +**模块**:员工福利 +**优先级**:P0 +**现象**:员工福利模块添加福利方案后,找不到为员工添加该项福利的入口。 +**优化方案**: +- 在福利方案详情或列表中增加"为员工添加福利"按钮 +- 支持批量选择员工 +- 添加后显示享受人员列表 --- -## 九、员工删除限制 - -### 9.1 已办理完毕的员工无法删除(反馈 #9) - -**用户反馈**:员工已办理完毕,想删除无法删除,必须做离职处理。 - -**现状分析**:系统设计中员工记录不允许物理删除,只能通过离职流程标记为 `TERMINATED` 状态。这是合理的数据管理设计。 - -**代码验证**:✅ 确认存在。`Roster.tsx` 中无删除员工按钮(搜索结果中 Roster.tsx 不含 Trash/delete 相关代码)。花名册仅支持「添加员工」「离职」「重新入职」,不支持物理删除。`WorkProcess.tsx` 中仅可删除草稿记录,不可删除已完成的员工档案。 - -**合理性**:⚠️ 部分合理。从数据合规角度,员工记录不应物理删除(需保留人事档案)。但如果是录入错误的测试数据,应提供清理机制。 - -**优化方案**: -- 保持正式员工不可删除,只能离职处理(合规要求) -- 增加「作废」功能:仅限录入错误且无关联业务数据(无合同、无工资记录)的员工可作废 -- 作废后数据保留但不在花名册显示,管理员可在设置中查看作废记录 -- 增加「测试数据清理」功能:管理员可一键清除所有测试员工 - -**优先级**:P2 中 +### 问题15:员工福利新增方案后员工汇总不显示,月度合计为0 +**模块**:员工福利 +**优先级**:P1 +**现象**:新增福利方案并添加员工后,员工汇总中没有显示该福利,月度合计仍为 0。 +**可能原因**:福利汇总统计逻辑未包含新增方案的金额,或汇总查询条件未关联到新增方案。 +**优化方案**:检查福利汇总统计的查询逻辑,确保所有有效福利方案均纳入月度合计计算。 --- -## 十、离职审批状态无法更改 +## 七、规章制度管理模块 -### 10.1 待审批状态无法更改或找不到更改入口(反馈 #10) - -**用户反馈**:选择待审批后,再点进去状态无法更改,没找到更改入口。 - -**现状分析**:`Termination.tsx` 中离职流程选择「待审批」后,可能缺少审批操作入口或状态流转不完整。 - -**代码验证**:❌ 不存在(已实现审批功能)。`Termination.tsx:967-993` 在详情视图中,当 `draftDetail.status === 'PENDING_APPROVAL'` 时,显示审批意见输入框和「审批通过」「驳回」按钮,分别调用 `approveMutation` 和 `rejectMutation`。列表视图中也有快捷审批图标按钮(828-846行)。API 层 `terminationApi.approve/reject` 完整。**用户可能是没找到详情入口**——需点击列表中的审批图标或详情按钮进入详情视图才能操作。 - -**合理性**:⚠️ 部分合理。功能已存在,但入口可能不够明显,用户没找到操作位置。建议优化 UI 引导。 - -**优化方案**: -- 离职详情页增加「审批」按钮(通过/驳回/退回修改) -- 待审批状态列表增加批量审批功能 -- 审批操作记录审批人、审批时间、审批意见 -- 增加审批通知提醒 - -**优先级**:P0 紧急 +### 问题8:新建规章制度无法进行文本或文档导入 +**模块**:规章制度 +**优先级**:P1 +**现象**:新建规章制度时,只能手动输入正文内容,无法导入 Word 文档或富文本。 +**优化方案**: +- 规章制度编辑器增加"导入 Word 文档"功能(复用文本模板模块的 mammoth 导入方案) +- 支持 .docx 格式文档导入并自动转为 HTML --- -## 十一、花名册职责边界 +## 八、文本模板模块 -### 11.1 花名册应只读,修改到相应模块操作(反馈 #11) - -**用户反馈**:花名册应该只能查看,修改应到相应模块,否则太乱。 - -**现状分析**:当前花名册 `Roster.tsx` 集成了大量操作:添加员工、薪资调整、部门变更、离职、重新入职等。`EmployeeProfile` 中还能直接编辑基本信息、合同、薪酬等。 - -**代码验证**:⚠️ 部分存在。`Roster.tsx` 列表支持添加员工、批量导入、导出,点击进入 `EmployeeProfile` 后可编辑基本信息、合同、薪酬等。操作确实集中在花名册中,但各编辑区域已有模块化分区(BasicInfo/ContractInfo/SalaryInfo 等)。 - -**合理性**:⚠️ 部分合理。花名册作为统一查看入口是合理的,但编辑入口确实可以更清晰。 - -**优化方案**: -- 花名册列表保持只读查看,点击进入员工详情 -- 员工详情页保留编辑功能,但按模块分区并标注来源模块(如「基本信息 → 员工档案模块」「合同信息 → 合同管理模块」) -- 每个编辑区域增加「前往该模块」链接,方便用户到对应模块操作 -- 花名册列表的操作按钮(薪资调整、离职等)保留,但增加模块跳转提示 -- 不建议完全移除编辑功能,因为会降低操作效率 - -**优先级**:P3 规划(涉及大量 UI 重构) +### 问题9:下载的 Word 文档毫无格式 +**模块**:文本模板 +**优先级**:P1 +**现象**:文本模板下载的 Word 文档没有格式,段落、标题、间距等全部丢失,需要手动调整。 +**可能原因**:当前下载方式可能是纯文本写入 .doc 文件,未使用 Word HTML 格式或样式定义。 +**优化方案**: +- 下载的 Word 文档使用完整的 Word HTML 格式(含 style 定义) +- 保留标题、段落间距、字体大小、对齐方式等格式 +- 参考离职证明下载的 Word HTML 方案 --- -## 十二、招聘模块缺失 - -### 12.1 系统没有招聘模块(反馈 #12) - -**用户反馈**:HR 最重要的招聘环节为什么没有? - -**现状分析**:系统目前覆盖入职→在职→离职全生命周期,但缺少招聘前端的简历管理、面试安排、Offer 管理等环节。 - -**代码验证**:✅ 确认存在。搜索全部前端代码无 `recruit`、`resume`、`interview`、`招聘`、`简历`、`面试` 相关页面或路由。系统无招聘模块。 - -**合理性**:✅ 合理。招聘是 HR 核心功能之一。 - -**优化方案**: -- 新建招聘模块,包含: - - **简历管理**:简历导入/录入、简历筛选、状态流转(待筛选→初试→复试→Offer→入职/淘汰) - - **面试管理**:面试安排、面试评价、面试日历 - - **Offer 管理**:Offer 模板、Offer 发放、接受/拒绝跟踪 - - **招聘渠道**:渠道管理、来源统计 - - **人才库**:未录用候选人归档,未来岗位匹配 -- 与花名册打通:入职时自动从简历库拉取候选人信息 -- 招聘数据看板:渠道转化率、招聘周期、录用率等 - -**优先级**:P3 规划(大型新功能,建议单独规划版本) +### 问题10:无固定期限劳动合同模板下载后不含员工信息 +**模块**:文本模板 +**优先级**:P1 +**现象**:使用"无固定期限劳动合同"模板,已填写了变量占位符,但下载的 Word 文档中变量未被替换为实际员工信息。 +**可能原因**:下载时未对模板内容进行变量替换处理,直接输出了原始模板。 +**优化方案**: +- 下载 Word 文档时自动替换模板中的变量占位符(如 {{name}}、{{idCardNumber}} 等) +- 如果是从员工详情页发起下载,自动填充该员工的信息 +- 如果是预览下载,提示用户填写变量或选择关联员工 --- -## 十三、角色权限分工 +## 九、电子签署模块 -### 13.1 专员和主管的权限分工(反馈 #13) - -**用户反馈**:工作角色分类,是否考虑有专员和主管的权限分工。 - -**现状分析**:当前系统角色为 `SUPER_ADMIN`、`ADMIN`、`HR`、`VIEWER` 四级,`HR` 角色拥有全部 HR 操作权限,无专员/主管区分。 - -**代码验证**:✅ 确认存在。`authStore.ts` 中 User 角色为 `SUPER_ADMIN | ADMIN | HR | VIEWER` 四级,无专员/主管区分。所有 HR 角色拥有相同权限,无法按模块或操作类型细分。 - -**合理性**:✅ 合理。中大型企业需要更细粒度的权限控制。 - -**优化方案**: -- 角色体系扩展: - - `HR_SUPERVISOR`(HR 主管):全部查看 + 审批权限 + 配置权限 - - `HR_SPECIALIST`(HR 专员):数据录入 + 日常操作,无审批和配置权限 -- 权限粒度: - - 查看权限:全部模块可查看 - - 操作权限:专员可录入/修改,主管可审批/删除/导出 - - 配置权限:仅主管/管理员可修改系统配置(社保比例、薪资结构等) -- 审批流:离职、薪资调整等关键操作需主管审批 -- 可按模块设置权限(如考勤专员只能操作考勤模块) - -**优先级**:P2 中 +### 问题11:发起签署时选择员工没有选项 +**模块**:电子签署 +**优先级**:P0 +**现象**:电子签署中发起签署时,选择员工的下拉框为空,没有任何选项。 +**可能原因**:员工列表查询接口未调用或返回数据为空,或下拉框数据绑定逻辑有误。 +**优化方案**:检查电子签署发起页面中员工列表的数据获取逻辑,确保正确加载在职员工列表。 --- -## 优先级汇总 +## 十、绩效考核模块 -| 优先级 | 编号 | 问题 | 状态 | -|--------|------|------|------| -| 优先级 | 编号 | 问题 | 验证状态 | 修复状态 | -|--------|------|------|----------|----------| -| ~~P0~~ | 2.1 | 导入错误提示乱码 | ⚠️ 部分存在(已有错误展示和导出,但信息可能含英文字段名) | 降级 P1 | -| ~~P0~~ | 4.1 | 社保基数不同步 | ❌ 不存在(代码已正确实现优先级 socialInsBase > salary) | 降级 P2(增加 UI 提示) | -| P0 紧急 | 8.1 | 员工办理关键信息未设必填 | ✅ 确认存在 | 待修复 | -| ~~P0~~ | 10.1 | 离职审批状态无法更改 | ❌ 不存在(已实现完整审批流程) | 无需修复(优化 UI 引导) | -| P1 高 | 1.1 | 发薪数据每月需重新导入 | ✅ 确认存在 | 待优化 | -| P1 高 | 3.1 | 工资流水导出格式适配银行 | ✅ 确认存在 | 待开发 | -| P1 高 | 5.1 | 商险管理添加员工入口缺失 | ✅ 确认存在 | 待修复 | -| ~~P1~~ | 6.1 | 员工福利月度汇总无数据 | ❌ 系统无福利模块 | 需澄清需求 | -| P2 中 | 7.1 | 证据链验证不清晰 | ⚠️ 部分存在(有验证但不显示具体原因) | 待优化 | -| P2 中 | 9.1 | 员工删除限制 | ✅ 确认存在(无删除入口) | 待优化 | -| P2 中 | 13.1 | 角色权限分工 | ✅ 确认存在 | 待规划 | -| P3 规划 | 11.1 | 花名册职责边界重构 | ⚠️ 部分存在 | 待规划 | -| P3 规划 | 12.1 | 招聘模块 | ✅ 确认不存在 | 待规划 | +### 问题12:新增绩效记录没有编辑考评人的选项 +**模块**:绩效考核 +**优先级**:P2 +**现象**:绩效考核新增记录时,表单中没有考评人(考核人)的输入字段。 +**优化方案**:在绩效记录新增表单中增加"考评人"输入框(已有 reviewer 字段,需确认前端是否显示)。 --- -## 备注 +## 十一、违纪记录模块 -## 代码验证结论 +### 问题13:违纪记录缺少处罚执行细节 +**模块**:违纪记录 +**优先级**:P2 +**现象**:违纪记录只有删除功能,缺少处罚执行的具体管理。用户希望支持: +1. 处罚类型为扣款/降职时,区分一次性处罚还是持续处罚(如罚一个月后恢复) +2. 停薪留职时,可设定留职时间段或灵活安排结束时间 +**优化方案**: +- 违纪记录新增"处罚执行"字段:处罚方式(一次性/持续性)、处罚开始日期、处罚结束日期、处罚金额/降职后岗位 +- 停薪留职支持设定预计结束日期,到期后提醒确认是否恢复 +- 违纪记录列表增加处罚状态列(执行中/已结束/已恢复) -### 已确认存在的问题(需修复) -- **#8 员工办理必填项缺失**:`WorkProcess.tsx` 无任何表单必填校验,只填姓名即可提交 -- **#5 商险添加员工入口缺失**:`CommercialInsuranceTab.tsx` 无添加参保员工按钮和 API -- **#1 发薪数据重复导入**:无「复制上月批次」功能 -- **#3 工资流水无银行格式导出**:`PayslipTab.tsx` 无导出功能 -- **#9 员工无法删除**:花名册无删除入口 -- **#12 无招聘模块**:系统中完全不存在 -- **#13 角色权限无专员/主管分工**:仅四级角色 +--- -### 不存在或已实现的问题(无需修复) -- **#4 社保基数不同步**:`payroll.service.ts:166` 已正确实现 `socialInsBase || salary` 优先级,问题可能是用户未设置 `socialInsBase` -- **#10 离职审批无法更改**:`Termination.tsx:967-993` 已实现完整审批操作(通过/驳回),用户可能未找到详情入口 -- **#6 员工福利汇总无数据**:系统中无福利模块,需澄清用户具体指什么 +## 十二、合规模块 -### 部分存在的问题(可优化) -- **#2 导入错误乱码**:已有错误展示和导出功能,但错误信息可能含英文字段名 -- **#7 证据链验证**:有验证功能但不显示具体异常原因 -- **#11 花名册职责边界**:编辑功能已模块化分区但确实集中在花名册中 +### 问题16:证据链验证完整性全部失败 +**模块**:合规 → 证据链 +**优先级**:P0 +**现象**:合规模块中证据链验证完整性,所有验证项全部失败。 +**可能原因**: +1. 证据链哈希计算逻辑与存储时的哈希不一致(如排序规则、拼接顺序变化) +2. 历史数据在升级过程中哈希规则变更,导致旧数据全部校验失败 +3. orgId 或其他参与哈希计算的参数发生变化 +**优化方案**: +- 检查 verifyAllEvidence 中的哈希计算逻辑,对比存储时的哈希生成逻辑 +- 如果是哈希规则变更导致,提供"重新生成哈希"功能(仅管理员可用) +- 排查是否有数据迁移或 orgId 变更导致的不一致 -### 备注 -- #4 和 #10 经代码验证不构成 bug,降级处理 -- #6 需与用户澄清具体需求 -- #12 招聘模块为大型新功能,建议单独规划版本迭代 +--- + +## 汇总 + +| 优先级 | 数量 | 问题编号 | +|--------|------|----------| +| P0 | 5 | 4, 6, 7, 11, 16 | +| P1 | 7 | 1, 2, 3, 5, 8, 9, 10, 15 | +| P2 | 2 | 12, 13 | +| 待确认 | 1 | 14 | +| **合计** | **16** | | diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 3eab296..93f990b 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -585,6 +585,9 @@ export const socialInsuranceApi = { /** 公积金活跃申报 */ housingActiveDeclaration: (month: string) => get('/social/housing/active-declaration', { params: { month } }).then(unwrap()), + /** 员工参保信息列表 */ + employeeEnrollment: (keyword?: string) => + get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap()), } // ========== 商业保险 ========== @@ -1053,6 +1056,9 @@ export const portalApi = { /** 撤回离职申请 */ resignationWithdraw: (id: string) => portalPost(`/resignation/${id}/withdraw`).then(unwrap()), + /** 下载离职证明 */ + downloadCertificate: (id: string) => + portalGet(`/resignation/${id}/certificate`, { responseType: 'blob' }) as any, /** 我的休假申请列表 */ myLeaves: () => portalGet('/leaves').then(unwrap()), diff --git a/frontend/src/pages/ESign.tsx b/frontend/src/pages/ESign.tsx index 403fa62..e22841e 100644 --- a/frontend/src/pages/ESign.tsx +++ b/frontend/src/pages/ESign.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react' -import { esignApi, rosterApi } from '../lib/api-services' +import { esignApi, employeeApi } from '../lib/api-services' import PageGuide from '../components/ui/PageGuide' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -45,10 +45,10 @@ export default function ESign() { }, }) - const { data: rosterData } = useQuery({ - queryKey: ['roster-for-esign'], + const { data: rosterData = [] } = useQuery({ + queryKey: ['employees-for-esign'], queryFn: async () => { - return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any + return await employeeApi.allLite({ status: 'ACTIVE' }) }, enabled: showCreate, }) @@ -224,7 +224,7 @@ export default function ESign() { onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })} > - {rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => ( + {rosterData.map((emp: any) => ( ))} diff --git a/frontend/src/pages/EmployeeBenefits.tsx b/frontend/src/pages/EmployeeBenefits.tsx index 19db575..b33c733 100644 --- a/frontend/src/pages/EmployeeBenefits.tsx +++ b/frontend/src/pages/EmployeeBenefits.tsx @@ -230,7 +230,7 @@ export default function EmployeeBenefits() {

参保人员({enrollments.length}人)

{enrollLoading ? ( @@ -393,7 +393,7 @@ export default function EmployeeBenefits() { {/* 批量参保 Modal */} {showEnrollModal && ( - setShowEnrollModal(false)} title="批量参保" size="lg"> + setShowEnrollModal(false)} title="为员工添加福利" size="lg">
选择需要参保的员工,设置生效月份后点击「确认参保」。 diff --git a/frontend/src/pages/Evidence.tsx b/frontend/src/pages/Evidence.tsx index 9122d88..0e44000 100644 --- a/frontend/src/pages/Evidence.tsx +++ b/frontend/src/pages/Evidence.tsx @@ -67,7 +67,7 @@ export default function Evidence() { )} {verifyResult?.invalid === 0 - ? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改` + ? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改${verifyResult?.repaired ? `(自动修复 ${verifyResult.repaired} 条历史数据)` : ''}` : `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx index 282c26a..bd55592 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { usePageSize } from '../hooks/usePageSize' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react' +import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react' +import mammoth from 'mammoth' import { policiesApi } from '../lib/api-services' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -201,6 +202,7 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc const [title, setTitle] = useState('') const [content, setContent] = useState('') const [type, setType] = useState('RULES') + const fileInputRef = useRef(null) const createMutation = useMutation({ mutationFn: () => policiesApi.create({ title, content, type }), @@ -230,7 +232,32 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
-