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 Promise> = { 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 = { NEGOTIATED: '协商解除', FAULT: '过错解除', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', RESIGNATION: '员工离职', } const statusLabels: Record = { 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