feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化

- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页
- Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度)
- 待办分为「风险提醒」「月度任务」两个顶层 tab
- 归档与工资条生成解耦:归档只锁定批次,工资条单独生成
- 工资条管理新增「从批次汇总生成」按钮
- Dashboard 总览优先从已归档批次 BatchEntry 汇总数据
- 新增工资条生成待办提醒,生成后自动标记完成
- 修复高风险统计只含 CONTRACT/TERMINATION 类型
- 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
freedakgmail
2026-07-23 13:42:28 +08:00
parent 820579e98d
commit 2a09d31ccc
20 changed files with 2300 additions and 235 deletions
+555
View File
@@ -0,0 +1,555 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import { decrypt } from '../lib/crypto'
import {
ensureDefaultTemplate,
getTemplate,
calcBatchEntry,
getPayrollRiskWarnings,
generatePayslipFromBatches,
} from '../services/payroll.service'
const router = Router()
router.use(authMiddleware)
// ========== 薪酬模版 ==========
// 获取薪酬模版
router.get('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const items = await getTemplate(req.user!.orgId)
res.json({ success: true, data: items })
} catch (err) {
next(err)
}
})
// 更新薪酬模版项
const updateTemplateItemSchema = z.object({
name: z.string().min(1).optional(),
formula: z.string().nullable().optional(),
order: z.number().int().optional(),
isEditable: z.boolean().optional(),
})
router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = updateTemplateItemSchema.parse(req.body)
const item = await prisma.payslipItem.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
const updateData: any = {}
if (data.name !== undefined && !item.isDefault) updateData.name = data.name
if (data.formula !== undefined) updateData.formula = data.formula
if (data.order !== undefined) updateData.order = data.order
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 新增薪酬模版项
const createTemplateItemSchema = z.object({
name: z.string().min(1),
code: z.string().min(1),
type: z.enum(['INPUT', 'CALCULATED']),
formula: z.string().nullable().optional(),
order: z.number().int().default(99),
isEditable: z.boolean().default(true),
})
router.post('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createTemplateItemSchema.parse(req.body)
const item = await prisma.payslipItem.create({
data: { ...data, orgId: req.user!.orgId, isDefault: false },
})
res.json({ success: true, data: item })
} catch (err) {
next(err)
}
})
// 删除薪酬模版项(仅非预置项)
router.delete('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.payslipItem.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
if (item.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '预置项不可删除' } })
await prisma.payslipItem.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== 发薪批次 ==========
// 检查本月是否已发薪
router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'ARCHIVED' },
})
const draftBatches = await prisma.payrollBatch.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'DRAFT' },
})
const publishedPayslips = await prisma.payslip.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'PUBLISHED' },
})
res.json({
success: true,
data: {
hasArchivedBatch: archivedBatches > 0,
archivedCount: archivedBatches,
draftCount: draftBatches,
payslipsPublished: publishedPayslips > 0,
},
})
} catch (err) {
next(err)
}
})
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
})
res.json({ success: true, data: batches })
} catch (err) {
next(err)
}
})
// 获取批次详情
router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: {
entries: {
include: {
employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true } },
},
orderBy: { employee: { name: 'asc' } },
},
},
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
res.json({ success: true, data: batch })
} catch (err) {
next(err)
}
})
// 创建批次
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'),
name: z.string().optional(),
remark: z.string().optional(),
})
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, type, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId
// 查询当月已有批次数
const existingBatches = await prisma.payrollBatch.count({
where: { orgId, month },
})
const batchNo = existingBatches + 1
// 获取组织发薪频率
const org = await prisma.organization.findUnique({ where: { id: orgId } })
// 获取在职员工 + 本月离职员工
const monthStart = new Date(`${month}-01`)
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
let employees: any[]
if (type === 'TERMINATION') {
// 离职结算批次:本月离职员工
const terminations = await prisma.terminationRecord.findMany({
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
})
employees = terminations.map(t => t.employee)
} else {
// 常规/奖金批次:在职 + 本月离职
employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ status: 'ACTIVE' },
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
],
},
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
}
// 获取上月发薪数据作为默认值
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
const batchName = name || `${month}${batchNo}${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}`
// 创建批次
const batch = await prisma.payrollBatch.create({
data: {
orgId,
month,
batchNo,
name: batchName,
type,
remark,
createdBy: req.user!.id,
employeeCount: employees.length,
},
})
// 创建批次条目
const entries: any[] = []
for (const emp of employees) {
// 获取上次发薪数据
const prevPayslip = await prisma.payslip.findUnique({
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
})
// 获取加班费
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId: emp.id, month } },
})
// 基本工资
let baseSalary = 0
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary
} else if (emp.monthlySalary) {
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
// 如果有上次发薪数据,带入
if (prevPayslip) {
baseSalary = prevPayslip.baseSalary
}
const overtimePay = overtime?.totalPay || 0
const allowance = prevPayslip?.allowance || 0
const deduction = prevPayslip?.deduction || 0
const bonus = type === 'BONUS' ? 0 : 0 // 奖金批次默认0,手动填写
// 计算社保、个税等
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
// 风险提示
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
const entry = await prisma.batchEntry.create({
data: {
batchId: batch.id,
orgId,
employeeId: emp.id,
baseSalary,
overtimePay,
allowance,
deduction,
bonus,
socialEmp: calcResult.socialEmp,
socialOrg: calcResult.socialOrg,
housingEmp: calcResult.housingEmp,
housingOrg: calcResult.housingOrg,
tax: calcResult.tax,
totalPay: calcResult.totalPay,
netPay: calcResult.netPay,
riskWarnings,
},
})
entries.push(entry)
}
// 更新批次汇总
const totals = entries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.totalPay,
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
const updatedBatch = await prisma.payrollBatch.update({
where: { id: batch.id },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } },
})
res.json({ success: true, data: updatedBatch })
} catch (err) {
next(err)
}
})
// 编辑批次条目(计算依据项)
const updateEntrySchema = z.object({
baseSalary: z.number().min(0).optional(),
overtimePay: z.number().min(0).optional(),
allowance: z.number().min(0).optional(),
deduction: z.number().min(0).optional(),
bonus: z.number().min(0).optional(),
})
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const data = updateEntrySchema.parse(req.body)
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
// 合并输入项
const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary,
overtimePay: data.overtimePay ?? entry.overtimePay,
allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus,
}
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...calcResult },
})
// 更新批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay),
totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay),
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
}), { totalPay: 0, totalNetPay: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 批次增加人员
router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const { employeeIds } = req.body as { employeeIds: string[] }
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
const results: any[] = []
for (const employeeId of employeeIds) {
// 检查是否已在批次中
const existing = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (existing) continue
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!emp) continue
let baseSalary = 0
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary
} else if (emp.monthlySalary) {
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId, month: batch.month } },
})
const overtimePay = overtime?.totalPay || 0
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...calcResult, riskWarnings,
},
})
results.push(entry)
}
// 更新批次人数
const count = await prisma.batchEntry.count({ where: { batchId } })
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
res.json({ success: true, data: { added: results.length } })
} catch (err) {
next(err)
}
})
// 批次移除人员
router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } })
const count = await prisma.batchEntry.count({ where: { batchId } })
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 归档批次
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次已归档' } })
await prisma.payrollBatch.update({
where: { id: batchId },
data: { status: 'ARCHIVED', archivedAt: new Date() },
})
res.json({ success: true, data: { archived: true } })
} catch (err) {
next(err)
}
})
// 从已归档批次汇总生成工资条
router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.body
const orgId = req.user!.orgId
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请提供有效的月份(YYYY-MM' } })
}
// 检查是否有已归档批次
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId, month, status: 'ARCHIVED' },
})
if (archivedBatches === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月无已归档批次,无法生成工资条' } })
}
const result = await generatePayslipFromBatches(orgId, month)
// 自动标记"生成工资条"待办为已完成
await prisma.riskItem.updateMany({
where: { orgId, status: 'PENDING', type: 'SALARY', title: { startsWith: `${month}月 生成工资条` } },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { generated: result.generated } })
} catch (err) {
next(err)
}
})
// 银行代发文件导出(接口预留)
router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const { format = 'csv' } = req.query
const batch = await prisma.payrollBatch.findFirst({
where: { id: batchId, orgId },
include: {
entries: {
include: { employee: { select: { name: true, bankAccount: true, bankName: true } } },
},
},
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status !== 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅归档批次可导出' } })
if (format === 'csv') {
const header = '姓名,银行账号,开户行,实发金额\n'
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
return res.send('\ufeff' + header + rows)
}
res.json({ success: true, data: batch })
} catch (err) {
next(err)
}
})
export default router
+6 -3
View File
@@ -36,11 +36,14 @@ router.get('/org', async (req: AuthRequest, res, next) => {
// 更新企业信息
router.put('/org', async (req: AuthRequest, res, next) => {
try {
const { name } = req.body as { name?: string }
const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number }
const updateData: any = {}
if (name) updateData.name = name
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: name ? { name } : {},
select: { id: true, name: true, plan: true, maxEmployees: true },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, payrollFrequency: true },
})
res.json({ success: true, data: org })
} catch (err) {