Files
TurboHR/backend/src/routes/export.routes.ts
T
selfrelease e1b5ae9aab feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
P0紧急修复(6项):
- 草稿保存完整恢复所有字段(含socialAvgWage)
- 补偿金批次从compensationBreakdown读取
- 违法解除风险确认UI
- 合同结束日期前后校验(前后端双保险)

P1高优先级(14项):
- 离职日期联动社保/公积金截止月(15号规则)
- 合规检查+工作交接改为软阻断(生成待办)
- 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义)
- 解聘并入花名册操作栏(类型选择跳转向导)
- 合同续签开始日期自动推导(原合同结束日+1天)
- 年龄合规筛查(童工阻断/未成年工/退休警告)
- 编辑入职日期后状态联动(待入职↔在职)
- 转正移植到花名册操作栏+薪资回写
- 男职工无法选择三期

P2体验优化(9项):
- "劳动合同"调整为"用工关系"
- 费用结算新增剩余年假折算(300%日工资)
- 身份证号全域改为"证件号码"(前后端18个文件)
- 手机号查重
- 开具证明+合同续签移植到花名册操作栏
- 批量转正+批量开具证明
- 去掉用工办理模块

P3规划(2项):
- 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance)
- 客服工作台(Ticket/ChatSession+SUPPORT角色)

新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage
新增字段: Employee.departmentId/supervisorId
新增角色: SUPPORT

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 12:37:27 +08:00

532 lines
22 KiB
TypeScript

import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import ExcelJS from 'exceljs'
import { createGzip } from 'zlib'
import { Writable } from 'stream'
const router = Router()
// RFC 5987 编码中文文件名,兼容所有浏览器
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
// 敏感字段脱敏
function maskIdCard(idCard: string | null): string | null {
if (!idCard) return null
if (idCard.length >= 11) return idCard.slice(0, 3) + '*'.repeat(idCard.length - 7) + idCard.slice(-4)
return idCard
}
function maskBankAccount(account: string | null): string | null {
if (!account) return null
if (account.length > 4) return '*'.repeat(account.length - 4) + account.slice(-4)
return account
}
// 导出全部数据(支持模块选择、格式选择、脱敏)
router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const format = (req.query.format as string) || 'json'
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
const exportBatchSize = 500
const fetchMap: Record<string, () => Promise<any[]>> = {
employees: () => prisma.employee.findMany({ where: { orgId }, take: exportBatchSize }),
contracts: () => prisma.laborContract.findMany({ where: { orgId }, take: exportBatchSize }),
terminations: () => prisma.terminationRecord.findMany({ where: { orgId }, take: exportBatchSize }),
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId }, take: exportBatchSize }),
payslips: () => prisma.payslip.findMany({ where: { orgId }, take: exportBatchSize }),
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId }, take: exportBatchSize }),
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId }, take: exportBatchSize }),
riskItems: () => prisma.riskItem.findMany({ where: { orgId }, take: exportBatchSize }),
}
const useGzip = req.query.gzip !== 'false'
const batchSize = 500
if (format === 'excel') {
const data: any = { exportedAt: new Date().toISOString(), orgId }
if (modules.includes('employees')) {
const employees = await fetchMap.employees()
data.employees = employees.map((e: any) => {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (mask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
return { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
})
}
for (const mod of modules) {
if (mod === 'employees') continue
if (fetchMap[mod]) {
data[mod] = await fetchMap[mod]()
}
}
const workbook = new ExcelJS.Workbook()
for (const mod of modules) {
if (!data[mod] || !data[mod].length) continue
const ws = workbook.addWorksheet(mod.slice(0, 31))
const rows = data[mod]
const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object')
ws.columns = keys.map(k => ({ header: k, key: k, width: 18 }))
ws.getRow(1).font = { bold: true }
for (const row of rows) {
const flat: any = {}
for (const k of keys) flat[k] = typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k]
ws.addRow(flat)
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} else {
// JSON 流式导出 + gzip 压缩
if (useGzip) {
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json.gz`))
} else {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json`))
}
const gzip = useGzip ? createGzip() : null
const output: Writable = gzip || res
if (gzip) { gzip.pipe(res) }
const write = (chunk: string) => {
output.write(Buffer.from(chunk))
}
write('{"exportedAt":"' + new Date().toISOString() + '","orgId":"' + orgId + '"')
for (const mod of modules) {
write(',"' + mod + '":[')
if (mod === 'employees') {
// 员工数据分批查询,避免内存溢出
let skip = 0
let first = true
while (true) {
const batch = await prisma.employee.findMany({ where: { orgId }, skip, take: batchSize })
if (batch.length === 0) break
for (const e of batch) {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (mask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
const row = { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
write((first ? '' : ',') + JSON.stringify(row))
first = false
}
skip += batchSize
if (batch.length < batchSize) break
}
} else if (fetchMap[mod]) {
const rows = await fetchMap[mod]()
for (let i = 0; i < rows.length; i++) {
write((i === 0 ? '' : ',') + JSON.stringify(rows[i]))
}
}
write(']')
}
write('}')
if (gzip) gzip.end()
else res.end()
}
} catch (err) {
next(err)
}
})
// 导出本月薪税汇总 Excel
router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const entries = await prisma.batchEntry.findMany({
where: { orgId, batch: { month, status: 'ARCHIVED' } },
include: { employee: true, batch: true },
orderBy: { employee: { name: 'asc' } },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('薪税汇总')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '基本工资', key: 'baseSalary', width: 12 },
{ header: '加班费', key: 'overtimePay', width: 12 },
{ header: '津贴补贴', key: 'allowance', width: 12 },
{ header: '奖金', key: 'bonus', width: 12 },
{ header: '扣款', key: 'deduction', width: 12 },
{ header: '应发合计', key: 'totalPay', width: 12 },
{ header: '个人社保', key: 'socialEmp', width: 12 },
{ header: '个人公积金', key: 'housingEmp', width: 12 },
{ header: '个人所得税', key: 'tax', width: 12 },
{ header: '实发工资', key: 'netPay', width: 12 },
{ header: '企业社保', key: 'socialOrg', width: 12 },
{ header: '企业公积金', key: 'housingOrg', width: 12 },
{ header: '企业总成本', key: 'orgCost', width: 12 },
]
ws.getRow(1).font = { bold: true }
for (const e of entries) {
ws.addRow({
name: e.employee.name,
department: e.employee.department,
baseSalary: e.baseSalary,
overtimePay: e.overtimePay,
allowance: e.allowance,
bonus: e.bonus,
deduction: e.deduction,
totalPay: e.totalPay,
socialEmp: e.socialEmp,
housingEmp: e.housingEmp,
tax: e.tax,
netPay: e.netPay,
socialOrg: e.socialOrg,
housingOrg: e.housingOrg,
orgCost: e.totalPay + e.socialOrg + e.housingOrg,
})
}
// 汇总行
const totalRow = ws.addRow({
name: '合计',
baseSalary: { formula: `SUM(C2:C${entries.length + 1})` },
overtimePay: { formula: `SUM(D2:D${entries.length + 1})` },
allowance: { formula: `SUM(E2:E${entries.length + 1})` },
bonus: { formula: `SUM(F2:F${entries.length + 1})` },
deduction: { formula: `SUM(G2:G${entries.length + 1})` },
totalPay: { formula: `SUM(H2:H${entries.length + 1})` },
socialEmp: { formula: `SUM(I2:I${entries.length + 1})` },
housingEmp: { formula: `SUM(J2:J${entries.length + 1})` },
tax: { formula: `SUM(K2:K${entries.length + 1})` },
netPay: { formula: `SUM(L2:L${entries.length + 1})` },
socialOrg: { formula: `SUM(M2:M${entries.length + 1})` },
housingOrg: { formula: `SUM(N2:N${entries.length + 1})` },
orgCost: { formula: `SUM(O2:O${entries.length + 1})` },
})
totalRow.font = { bold: true }
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`薪税汇总-${month}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出花名册 Excel(支持筛选)
router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const search = req.query.search as string | undefined
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const where: any = { orgId }
if (department) where.department = department
if (status === 'RESIGNED') {
where.status = 'RESIGNED'
} else if (status === 'ACTIVE') {
where.status = 'ACTIVE'
}
if (search) {
where.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const employees = await prisma.employee.findMany({
where,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('花名册')
ws.columns = [
{ header: '姓名', key: 'name', width: 10 },
{ header: '部门', key: 'department', width: 12 },
{ header: '性别', key: 'gender', width: 6 },
{ header: '状态', key: 'status', width: 8 },
{ header: '入职日期', key: 'hireDate', width: 12 },
{ header: '手机号', key: 'phone', width: 13 },
{ header: '证件号码', key: 'idCardNumber', width: 20 },
{ header: '月工资', key: 'monthlySalary', width: 10 },
{ header: '社保基数', key: 'socialInsBase', width: 10 },
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
{ header: '专项附加扣除', key: 'specialDeduction', width: 12 },
{ header: '参保城市', key: 'city', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 10 },
{ header: '紧急联系电话', key: 'emergencyPhone', width: 13 },
{ header: '住址', key: 'address', width: 18 },
{ header: '开户行', key: 'bankName', width: 10 },
{ header: '银行账号', key: 'bankAccount', width: 18 },
{ header: '合同起始', key: 'contractStart', width: 12 },
{ header: '合同结束', key: 'contractEnd', width: 12 },
]
ws.getRow(1).font = { bold: true }
// 是否脱敏(非 ADMIN 用户强制脱敏)
const shouldMask = req.user!.role !== 'ADMIN'
for (const e of employees) {
const contract = e.contracts[0]
// 解密敏感字段
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (shouldMask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
ws.addRow({
name: e.name,
department: e.department,
gender: e.gender || '',
status: e.status === 'ACTIVE' ? '在职' : e.status === 'RESIGNED' ? '离职' : '预入职',
hireDate: e.hireDate?.toISOString().slice(0, 10) || '',
phone: e.phone || '',
idCardNumber: idCard || '',
monthlySalary: salary,
socialInsBase: e.socialInsBase || 0,
housingFundBase: e.housingFundBase || 0,
specialDeduction: e.specialDeduction || 0,
city: e.city || '',
emergencyContact: e.emergencyContact || '',
emergencyPhone: e.emergencyPhone || '',
address: e.address || '',
bankName: e.bankName || '',
bankAccount: bankAccount || '',
contractStart: contract?.startDate?.toISOString().slice(0, 10) || '',
contractEnd: contract?.endDate?.toISOString().slice(0, 10) || '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`花名册-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出解聘记录 Excel(支持筛选)
router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const search = req.query.search as string | undefined
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId }
if (status) where.status = status
if (dateFrom || dateTo) {
where.terminationDate = {}
if (dateFrom) where.terminationDate.gte = new Date(dateFrom)
if (dateTo) where.terminationDate.lte = new Date(dateTo + 'T23:59:59')
}
if (department || search) {
where.employee = {}
if (department) where.employee.department = department
if (search) {
where.employee.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
}
const records = await prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('解聘记录')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '解聘类型', key: 'type', width: 12 },
{ header: '解聘原因', key: 'reason', width: 20 },
{ header: '解聘日期', key: 'terminationDate', width: 12 },
{ header: '补偿金', key: 'compensation', width: 12 },
{ header: '状态', key: 'status', width: 10 },
{ header: '创建日期', key: 'createdAt', width: 12 },
]
ws.getRow(1).font = { bold: true }
const reasonLabels: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '过错解除', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', RESIGNATION: '员工离职',
}
const statusLabels: Record<string, string> = {
DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批',
REJECTED: '已驳回', EXECUTING: '执行中', COMPLETED: '已完成', CANCELLED: '已撤销',
}
for (const r of records) {
ws.addRow({
name: r.employee.name,
department: r.employee.department,
type: r.type === 'TERMINATION' ? '解聘' : '离职',
reason: reasonLabels[r.reason] || r.reason,
terminationDate: r.terminationDate?.toISOString().slice(0, 10) || '',
compensation: r.compensation || 0,
status: statusLabels[r.status] || r.status,
createdAt: r.createdAt?.toISOString().slice(0, 10) || '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`解聘记录-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出个税申报表 Excel(对齐自然人电子税务局格式)
router.get('/tax-declaration', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const entries = await prisma.batchEntry.findMany({
where: { orgId, batch: { month, status: 'ARCHIVED' } },
include: { employee: true, batch: true },
orderBy: { employee: { name: 'asc' } },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('个税申报表')
// 个税申报表列定义(对齐自然人电子税务局模板)
ws.columns = [
{ header: '序号', key: 'seq', width: 6 },
{ header: '姓名', key: 'name', width: 10 },
{ header: '身份证件号码', key: 'idCardNumber', width: 22 },
{ header: '所得项目', key: 'incomeType', width: 16 },
{ header: '本期收入', key: 'totalPay', width: 12 },
{ header: '本期免税收入', key: 'taxFreeIncome', width: 14 },
{ header: '基本减除费用', key: 'basicDeduction', width: 14 },
{ header: '专项扣除合计', key: 'specialDeductionTotal', width: 14 },
{ header: '养老保险', key: 'pensionEmp', width: 10 },
{ header: '医疗保险', key: 'medicalEmp', width: 10 },
{ header: '失业保险', key: 'unemploymentEmp', width: 10 },
{ header: '住房公积金', key: 'housingEmp', width: 12 },
{ header: '专项附加扣除', key: 'specialAdditionalDeduction', width: 14 },
{ header: '其他扣除', key: 'otherDeduction', width: 10 },
{ header: '累计收入额', key: 'ytdIncome', width: 12 },
{ header: '累计减除费用', key: 'ytdBasicDeduction', width: 14 },
{ header: '累计专项扣除', key: 'ytdSpecialDeduction', width: 14 },
{ header: '累计专项附加扣除', key: 'ytdSpecialAdditional', width: 16 },
{ header: '累计应纳税所得额', key: 'ytdTaxableIncome', width: 16 },
{ header: '税率', key: 'taxRate', width: 8 },
{ header: '速算扣除数', key: 'quickDeduction', width: 12 },
{ header: '累计已预扣税额', key: 'ytdTaxDeducted', width: 14 },
{ header: '本期应预扣税额', key: 'tax', width: 14 },
{ header: '备注', key: 'remark', width: 20 },
]
ws.getRow(1).font = { bold: true }
let seq = 0
for (const e of entries) {
seq++
// 解密证件号码
let idCard: string = ''
try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' }
// 从社保中拆分个人部分(简化:使用 socialEmp 总额按比例拆分)
const socialEmp = e.socialEmp || 0
const pensionEmp = Math.round(socialEmp * 0.56) // 养老约 56%
const medicalEmp = Math.round(socialEmp * 0.36) // 医疗约 36%
const unemploymentEmp = socialEmp - pensionEmp - medicalEmp // 剩余为失业
// 累计数据
const ytdTaxDeducted = (e as any).ytdTaxDeducted || e.tax || 0
ws.addRow({
seq,
name: e.employee.name,
idCardNumber: idCard,
incomeType: '工资薪金所得',
totalPay: e.totalPay || 0,
taxFreeIncome: 0,
basicDeduction: 5000, // 基本减除费用 5000/月
specialDeductionTotal: socialEmp + (e.housingEmp || 0),
pensionEmp,
medicalEmp,
unemploymentEmp,
housingEmp: e.housingEmp || 0,
specialAdditionalDeduction: e.employee.specialDeduction || 0,
otherDeduction: 0,
ytdIncome: e.totalPay || 0, // 简化:单月累计=本月
ytdBasicDeduction: 5000,
ytdSpecialDeduction: socialEmp + (e.housingEmp || 0),
ytdSpecialAdditional: e.employee.specialDeduction || 0,
ytdTaxableIncome: Math.max(0, (e.totalPay || 0) - 5000 - socialEmp - (e.housingEmp || 0) - (e.employee.specialDeduction || 0)),
taxRate: '',
quickDeduction: 0,
ytdTaxDeducted,
tax: e.tax || 0,
remark: '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`个税申报表-${month}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
export default router