feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user