feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
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()
// 敏感字段脱敏
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, 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 fetchMap: Record<string, () => Promise<any>> = {
employees: () => prisma.employee.findMany({ where: { orgId } }),
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
}
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', `attachment; filename="export-${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', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
} else {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${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', `attachment; filename="payroll-${month}.xlsx"`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
export default router