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
+141 -1
View File
@@ -50,6 +50,27 @@ enum RiskLevel {
LOW
}
enum PayrollBatchType {
REGULAR // 常规发薪
TERMINATION // 离职结算
BONUS // 年终奖/奖金
}
enum PayrollBatchStatus {
DRAFT // 草稿(可编辑)
ARCHIVED // 归档(已发薪,锁定)
}
enum PayslipItemType {
INPUT // 手工输入项(计算依据)
CALCULATED // 计算项(公式自动计算)
}
enum PayslipStatus {
PENDING // 待发布
PUBLISHED // 已发布到员工端
}
enum RiskStatus {
PENDING
RESOLVED
@@ -90,6 +111,7 @@ model Organization {
plan Plan @default(FREE)
maxEmployees Int @default(20)
city String?
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -101,6 +123,9 @@ model Organization {
riskItems RiskItem[]
auditLogs AuditLog[]
payslips Payslip[]
payrollBatches PayrollBatch[]
payslipItems PayslipItem[]
salaryChangeRecords SalaryChangeRecord[]
onboardingLinks OnboardingLink[]
confirmLinks ContractConfirmLink[]
socialInsuranceConfig SocialInsuranceConfig?
@@ -149,6 +174,10 @@ model Employee {
isPregnant Boolean @default(false)
isInMedicalPeriod Boolean @default(false)
isWorkInjured Boolean @default(false)
// 薪税扩展
socialInsBase Float? // 社保缴费基数(按人核定)
housingFundBase Float? // 公积金缴费基数(按人核定)
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -158,6 +187,8 @@ model Employee {
terminations TerminationRecord[]
riskItems RiskItem[]
payslips Payslip[]
salaryChanges SalaryChangeRecord[]
batchEntries BatchEntry[]
attachments EmployeeAttachment[]
disciplinaryRecords DisciplinaryRecord[]
attendanceRecords AttendanceRecord[]
@@ -436,6 +467,7 @@ model Payslip {
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
month String // YYYY-MM
// 薪酬构成
baseSalary Float @default(0)
overtimePay Float @default(0)
weekdayOvertimePay Float @default(0)
@@ -443,14 +475,122 @@ model Payslip {
holidayOvertimePay Float @default(0)
allowance Float @default(0)
deduction Float @default(0)
totalPay Float @default(0)
bonus Float @default(0) // 奖金/年终奖
totalPay Float @default(0) // 应发合计
// 扣除项
socialEmp Float @default(0) // 个人社保
housingEmp Float @default(0) // 个人公积金
tax Float @default(0) // 个人所得税
netPay Float @default(0) // 实发工资 = totalPay - socialEmp - housingEmp - tax
// 累计预扣法
ytdIncome Float @default(0) // 当年累计收入
ytdTaxDeducted Float @default(0) // 当年累计已扣税
ytdSocialEmp Float @default(0) // 当年累计个人社保
ytdHousingEmp Float @default(0) // 当年累计个人公积金
// 状态
status PayslipStatus @default(PENDING) // PENDING → PUBLISHED
confirmedAt DateTime?
confirmedIp String?
publishedAt DateTime? // 工资条发布到员工端的时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, month])
@@index([orgId, month])
@@index([orgId, status])
}
model PayrollBatch {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
month String // YYYY-MM
batchNo Int // 批次序号(1, 2, 3...
name String // 批次名称
type PayrollBatchType @default(REGULAR)
status PayrollBatchStatus @default(DRAFT)
employeeCount Int @default(0)
totalPay Float @default(0)
totalNetPay Float @default(0)
totalSocialOrg Float @default(0)
totalSocialEmp Float @default(0)
totalHousingOrg Float @default(0)
totalHousingEmp Float @default(0)
totalTax Float @default(0)
remark String?
createdBy String
createdAt DateTime @default(now())
archivedAt DateTime?
updatedAt DateTime @updatedAt
entries BatchEntry[]
@@unique([orgId, month, batchNo])
@@index([orgId, month])
@@index([orgId, status])
}
model BatchEntry {
id String @id @default(cuid())
batchId String
batch PayrollBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
orgId String
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
// 薪酬项(可编辑的输入项)
baseSalary Float @default(0)
overtimePay Float @default(0)
allowance Float @default(0)
deduction Float @default(0)
bonus Float @default(0)
// 自动计算项
socialEmp Float @default(0)
socialOrg Float @default(0)
housingEmp Float @default(0)
housingOrg Float @default(0)
tax Float @default(0)
totalPay Float @default(0) // 应发合计
netPay Float @default(0) // 实发工资
// 风险提示
riskWarnings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([batchId, employeeId])
@@index([orgId, employeeId])
}
model PayslipItem {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 显示名称
code String // 字段代码
type PayslipItemType @default(INPUT)
formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction"
order Int @default(0)
isDefault Boolean @default(true) // 系统预置项不可删除
isEditable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, code])
}
model SalaryChangeRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldSalary Float
newSalary Float
effectiveDate DateTime // 生效日期
reason String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
}
model OnboardingLink {
+251 -46
View File
@@ -1,29 +1,92 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
import { randomBytes } from 'crypto'
import { encrypt } from '../src/lib/crypto'
const prisma = new PrismaClient()
async function main() {
// 创建测试企业
let org = await prisma.organization.findFirst({ where: { name: '测试科技有限公司' } })
if (!org) {
org = await prisma.organization.create({
data: {
name: '测试科技有限公司',
plan: 'FREE',
maxEmployees: 20,
city: '上海',
},
})
}
// 社保计算(与 payroll.service.ts 一致)
function calcSocial(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 { socialEmp: Math.round(socialEmp * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100 }
}
function calcHousing(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 { housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100 }
}
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)
}
// 创建管理员用户
// 9名员工完整数据
const EMPLOYEES = [
{ name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' },
{ name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true },
{ name: '王强', gender: '男', dept: '销售部', phone: '13900000003', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' },
{ name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' },
{ name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' },
{ name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' },
{ name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' },
{ name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' },
{ name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' },
]
async function main() {
// 1. 清空所有数据(按依赖顺序删除)
console.log('清空现有数据...')
await prisma.notificationLog.deleteMany()
await prisma.auditLog.deleteMany()
await prisma.batchEntry.deleteMany()
await prisma.payrollBatch.deleteMany()
await prisma.payslipItem.deleteMany()
await prisma.salaryChangeRecord.deleteMany()
await prisma.payslip.deleteMany()
await prisma.overtimeRecord.deleteMany()
await prisma.terminationRecord.deleteMany()
await prisma.riskItem.deleteMany()
await prisma.employeeAttachment.deleteMany()
await prisma.disciplinaryRecord.deleteMany()
await prisma.attendanceRecord.deleteMany()
await prisma.trainingRecord.deleteMany()
await prisma.performanceRecord.deleteMany()
await prisma.laborContract.deleteMany()
await prisma.contractConfirmLink.deleteMany()
await prisma.onboardingLink.deleteMany()
await prisma.employee.deleteMany()
await prisma.socialInsuranceConfig.deleteMany()
await prisma.notificationSetting.deleteMany()
await prisma.user.deleteMany()
await prisma.organization.deleteMany()
console.log('数据已清空')
// 2. 创建企业
const org = await prisma.organization.create({
data: {
name: '智云科技有限公司',
plan: 'PRO',
maxEmployees: 50,
city: '上海',
payrollFrequency: 1,
},
})
console.log('企业已创建:', org.name)
// 3. 创建管理员
const passwordHash = await bcrypt.hash('12345678', 10)
const admin = await prisma.user.upsert({
where: { phone: '13800000001' },
update: {},
create: {
const admin = await prisma.user.create({
data: {
orgId: org.id,
phone: '13800000001',
name: '管理员',
@@ -31,40 +94,182 @@ async function main() {
role: 'ADMIN',
},
})
console.log('管理员已创建:', admin.phone)
// 创建测试员工
const salaryHash = randomBytes(32).toString('hex')
const employee = await prisma.employee.create({
// 4. 创建社保配置(上海标准)
await prisma.socialInsuranceConfig.create({
data: {
orgId: org.id,
name: '张三',
department: '技术部',
hireDate: new Date('2026-01-15'),
monthlySalary: 'encrypted:' + salaryHash,
phone: '13900000001',
gender: '男',
createdBy: admin.id,
city: '上海',
pensionOrg: 16,
pensionEmp: 8,
medicalOrg: 9.8,
medicalEmp: 2,
unemploymentOrg: 0.5,
unemploymentEmp: 0.5,
injuryOrg: 0.2,
maternityOrg: 0.8,
housingOrg: 7,
housingEmp: 7,
baseMin: 7384,
baseMax: 36921,
},
})
console.log('社保配置已创建')
// 5. 创建通知设置
await prisma.notificationSetting.create({
data: {
orgId: org.id,
contractExpiry: true,
expiryDays: 30,
contractUnsigned: true,
overtimeAlert: true,
payslipReady: true,
payrollDay: 10,
socialInsDay: 15,
housingFundDay: 15,
taxDay: 15,
},
})
// 创建测试合同
await prisma.laborContract.create({
data: {
orgId: org.id,
employeeId: employee.id,
signDate: new Date('2026-01-20'),
startDate: new Date('2026-02-01'),
endDate: new Date('2029-01-31'),
contractType: 'FIXED',
signMethod: 'PAPER',
contractYears: 3,
probationMonths: 2,
probationSalary: 6400,
createdBy: admin.id,
},
})
// 6. 创建薪酬模版(预置项)
const defaultItems: { 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 },
]
for (const item of defaultItems) {
await prisma.payslipItem.create({
data: { orgId: org.id, ...item },
})
}
console.log('薪酬模版已创建')
console.log('Seed data created:', { org: org.id, admin: admin.id, employee: employee.id })
// 7. 创建9名员工 + 合同
for (let i = 0; i < EMPLOYEES.length; i++) {
const e = EMPLOYEES[i]
const emp = await prisma.employee.create({
data: {
orgId: org.id,
name: e.name,
department: e.dept,
hireDate: new Date(e.hireDate),
monthlySalary: encrypt(String(e.salary)),
phone: e.phone,
gender: e.gender,
socialInsBase: e.socialBase,
housingFundBase: e.housingBase,
specialDeduction: e.specialDeduction,
bankName: e.bank,
bankAccount: encrypt(e.account),
emergencyContact: e.emergency,
emergencyPhone: e.emergencyPhone,
address: e.address,
isPregnant: e.pregnant || false,
createdBy: admin.id,
},
})
// 创建合同
const startDate = new Date(e.hireDate)
const endDate = e.contractType === 'FIXED'
? new Date(startDate.getFullYear() + e.years, startDate.getMonth(), startDate.getDate() - 1)
: null
await prisma.laborContract.create({
data: {
orgId: org.id,
employeeId: emp.id,
signDate: new Date(e.hireDate),
startDate,
endDate,
contractType: e.contractType as any,
signMethod: 'PAPER',
contractYears: e.years,
probationMonths: e.probation,
probationSalary: e.probationSalary,
createdBy: admin.id,
},
})
console.log(`员工 ${i + 1}/9 已创建: ${e.name} - ${e.dept} - ¥${e.salary}/月`)
}
// 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据
console.log('\n生成 1-6 月历史工资条...')
const socialConfig = await prisma.socialInsuranceConfig.findUnique({ where: { orgId: org.id } })
const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } })
const months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06']
for (const emp of allEmployees) {
// 跳过 2026 年之后入职的员工
const hireYear = emp.hireDate.getFullYear()
if (hireYear > 2026) continue
const hireMonth = hireYear === 2026 ? emp.hireDate.getMonth() + 1 : 1
let ytdIncome = 0, ytdSocialEmp = 0, ytdHousingEmp = 0, ytdTaxDeducted = 0
for (let m = 1; m <= 6; m++) {
if (m < hireMonth) continue
const monthStr = `2026-${String(m).padStart(2, '0')}`
const baseSalary = emp.socialInsBase || 0 // 用社保基数作为基本工资(简化)
const social = calcSocial(emp.socialInsBase || baseSalary, socialConfig)
const housing = calcHousing(emp.housingFundBase || baseSalary, socialConfig)
const totalPay = baseSalary
const specialDeduction = emp.specialDeduction * m
ytdIncome += totalPay
ytdSocialEmp += social.socialEmp
ytdHousingEmp += housing.housingEmp
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * m - ytdSocialEmp - ytdHousingEmp - specialDeduction)
const ytdTax = calcTax(ytdTaxableIncome)
const monthTax = Math.max(0, Math.round((ytdTax - ytdTaxDeducted) * 100) / 100)
ytdTaxDeducted += monthTax
const netPay = Math.round((totalPay - social.socialEmp - housing.housingEmp - monthTax) * 100) / 100
await prisma.payslip.create({
data: {
orgId: org.id,
employeeId: emp.id,
month: monthStr,
baseSalary,
overtimePay: 0,
allowance: 0,
deduction: 0,
bonus: 0,
totalPay,
socialEmp: social.socialEmp,
housingEmp: housing.housingEmp,
tax: monthTax,
netPay,
ytdIncome,
ytdTaxDeducted,
ytdSocialEmp,
ytdHousingEmp,
status: 'PUBLISHED',
publishedAt: new Date(`${monthStr}-10T10:00:00Z`),
confirmedAt: new Date(`${monthStr}-12T10:00:00Z`),
},
})
}
console.log(` ${emp.name}: 1-6月工资条已生成`)
}
console.log('\n===== 示例数据创建完成 =====')
console.log(`企业: ${org.name}`)
console.log(`管理员: 13800000001 / 密码: 12345678`)
console.log(`员工: ${EMPLOYEES.length}`)
console.log('社保配置: 上海标准')
console.log('薪酬模版: 10项预置')
}
main()
+2
View File
@@ -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)
+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) {
+3
View File
@@ -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({
+22 -1
View File
@@ -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)
+324
View File
@@ -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 }
}
+98 -20
View File
@@ -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,