feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板

This commit is contained in:
selfrelease
2026-07-31 17:40:49 +08:00
parent c15e11ec22
commit 821a62e3f8
16 changed files with 3177 additions and 55 deletions
+138
View File
@@ -239,4 +239,142 @@ router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Res
}
})
// 入离职统计看板 — 按月聚合入职和离职人数
router.get('/turnover-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const months = parseInt(req.query.months as string) || 12
// 计算最近 N 个月的月份列表
const now = new Date()
const monthList: string[] = []
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
monthList.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
// 查询入职数据(按 hireDate 月份分组)
const startDate = new Date(monthList[0] + '-01')
const employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ hireDate: { gte: startDate } },
{ status: 'RESIGNED' },
],
},
select: { id: true, name: true, hireDate: true, status: true, department: true },
})
// 查询离职记录
const terminations = await prisma.terminationRecord.findMany({
where: {
orgId,
terminationDate: { gte: startDate },
},
select: { employeeId: true, terminationDate: true, type: true, reason: true },
})
// 按月聚合
const monthlyData = monthList.map(month => {
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
const hired = employees.filter(e => e.hireDate >= monthStart && e.hireDate < monthEnd).length
const left = terminations.filter(t => {
const td = t.terminationDate
return td && td >= monthStart && td < monthEnd
}).length
return { month, hired, left, net: hired - left }
})
// 汇总
const totalHired = monthlyData.reduce((s, m) => s + m.hired, 0)
const totalLeft = monthlyData.reduce((s, m) => s + m.left, 0)
const currentHeadcount = employees.filter(e => e.status !== 'RESIGNED').length
const avgHeadcount = currentHeadcount // 简化:使用当前人数
const turnoverRate = avgHeadcount > 0 ? (totalLeft / avgHeadcount * 100).toFixed(1) : '0'
res.json({
success: true,
data: {
monthly: monthlyData,
summary: {
totalHired,
totalLeft,
currentHeadcount,
turnoverRate: parseFloat(turnoverRate),
},
},
})
} catch (err) {
next(err)
}
})
// 绩效统计看板 — 按周期聚合绩效分布
router.get('/performance-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const period = (req.query.period as string) || new Date().toISOString().slice(0, 4) // 默认当前年
const records = await prisma.performanceRecord.findMany({
where: {
orgId,
period: { startsWith: period },
},
include: { employee: { select: { name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
// 按等级分布
const gradeDist: Record<string, number> = {}
// 按部门分布
const deptDist: Record<string, { count: number; avgScore: number; scores: number[] }> = {}
// 按周期分布
const periodDist: Record<string, { count: number; avgScore: number; scores: number[] }> = {}
for (const r of records) {
// 等级分布
const grade = r.grade || '未评级'
gradeDist[grade] = (gradeDist[grade] || 0) + 1
// 部门分布
const dept = r.employee?.department || '未分配'
if (!deptDist[dept]) deptDist[dept] = { count: 0, avgScore: 0, scores: [] }
deptDist[dept].count++
if (r.score) deptDist[dept].scores.push(r.score)
// 周期分布
if (!periodDist[r.period]) periodDist[r.period] = { count: 0, avgScore: 0, scores: [] }
periodDist[r.period].count++
if (r.score) periodDist[r.period].scores.push(r.score)
}
// 计算平均分
const calcAvg = (d: typeof deptDist) => Object.entries(d).map(([name, v]) => ({
name,
count: v.count,
avgScore: v.scores.length > 0 ? Math.round(v.scores.reduce((s, x) => s + x, 0) / v.scores.length * 10) / 10 : 0,
}))
const allScores = records.map(r => r.score).filter(Boolean) as number[]
const overallAvg = allScores.length > 0 ? Math.round(allScores.reduce((s, x) => s + x, 0) / allScores.length * 10) / 10 : 0
res.json({
success: true,
data: {
total: records.length,
overallAvgScore: overallAvg,
gradeDistribution: Object.entries(gradeDist).map(([name, value]) => ({ name, value })),
departmentDistribution: calcAvg(deptDist),
periodDistribution: calcAvg(periodDist),
},
})
} catch (err) {
next(err)
}
})
export default router
+141 -5
View File
@@ -282,26 +282,65 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('花名册')
ws.columns = [
{ header: '姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '状态', key: 'status', width: 10 },
{ 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 },
{ header: '联系方式', key: 'phone', width: 15 },
]
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) || '',
phone: e.phone || '',
})
}
@@ -386,4 +425,101 @@ router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Respon
}
})
// 导出个税申报表 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
+22 -6
View File
@@ -267,6 +267,16 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
if (idCheck.upgraded) idCard = idCheck.upgraded
}
// 社保基数:值为 0 或「无」表示不参保
const socialInsBaseVal = num(getField(r, '社保基数'))
const socialInsOptOut = val(getField(r, '社保基数')) === '无' || val(getField(r, '社保基数')) === '不缴'
const socialInsBase = socialInsOptOut ? 0 : (socialInsBaseVal || num(salary))
// 公积金基数:值为 0 或「无」表示不缴纳
const housingFundBaseVal = num(getField(r, '公积金基数'))
const housingFundOptOut = val(getField(r, '公积金基数')) === '无' || val(getField(r, '公积金基数')) === '不缴'
const housingFundBase = housingFundOptOut ? 0 : (housingFundBaseVal || num(salary))
const emp = await prisma.employee.create({
data: {
orgId, name, department: dept, hireDate,
@@ -283,21 +293,27 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
address: val(getField(r, '住址')) || null,
bankName: val(getField(r, '开户行')) || null,
bankAccount: val(getField(r, '银行账号')) ? encrypt(val(getField(r, '银行账号'))) : null,
socialInsBase: num(getField(r, '社保基数')) || num(salary),
housingFundBase: num(getField(r, '公积金基数')) || num(salary),
socialInsBase,
housingFundBase,
specialDeduction: num(getField(r, '专项附加扣除')) || 0,
city: val(getField(r, '参保城市')) || null,
isPregnant: val(getField(r, '孕期')) === '是',
isInMedicalPeriod: val(getField(r, '医疗期')) === '是',
isWorkInjured: val(getField(r, '工伤')) === '是',
socialInsStartMonth: dateToMonth(hireDate),
housingFundStartMonth: dateToMonth(hireDate),
socialInsStartMonth: socialInsOptOut ? null : dateToMonth(hireDate),
housingFundStartMonth: housingFundOptOut ? null : dateToMonth(hireDate),
createdBy: userId,
},
})
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(getField(r, '社保基数')) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(getField(r, '公积金基数')) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
// 仅在未 opt-out 时创建社保记录
if (!socialInsOptOut) {
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: socialInsBase, changeType: 'ONBOARDING', createdBy: userId } })
}
// 仅在未 opt-out 时创建公积金记录
if (!housingFundOptOut) {
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: housingFundBase, changeType: 'ONBOARDING', createdBy: userId } })
}
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })