fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
This commit is contained in:
@@ -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<string, any> = {}
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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 += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
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}`)
|
||||
|
||||
@@ -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 || '导入失败'}`)
|
||||
|
||||
@@ -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 || '重算失败'}`)
|
||||
|
||||
@@ -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<string, string> = {
|
||||
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
|
||||
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
|
||||
}
|
||||
const reason = reasonLabel[record.reason] || record.reason || ''
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
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 = `<h1>解除/终止劳动合同证明书</h1>
|
||||
<p>兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||||
<p>特此证明。</p>
|
||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||||
}
|
||||
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>离职证明</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${content}</body></html>`
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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 += '</table>'; inTable = false }
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
// 标题检测:以"第X条"开头的行作为小标题
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
if (inTable) html += '</table>'
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
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}`)
|
||||
|
||||
Reference in New Issue
Block a user