feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化
- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页 - Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度) - 待办分为「风险提醒」「月度任务」两个顶层 tab - 归档与工资条生成解耦:归档只锁定批次,工资条单独生成 - 工资条管理新增「从批次汇总生成」按钮 - Dashboard 总览优先从已归档批次 BatchEntry 汇总数据 - 新增工资条生成待办提醒,生成后自动标记完成 - 修复高风险统计只含 CONTRACT/TERMINATION 类型 - 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
@@ -34,6 +34,7 @@ import aiRoutes from './routes/ai.routes'
|
||||
import portalRoutes from './routes/portal.routes'
|
||||
import settingsRoutes from './routes/settings.routes'
|
||||
import payrollRoutes from './routes/payroll.routes'
|
||||
import payroll2Routes from './routes/payroll2.routes'
|
||||
import socialRoutes from './routes/social.routes'
|
||||
import notificationRoutes from './routes/notification.routes'
|
||||
import attachmentRoutes from './routes/attachment.routes'
|
||||
@@ -46,6 +47,7 @@ app.use('/api/v1/ai', aiRoutes)
|
||||
app.use('/api/v1/portal', portalRoutes)
|
||||
app.use('/api/v1/settings', settingsRoutes)
|
||||
app.use('/api/v1/payroll', payrollRoutes)
|
||||
app.use('/api/v1/payroll2', payroll2Routes)
|
||||
app.use('/api/v1/social', socialRoutes)
|
||||
app.use('/api/v1/notifications', notificationRoutes)
|
||||
app.use('/api/v1/attachments', attachmentRoutes)
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -32,6 +32,9 @@ export const updateEmployeeSchema = z.object({
|
||||
isPregnant: z.boolean().optional(),
|
||||
isInMedicalPeriod: z.boolean().optional(),
|
||||
isWorkInjured: z.boolean().optional(),
|
||||
socialInsBase: z.number().min(0).nullable().optional(),
|
||||
housingFundBase: z.number().min(0).nullable().optional(),
|
||||
specialDeduction: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
|
||||
@@ -212,12 +212,33 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.name !== undefined) updateData.name = data.name
|
||||
if (data.department !== undefined) updateData.department = data.department
|
||||
if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate)
|
||||
if (data.monthlySalary !== undefined) updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
if (data.monthlySalary !== undefined) {
|
||||
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const newSalary = Number(data.monthlySalary) || 0
|
||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
// 记录薪资变更
|
||||
if (oldSalary !== newSalary) {
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldSalary,
|
||||
newSalary,
|
||||
effectiveDate: new Date(),
|
||||
reason: data.salaryChangeReason || '手动调整',
|
||||
createdBy: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (data.gender !== undefined) updateData.gender = data.gender
|
||||
if (data.phone !== undefined) updateData.phone = data.phone
|
||||
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
|
||||
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
|
||||
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
|
||||
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
|
||||
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
|
||||
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: updateData })
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
|
||||
// ========== 薪酬模版 ==========
|
||||
|
||||
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
|
||||
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
|
||||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
|
||||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
|
||||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
|
||||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
|
||||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
|
||||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
|
||||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
|
||||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
|
||||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
|
||||
]
|
||||
|
||||
export async function ensureDefaultTemplate(orgId: string) {
|
||||
const existing = await prisma.payslipItem.count({ where: { orgId } })
|
||||
if (existing === 0) {
|
||||
await prisma.payslipItem.createMany({
|
||||
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplate(orgId: string) {
|
||||
await ensureDefaultTemplate(orgId)
|
||||
return prisma.payslipItem.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 社保计算 ==========
|
||||
|
||||
export function calcSocialInsurance(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
|
||||
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
|
||||
return { actualBase, socialEmp, socialOrg }
|
||||
}
|
||||
|
||||
export function calcHousingFund(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
return { actualBase, housingEmp, housingOrg }
|
||||
}
|
||||
|
||||
// ========== 累计预扣个税 ==========
|
||||
|
||||
const TAX_BRACKETS = [
|
||||
{ rate: 0.03, quickDeduction: 0 },
|
||||
{ rate: 0.10, quickDeduction: 2520 },
|
||||
{ rate: 0.20, quickDeduction: 16920 },
|
||||
{ rate: 0.25, quickDeduction: 31920 },
|
||||
{ rate: 0.30, quickDeduction: 52920 },
|
||||
{ rate: 0.35, quickDeduction: 85920 },
|
||||
{ rate: 0.45, quickDeduction: 181920 },
|
||||
]
|
||||
|
||||
export function calcTax(taxableIncome: number): number {
|
||||
if (taxableIncome <= 0) return 0
|
||||
let tax = 0
|
||||
if (taxableIncome <= 36000) tax = taxableIncome * 0.03
|
||||
else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520
|
||||
else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920
|
||||
else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920
|
||||
else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920
|
||||
else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920
|
||||
else tax = taxableIncome * 0.45 - 181920
|
||||
return Math.max(0, Math.round(tax * 100) / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计预扣法计算当月个税
|
||||
* @param ytdTaxableIncome 当年累计应纳税所得额(含当月)
|
||||
* @param ytdTaxDeducted 当年累计已预扣税额
|
||||
* @returns 当月应预扣税额
|
||||
*/
|
||||
export function calcCumulativeTax(ytdTaxableIncome: number, ytdTaxDeducted: number): number {
|
||||
const ytdTax = calcTax(ytdTaxableIncome)
|
||||
const currentMonthTax = Math.max(0, ytdTax - ytdTaxDeducted)
|
||||
return Math.round(currentMonthTax * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* 年终奖单独计税
|
||||
* @param bonusAmount 奖金金额
|
||||
* @returns 应纳税额
|
||||
*/
|
||||
export function calcBonusTax(bonusAmount: number): number {
|
||||
if (bonusAmount <= 0) return 0
|
||||
const monthlyBonus = bonusAmount / 12
|
||||
let rate = 0.03
|
||||
let quickDeduction = 0
|
||||
if (monthlyBonus <= 3000) { rate = 0.03; quickDeduction = 0 }
|
||||
else if (monthlyBonus <= 12000) { rate = 0.10; quickDeduction = 210 }
|
||||
else if (monthlyBonus <= 25000) { rate = 0.20; quickDeduction = 1410 }
|
||||
else if (monthlyBonus <= 35000) { rate = 0.25; quickDeduction = 2660 }
|
||||
else if (monthlyBonus <= 55000) { rate = 0.30; quickDeduction = 4410 }
|
||||
else if (monthlyBonus <= 80000) { rate = 0.35; quickDeduction = 7160 }
|
||||
else { rate = 0.45; quickDeduction = 15160 }
|
||||
const tax = bonusAmount * rate - quickDeduction
|
||||
return Math.max(0, Math.round(tax * 100) / 100)
|
||||
}
|
||||
|
||||
// ========== 批次计算 ==========
|
||||
|
||||
export async function calcBatchEntry(
|
||||
orgId: string,
|
||||
employeeId: string,
|
||||
month: string,
|
||||
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
|
||||
batchType: string = 'REGULAR',
|
||||
) {
|
||||
const [employee, socialConfig] = await Promise.all([
|
||||
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
|
||||
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
|
||||
])
|
||||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
// 社保基数:优先用员工核定基数,否则用基本工资
|
||||
const socialBase = employee.socialInsBase || inputs.baseSalary
|
||||
const housingBase = employee.housingFundBase || inputs.baseSalary
|
||||
|
||||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
||||
if (socialConfig) {
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
const housing = calcHousingFund(housingBase, socialConfig)
|
||||
socialEmp = social.socialEmp
|
||||
socialOrg = social.socialOrg
|
||||
housingEmp = housing.housingEmp
|
||||
housingOrg = housing.housingOrg
|
||||
}
|
||||
|
||||
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
|
||||
|
||||
// 个税计算
|
||||
let tax = 0
|
||||
if (batchType === 'BONUS') {
|
||||
// 年终奖单独计税
|
||||
tax = calcBonusTax(inputs.bonus)
|
||||
} else {
|
||||
// 累计预扣法
|
||||
const year = month.slice(0, 4)
|
||||
const prevPayslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId,
|
||||
month: { startsWith: year, lt: month },
|
||||
},
|
||||
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true },
|
||||
})
|
||||
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay
|
||||
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp
|
||||
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp
|
||||
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0)
|
||||
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||||
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
|
||||
}
|
||||
|
||||
const netPay = totalPay - socialEmp - housingEmp - tax
|
||||
|
||||
return {
|
||||
socialEmp: Math.round(socialEmp * 100) / 100,
|
||||
socialOrg: Math.round(socialOrg * 100) / 100,
|
||||
housingEmp: Math.round(housingEmp * 100) / 100,
|
||||
housingOrg: Math.round(housingOrg * 100) / 100,
|
||||
tax,
|
||||
totalPay: Math.round(totalPay * 100) / 100,
|
||||
netPay: Math.round(netPay * 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 风险提示 ==========
|
||||
|
||||
export async function getPayrollRiskWarnings(orgId: string, employeeId: string): Promise<string[]> {
|
||||
const warnings: string[] = []
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
terminations: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
})
|
||||
if (!employee) return warnings
|
||||
|
||||
if (employee.status === 'RESIGNED') {
|
||||
warnings.push('该员工已离职,需进行离职结算')
|
||||
}
|
||||
if (!employee.contracts.length || employee.contracts[0].contractType === 'UNSIGNED') {
|
||||
warnings.push('未签订书面劳动合同')
|
||||
}
|
||||
if (employee.contracts.length) {
|
||||
const contract = employee.contracts[0]
|
||||
if (contract.endDate) {
|
||||
const daysToExpiry = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
if (daysToExpiry <= 30 && daysToExpiry > 0) {
|
||||
warnings.push(`合同将于 ${daysToExpiry} 天后到期`)
|
||||
}
|
||||
}
|
||||
if (contract.probationMonths > 0 && contract.startDate) {
|
||||
const probationEnd = new Date(contract.startDate)
|
||||
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
|
||||
if (probationEnd > new Date()) {
|
||||
warnings.push('试用期员工,薪资可能不同')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!employee.socialInsBase) {
|
||||
warnings.push('未设置社保缴费基数')
|
||||
}
|
||||
if (!employee.housingFundBase) {
|
||||
warnings.push('未设置公积金缴费基数')
|
||||
}
|
||||
if (employee.terminations.length) {
|
||||
warnings.push('已有解聘记录,请注意结算')
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
// ========== 工资条汇总生成 ==========
|
||||
|
||||
export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
// 获取当月所有已归档批次
|
||||
const batches = await prisma.payrollBatch.findMany({
|
||||
where: { orgId, month, status: 'ARCHIVED' },
|
||||
include: { entries: true },
|
||||
})
|
||||
if (batches.length === 0) return { generated: 0 }
|
||||
|
||||
// 按员工汇总
|
||||
const employeeMap = new Map<string, any>()
|
||||
for (const batch of batches) {
|
||||
for (const entry of batch.entries) {
|
||||
const existing = employeeMap.get(entry.employeeId) || {
|
||||
baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0,
|
||||
socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0,
|
||||
totalPay: 0, netPay: 0,
|
||||
}
|
||||
existing.baseSalary += entry.baseSalary
|
||||
existing.overtimePay += entry.overtimePay
|
||||
existing.allowance += entry.allowance
|
||||
existing.deduction += entry.deduction
|
||||
existing.bonus += entry.bonus
|
||||
existing.socialEmp += entry.socialEmp
|
||||
existing.socialOrg += entry.socialOrg
|
||||
existing.housingEmp += entry.housingEmp
|
||||
existing.housingOrg += entry.housingOrg
|
||||
existing.tax += entry.tax
|
||||
existing.totalPay += entry.totalPay
|
||||
existing.netPay += entry.netPay
|
||||
employeeMap.set(entry.employeeId, existing)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算累计数据
|
||||
const year = month.slice(0, 4)
|
||||
const monthNum = Number(month.slice(5, 7))
|
||||
|
||||
let generated = 0
|
||||
for (const [employeeId, summary] of employeeMap) {
|
||||
// 获取当年之前月份的累计数据
|
||||
const prevPayslips = await prisma.payslip.findMany({
|
||||
where: { orgId, employeeId, month: { startsWith: year, lt: month } },
|
||||
select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true },
|
||||
})
|
||||
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay
|
||||
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax
|
||||
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp
|
||||
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + summary.housingEmp
|
||||
|
||||
await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||
allowance: Math.round(summary.allowance * 100) / 100,
|
||||
deduction: Math.round(summary.deduction * 100) / 100,
|
||||
bonus: Math.round(summary.bonus * 100) / 100,
|
||||
totalPay: Math.round(summary.totalPay * 100) / 100,
|
||||
socialEmp: Math.round(summary.socialEmp * 100) / 100,
|
||||
housingEmp: Math.round(summary.housingEmp * 100) / 100,
|
||||
tax: Math.round(summary.tax * 100) / 100,
|
||||
netPay: Math.round(summary.netPay * 100) / 100,
|
||||
ytdIncome: Math.round(ytdIncome * 100) / 100,
|
||||
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
|
||||
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
|
||||
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
orgId,
|
||||
employeeId,
|
||||
month,
|
||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||
allowance: Math.round(summary.allowance * 100) / 100,
|
||||
deduction: Math.round(summary.deduction * 100) / 100,
|
||||
bonus: Math.round(summary.bonus * 100) / 100,
|
||||
totalPay: Math.round(summary.totalPay * 100) / 100,
|
||||
socialEmp: Math.round(summary.socialEmp * 100) / 100,
|
||||
housingEmp: Math.round(summary.housingEmp * 100) / 100,
|
||||
tax: Math.round(summary.tax * 100) / 100,
|
||||
netPay: Math.round(summary.netPay * 100) / 100,
|
||||
ytdIncome: Math.round(ytdIncome * 100) / 100,
|
||||
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
|
||||
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
|
||||
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
})
|
||||
generated++
|
||||
}
|
||||
|
||||
return { generated }
|
||||
}
|
||||
@@ -172,6 +172,21 @@ export async function detectMonthlyTasks(orgId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 工资条生成提醒:当月有已归档批次时提醒生成工资条
|
||||
const archivedBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month: currentMonth, status: 'ARCHIVED' },
|
||||
})
|
||||
if (archivedBatches > 0) {
|
||||
risks.push({
|
||||
employeeId: null,
|
||||
type: 'SALARY',
|
||||
level: 'MEDIUM',
|
||||
title: `${currentMonth}月 生成工资条`,
|
||||
description: `本月有 ${archivedBatches} 个已归档工资批次,请前往工资条管理汇总生成工资条`,
|
||||
actionUrl: '/money',
|
||||
})
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
@@ -181,10 +196,10 @@ export async function runRiskDetection(orgId: string) {
|
||||
})
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.title}`))
|
||||
|
||||
// 月度任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的月度任务被重新创建
|
||||
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
||||
const monthlyExisting = await prisma.riskItem.findMany({
|
||||
where: { orgId, type: 'MONTHLY', title: { startsWith: `${currentMonth}月` } },
|
||||
where: { orgId, title: { startsWith: `${currentMonth}月` } },
|
||||
select: { employeeId: true, title: true },
|
||||
})
|
||||
const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`))
|
||||
@@ -227,12 +242,12 @@ export async function getDashboardData(orgId: string) {
|
||||
|
||||
const [
|
||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
||||
overtimeRecords, payslips, socialConfig,
|
||||
overtimeRecords, payslips, batchEntries, socialConfig,
|
||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||
monthSeverancePay,
|
||||
] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH', type: { in: ['CONTRACT', 'TERMINATION'] } } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
@@ -254,6 +269,11 @@ export async function getDashboardData(orgId: string) {
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true },
|
||||
}),
|
||||
// 已归档批次的条目(用于总览汇总)
|
||||
prisma.batchEntry.findMany({
|
||||
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
|
||||
}),
|
||||
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
|
||||
prisma.laborContract.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
@@ -275,20 +295,75 @@ export async function getDashboardData(orgId: string) {
|
||||
|
||||
const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0)
|
||||
|
||||
// 本月薪税汇总
|
||||
const totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
|
||||
const totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
|
||||
const totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
|
||||
const totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
|
||||
const totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
|
||||
const confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
// 本月薪税汇总:优先从已归档批次汇总,无归档批次则用工资条数据
|
||||
const archivedEntries = batchEntries
|
||||
const useArchivedData = archivedEntries.length > 0
|
||||
|
||||
// 社保公积金估算(基于在职员工数 × 社保配置)
|
||||
let totalBaseSalary: number, totalOvertimePay: number, totalAllowance: number, totalDeduction: number, totalPay: number
|
||||
let totalSocialOrg: number, totalSocialEmp: number, totalHousingOrg: number, totalHousingEmp: number, totalTax: number, totalNetPay: number
|
||||
let payslipCount: number, confirmedPayslips: number
|
||||
|
||||
if (useArchivedData) {
|
||||
// 从已归档批次条目汇总(同一员工多批次的金额累加)
|
||||
const empMap = new Map<string, any>()
|
||||
for (const e of archivedEntries) {
|
||||
const ex = empMap.get(e.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, totalPay: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, netPay: 0 }
|
||||
ex.baseSalary += e.baseSalary
|
||||
ex.overtimePay += e.overtimePay
|
||||
ex.allowance += e.allowance
|
||||
ex.deduction += e.deduction
|
||||
ex.bonus += e.bonus
|
||||
ex.totalPay += e.totalPay
|
||||
ex.socialEmp += e.socialEmp
|
||||
ex.socialOrg += e.socialOrg
|
||||
ex.housingEmp += e.housingEmp
|
||||
ex.housingOrg += e.housingOrg
|
||||
ex.tax += e.tax
|
||||
ex.netPay += e.netPay
|
||||
empMap.set(e.employeeId, ex)
|
||||
}
|
||||
const summary = Array.from(empMap.values())
|
||||
totalBaseSalary = summary.reduce((s, e) => s + e.baseSalary, 0)
|
||||
totalOvertimePay = summary.reduce((s, e) => s + e.overtimePay, 0)
|
||||
totalAllowance = summary.reduce((s, e) => s + e.allowance, 0)
|
||||
totalDeduction = summary.reduce((s, e) => s + e.deduction, 0)
|
||||
totalPay = summary.reduce((s, e) => s + e.totalPay, 0)
|
||||
totalSocialOrg = summary.reduce((s, e) => s + e.socialOrg, 0)
|
||||
totalSocialEmp = summary.reduce((s, e) => s + e.socialEmp, 0)
|
||||
totalHousingOrg = summary.reduce((s, e) => s + e.housingOrg, 0)
|
||||
totalHousingEmp = summary.reduce((s, e) => s + e.housingEmp, 0)
|
||||
totalTax = summary.reduce((s, e) => s + e.tax, 0)
|
||||
totalNetPay = summary.reduce((s, e) => s + e.netPay, 0)
|
||||
payslipCount = summary.length
|
||||
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
} else {
|
||||
// fallback:从工资条表汇总
|
||||
totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
|
||||
totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
|
||||
totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
|
||||
totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
|
||||
totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
|
||||
totalSocialOrg = 0
|
||||
totalSocialEmp = 0
|
||||
totalHousingOrg = 0
|
||||
totalHousingEmp = 0
|
||||
totalTax = 0
|
||||
totalNetPay = 0
|
||||
payslipCount = payslips.length
|
||||
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
}
|
||||
|
||||
// 社保公积金:优先用归档批次的实际计算值,否则估算
|
||||
let socialOrgTotal = 0
|
||||
let socialEmpTotal = 0
|
||||
let housingOrgTotal = 0
|
||||
let housingEmpTotal = 0
|
||||
if (socialConfig && employeeCount > 0) {
|
||||
if (useArchivedData) {
|
||||
socialOrgTotal = totalSocialOrg
|
||||
socialEmpTotal = totalSocialEmp
|
||||
housingOrgTotal = totalHousingOrg
|
||||
housingEmpTotal = totalHousingEmp
|
||||
} else if (socialConfig && employeeCount > 0) {
|
||||
// 用平均工资作为估算基数
|
||||
const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin
|
||||
socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount
|
||||
@@ -297,11 +372,12 @@ export async function getDashboardData(orgId: string) {
|
||||
housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount
|
||||
}
|
||||
|
||||
// 个税估算(简化:应纳税所得额 = 税前工资 - 5000起征点 - 社保个人部分 - 公积金个人部分)
|
||||
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
|
||||
// 累计预扣法简化:月度个税估算
|
||||
// 个税:优先用归档批次的实际计算值,否则估算
|
||||
let estimatedTax = 0
|
||||
if (taxableIncome > 0) {
|
||||
if (useArchivedData) {
|
||||
estimatedTax = totalTax
|
||||
} else {
|
||||
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
|
||||
if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03
|
||||
else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1
|
||||
else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2
|
||||
@@ -314,9 +390,9 @@ export async function getDashboardData(orgId: string) {
|
||||
const payrollSummary = {
|
||||
month: currentMonth,
|
||||
employeeCount,
|
||||
payslipCount: payslips.length,
|
||||
payslipCount,
|
||||
confirmedPayslips,
|
||||
unconfirmedPayslips: payslips.length - confirmedPayslips,
|
||||
unconfirmedPayslips: payslipCount - confirmedPayslips,
|
||||
baseSalary: totalBaseSalary,
|
||||
overtimePay: totalOvertimePay,
|
||||
allowance: totalAllowance,
|
||||
@@ -331,7 +407,7 @@ export async function getDashboardData(orgId: string) {
|
||||
// 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 经济补偿金
|
||||
orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + (monthSeverancePay._sum.compensation || 0),
|
||||
// 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税
|
||||
empNetPay: totalPay - socialEmpTotal - housingEmpTotal - estimatedTax,
|
||||
empNetPay: useArchivedData ? totalNetPay : totalPay - socialEmpTotal - housingEmpTotal - estimatedTax,
|
||||
}
|
||||
|
||||
// 本月工作动态
|
||||
@@ -353,6 +429,7 @@ export async function getDashboardData(orgId: string) {
|
||||
|
||||
const todos = riskItems.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
@@ -361,6 +438,7 @@ export async function getDashboardData(orgId: string) {
|
||||
|
||||
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
|
||||
Reference in New Issue
Block a user