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
+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