feat: 社保公积金独立配置+版本化缴费记录+月度增减员+补偿金批次
- Schema: 拆分社保/公积金配置,新增EmployeeSocialInsRecord/EmployeeHousingFundRecord/DepartmentRecord模型,扩展SalaryChangeRecord,增加SEVERANCE批次类型 - 后端: createEmployee/rehireEmployee接收社保公积金字段并创建缴费记录版本;createTermination/createResignation接收截止年月并关闭缴费记录;调薪/调部门API+版本记录;月度增减员API;公积金独立CRUD/计算/调基;SEVERANCE批次calcBatchEntry - 前端: AddEmployeeModal/RehireModal增加社保公积金输入;ResignModal/Termination增加截止年月+日期不一致提醒;花名册增加调薪/调部门弹窗;SocialInsurance.tsx Tab拆分(社保/公积金/月度增减员)+CSV导出;Money.tsx增加补偿金批次类型 - 修复: seed.ts移除housingOrg/housingEmp;risk.service.ts从HousingFundConfig获取公积金费率
This commit is contained in:
@@ -55,6 +55,7 @@ enum PayrollBatchType {
|
||||
REGULAR // 常规发薪
|
||||
TERMINATION // 离职结算
|
||||
BONUS // 年终奖/奖金
|
||||
SEVERANCE // 补偿金按月发放(无社保,个税按政策处理)
|
||||
}
|
||||
|
||||
enum PayrollBatchStatus {
|
||||
@@ -131,6 +132,10 @@ model Organization {
|
||||
onboardingLinks OnboardingLink[]
|
||||
confirmLinks ContractConfirmLink[]
|
||||
socialInsuranceConfig SocialInsuranceConfig[]
|
||||
housingFundConfigs HousingFundConfig[]
|
||||
socialInsRecords EmployeeSocialInsRecord[]
|
||||
housingFundRecords EmployeeHousingFundRecord[]
|
||||
departmentRecords EmployeeDepartmentRecord[]
|
||||
notificationSetting NotificationSetting?
|
||||
overtimeConfig OvertimeConfig?
|
||||
notificationLogs NotificationLog[]
|
||||
@@ -178,8 +183,12 @@ model Employee {
|
||||
isInMedicalPeriod Boolean @default(false)
|
||||
isWorkInjured Boolean @default(false)
|
||||
// 薪税扩展
|
||||
socialInsBase Float? // 社保缴费基数(按人核定)
|
||||
housingFundBase Float? // 公积金缴费基数(按人核定)
|
||||
socialInsBase Float? // 社保缴费基数(便捷字段,由Record同步)
|
||||
housingFundBase Float? // 公积金缴费基数(便捷字段,由Record同步)
|
||||
socialInsStartMonth String? // 当前社保开始年月(便捷字段)
|
||||
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
|
||||
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
|
||||
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
|
||||
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
@@ -197,6 +206,9 @@ model Employee {
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
socialInsRecords EmployeeSocialInsRecord[]
|
||||
housingFundRecords EmployeeHousingFundRecord[]
|
||||
departmentRecords EmployeeDepartmentRecord[]
|
||||
}
|
||||
|
||||
model LaborContract {
|
||||
@@ -256,6 +268,8 @@ model TerminationRecord {
|
||||
terminationDate DateTime
|
||||
resignationReason String? // 主动离职原因(type=RESIGNATION时使用)
|
||||
compensation Float @default(0)
|
||||
socialInsEndMonth String? // 社保截止缴费年月 YYYY-MM
|
||||
housingFundEndMonth String? // 公积金截止缴费年月 YYYY-MM
|
||||
riskLevel RiskAssessment @default(SAFE)
|
||||
checklist Json
|
||||
remark String?
|
||||
@@ -315,14 +329,33 @@ model SocialInsuranceConfig {
|
||||
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
||||
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
||||
maternityOrg Float @default(0.8) // 生育保险 企业比例 %
|
||||
housingOrg Float @default(12) // 公积金 企业比例 %
|
||||
housingEmp Float @default(12) // 公积金 个人比例 %
|
||||
baseMin Float @default(6326) // 缴费基数下限
|
||||
baseMax Float @default(33891) // 缴费基数上限
|
||||
baseMin Float @default(6326) // 社保缴费基数下限
|
||||
baseMax Float @default(33891) // 社保缴费基数上限
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true) // 是否当前生效版本
|
||||
adjustmentDone Boolean @default(false) // 是否已执行过员工基数调整
|
||||
adjustmentDone Boolean @default(false) // 是否已执行过社保基数调整
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([orgId, effectiveFrom])
|
||||
@@index([orgId, isCurrent])
|
||||
}
|
||||
|
||||
model HousingFundConfig {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京")
|
||||
housingOrg Float @default(12) // 公积金 企业比例 %
|
||||
housingEmp Float @default(12) // 公积金 个人比例 %
|
||||
baseMin Float @default(6326) // 公积金缴费基数下限
|
||||
baseMax Float @default(33891) // 公积金缴费基数上限
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true) // 是否当前生效版本
|
||||
adjustmentDone Boolean @default(false) // 是否已执行过公积金基数调整
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -613,11 +646,72 @@ model SalaryChangeRecord {
|
||||
oldSalary Float
|
||||
newSalary Float
|
||||
effectiveDate DateTime // 生效日期
|
||||
effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换)
|
||||
endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置)
|
||||
changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪
|
||||
reason String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, effectiveMonth, endMonth])
|
||||
}
|
||||
|
||||
model EmployeeSocialInsRecord {
|
||||
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)
|
||||
startMonth String // 开始缴费年月 YYYY-MM
|
||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||
base Float // 缴费基数
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
|
||||
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, startMonth, endMonth])
|
||||
}
|
||||
|
||||
model EmployeeHousingFundRecord {
|
||||
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)
|
||||
startMonth String // 开始缴费年月 YYYY-MM
|
||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||
base Float // 缴费基数
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
|
||||
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, startMonth, endMonth])
|
||||
}
|
||||
|
||||
model EmployeeDepartmentRecord {
|
||||
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)
|
||||
oldDepartment String // 调整前部门
|
||||
newDepartment String // 调整后部门
|
||||
effectiveMonth String // 生效年月 YYYY-MM
|
||||
endMonth String? // 失效年月 YYYY-MM(null=至今有效)
|
||||
reason String? // 调部门原因
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, effectiveMonth, endMonth])
|
||||
}
|
||||
|
||||
model OnboardingLink {
|
||||
|
||||
@@ -109,8 +109,6 @@ async function main() {
|
||||
unemploymentEmp: 0.5,
|
||||
injuryOrg: 0.2,
|
||||
maternityOrg: 0.8,
|
||||
housingOrg: 7,
|
||||
housingEmp: 7,
|
||||
baseMin: 7384,
|
||||
baseMax: 36921,
|
||||
effectiveFrom: '2025-07',
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 一次性迁移脚本:为现有员工创建初始版本记录
|
||||
* 运行方式:npx tsx scripts/migrate-records.ts
|
||||
*/
|
||||
import prisma from '../src/lib/prisma.js'
|
||||
import { decrypt } from '../src/lib/crypto.js'
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const employees = await prisma.employee.findMany({
|
||||
include: {
|
||||
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
|
||||
salaryChanges: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
socialInsRecords: { take: 1 },
|
||||
housingFundRecords: { take: 1 },
|
||||
departmentRecords: { take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Found ${employees.length} employees to migrate`)
|
||||
|
||||
for (const emp of employees) {
|
||||
const hireMonth = dateToMonth(emp.hireDate)
|
||||
const termination = emp.terminations[0]
|
||||
const endMonth = termination ? dateToMonth(termination.terminationDate) : null
|
||||
|
||||
// 解密月薪获取数值
|
||||
let salaryNum = 0
|
||||
try {
|
||||
salaryNum = parseFloat(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
salaryNum = parseFloat(emp.monthlySalary) || 0
|
||||
}
|
||||
|
||||
const socialInsBase = emp.socialInsBase ?? salaryNum
|
||||
const housingFundBase = emp.housingFundBase ?? salaryNum
|
||||
|
||||
// 1. 社保缴费记录(仅当尚无记录时创建)
|
||||
if (emp.socialInsRecords.length === 0) {
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId: emp.orgId,
|
||||
employeeId: emp.id,
|
||||
startMonth: emp.socialInsStartMonth || hireMonth,
|
||||
endMonth: endMonth || emp.socialInsEndMonth || null,
|
||||
base: socialInsBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: emp.createdBy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 公积金缴费记录
|
||||
if (emp.housingFundRecords.length === 0) {
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId: emp.orgId,
|
||||
employeeId: emp.id,
|
||||
startMonth: emp.housingFundStartMonth || hireMonth,
|
||||
endMonth: endMonth || emp.housingFundEndMonth || null,
|
||||
base: housingFundBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: emp.createdBy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 薪资变更记录(仅当尚无记录时创建)
|
||||
if (emp.salaryChanges.length === 0) {
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId: emp.orgId,
|
||||
employeeId: emp.id,
|
||||
oldSalary: 0,
|
||||
newSalary: salaryNum,
|
||||
effectiveDate: emp.hireDate,
|
||||
effectiveMonth: hireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: emp.createdBy,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 已有记录但缺少 effectiveMonth/endMonth/changeType,补充
|
||||
const latest = emp.salaryChanges[0]
|
||||
if (!latest.effectiveMonth || !latest.changeType) {
|
||||
await prisma.salaryChangeRecord.update({
|
||||
where: { id: latest.id },
|
||||
data: {
|
||||
effectiveMonth: dateToMonth(latest.effectiveDate),
|
||||
changeType: latest.changeType || 'SALARY_CHANGE',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 部门变更记录
|
||||
if (emp.departmentRecords.length === 0) {
|
||||
await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId: emp.orgId,
|
||||
employeeId: emp.id,
|
||||
oldDepartment: '',
|
||||
newDepartment: emp.department,
|
||||
effectiveMonth: hireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: emp.createdBy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: {
|
||||
socialInsStartMonth: emp.socialInsStartMonth || hireMonth,
|
||||
socialInsEndMonth: endMonth || emp.socialInsEndMonth || null,
|
||||
socialInsBase,
|
||||
housingFundStartMonth: emp.housingFundStartMonth || hireMonth,
|
||||
housingFundEndMonth: endMonth || emp.housingFundEndMonth || null,
|
||||
housingFundBase,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(` ✓ ${emp.name} (${emp.department}) — records created/synced`)
|
||||
}
|
||||
|
||||
console.log('\nMigration complete!')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Migration failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -182,7 +182,7 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
|
||||
// 创建批次
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
@@ -211,7 +211,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
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 batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}`
|
||||
|
||||
// 根据模式确定员工列表和数据来源
|
||||
let employees: any[] = []
|
||||
@@ -236,7 +236,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
})
|
||||
} else {
|
||||
// copy_last 或 blank_employees:拉入员工
|
||||
if (type === 'TERMINATION') {
|
||||
if (type === 'TERMINATION' || type === 'SEVERANCE') {
|
||||
const terminations = await prisma.terminationRecord.findMany({
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
@@ -317,7 +317,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
|
||||
// 计算社保、个税等
|
||||
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
|
||||
const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0
|
||||
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
|
||||
// 风险提示
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { decrypt, encrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
@@ -570,4 +570,135 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req:
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 调薪/调部门 API ==========
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 调薪
|
||||
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newSalary, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const oldSalary = safeDecrypt(employee.monthlySalary)
|
||||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||||
const prevEffMonth = prevMonth(effMonth)
|
||||
|
||||
// 关闭之前有效记录
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId: req.params.id, endMonth: null },
|
||||
data: { endMonth: prevEffMonth },
|
||||
})
|
||||
|
||||
// 创建新薪资记录
|
||||
const record = await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.id,
|
||||
oldSalary,
|
||||
newSalary: Number(newSalary),
|
||||
effectiveDate: new Date(`${effMonth}-01`),
|
||||
effectiveMonth: effMonth,
|
||||
endMonth: null,
|
||||
changeType: 'SALARY_CHANGE',
|
||||
reason: reason || null,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { monthlySalary: encrypt(String(newSalary)) },
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调薪历史
|
||||
router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.salaryChangeRecord.findMany({
|
||||
where: { employeeId: req.params.id, orgId: req.user!.orgId },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调部门
|
||||
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newDepartment, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const oldDepartment = employee.department
|
||||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||||
const prevEffMonth = prevMonth(effMonth)
|
||||
|
||||
// 关闭之前有效记录
|
||||
await prisma.employeeDepartmentRecord.updateMany({
|
||||
where: { employeeId: req.params.id, endMonth: null },
|
||||
data: { endMonth: prevEffMonth },
|
||||
})
|
||||
|
||||
// 创建新部门记录
|
||||
const record = await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.id,
|
||||
oldDepartment,
|
||||
newDepartment,
|
||||
effectiveMonth: effMonth,
|
||||
endMonth: null,
|
||||
changeType: 'TRANSFER',
|
||||
reason: reason || null,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { department: newDepartment },
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调部门历史
|
||||
router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.employeeDepartmentRecord.findMany({
|
||||
where: { employeeId: req.params.id, orgId: req.user!.orgId },
|
||||
orderBy: { effectiveMonth: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod'
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const configFields = {
|
||||
const socialConfigFields = {
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
@@ -17,6 +17,12 @@ const configFields = {
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
maternityOrg: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
const housingConfigFields = {
|
||||
city: z.string().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
@@ -85,7 +91,7 @@ router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, ne
|
||||
|
||||
// 新建版本(年度调基/比例变更)
|
||||
const createVersionSchema = z.object({
|
||||
...configFields,
|
||||
...socialConfigFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
@@ -147,7 +153,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: true, monthlySalary: true },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
@@ -180,21 +186,16 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldSocialBase = emp.socialInsBase ?? monthlyWage
|
||||
const oldHousingBase = emp.housingFundBase ?? monthlyWage
|
||||
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
|
||||
// 建议基数 = 上年月均工资按上下限裁剪
|
||||
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
const suggestedHousingBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldSocialBase,
|
||||
oldHousingBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedSocialBase,
|
||||
suggestedHousingBase,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -209,7 +210,6 @@ const adjustApplySchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newSocialBase: z.number(),
|
||||
newHousingBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -225,19 +225,40 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const { items } = adjustApplySchema.parse(req.body)
|
||||
const adjustMonth = config.effectiveFrom
|
||||
const prevAdjustMonth = (() => {
|
||||
const [y, m] = adjustMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
})()
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
// 裁剪到上下限范围内
|
||||
const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax)
|
||||
const housingBase = Math.min(Math.max(item.newHousingBase, config.baseMin), config.baseMax)
|
||||
|
||||
// 关闭旧社保记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: prevAdjustMonth },
|
||||
})
|
||||
|
||||
// 创建新社保记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base: socialBase,
|
||||
changeType: 'ADJUST',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: {
|
||||
socialInsBase: socialBase,
|
||||
housingFundBase: housingBase,
|
||||
},
|
||||
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
@@ -296,11 +317,8 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg + housingOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp + housingEmp
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
@@ -317,7 +335,6 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
|
||||
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
|
||||
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
|
||||
{ name: '住房公积金', orgRate: config.housingOrg, empRate: config.housingEmp, orgAmount: housingOrg, empAmount: housingEmp },
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
@@ -329,4 +346,431 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 公积金配置 ==========
|
||||
|
||||
// 获取当前公积金配置
|
||||
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金配置版本列表
|
||||
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const versions = await prisma.housingFundConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建公积金配置版本
|
||||
const createHousingVersionSchema = z.object({
|
||||
...housingConfigFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createHousingVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const existing = await prisma.housingFundConfig.findUnique({
|
||||
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
|
||||
}
|
||||
|
||||
const prevCurrent = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
if (prevCurrent) {
|
||||
const [year, mon] = data.effectiveFrom.split('-').map(Number)
|
||||
const prevMonth = mon === 1
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(mon - 1).padStart(2, '0')}`
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id: prevCurrent.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
const version = await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId,
|
||||
...data,
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: version })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金计算
|
||||
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
if (month) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
configVersion: config.effectiveFrom,
|
||||
housingOrg,
|
||||
housingEmp,
|
||||
total: housingOrg + housingEmp,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金调基预览
|
||||
router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const lastYearStart = `${now.getFullYear() - 1}-01`
|
||||
const lastYearEnd = `${now.getFullYear() - 1}-12`
|
||||
|
||||
const lastYearPayslips = await prisma.payslip.findMany({
|
||||
where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } },
|
||||
select: { employeeId: true, totalPay: true },
|
||||
})
|
||||
|
||||
const empPayslipMap = new Map<string, number[]>()
|
||||
for (const p of lastYearPayslips) {
|
||||
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
|
||||
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
|
||||
}
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldBase = emp.housingFundBase ?? monthlyWage
|
||||
const payslips = empPayslipMap.get(emp.id)
|
||||
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
|
||||
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedBase,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行公积金调基
|
||||
const adjustHousingSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const { items } = adjustHousingSchema.parse(req.body)
|
||||
const adjustMonth = config.effectiveFrom
|
||||
const prevAdjustMonth = (() => {
|
||||
const [y, m] = adjustMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
})()
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
|
||||
|
||||
// 关闭旧记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: prevAdjustMonth },
|
||||
})
|
||||
|
||||
// 创建新记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base,
|
||||
changeType: 'ADJUST',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 月度增减员 ==========
|
||||
|
||||
// 社保月度增减员
|
||||
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 增员:startMonth == month
|
||||
const additions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, startMonth: month },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// 减员:endMonth == month 且 changeType == TERMINATION
|
||||
const reductions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
additions: additions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
reductions: reductions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金月度增减员
|
||||
router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const additions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, startMonth: month },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const reductions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
additions: additions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
reductions: reductions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 在职申报 ==========
|
||||
|
||||
// 社保在保人员
|
||||
router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const records = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
items: records.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金在保人员
|
||||
router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const records = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
items: records.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -6,6 +6,18 @@ function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function getContractStatus(contract: {
|
||||
signDate: Date | null
|
||||
startDate: Date
|
||||
@@ -155,12 +167,20 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const hireDate = new Date(data.hireDate)
|
||||
const hireMonth = dateToMonth(hireDate)
|
||||
const salaryNum = Number(data.monthlySalary) || 0
|
||||
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: data.name,
|
||||
department: data.department,
|
||||
hireDate: new Date(data.hireDate),
|
||||
hireDate,
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
gender: data.gender,
|
||||
phone: data.phone,
|
||||
@@ -168,6 +188,65 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
isPregnant: data.isPregnant || false,
|
||||
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
||||
isWorkInjured: data.isWorkInjured || false,
|
||||
socialInsBase,
|
||||
housingFundBase,
|
||||
socialInsStartMonth,
|
||||
housingFundStartMonth,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
startMonth: socialInsStartMonth,
|
||||
endMonth: null,
|
||||
base: socialInsBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
startMonth: housingFundStartMonth,
|
||||
endMonth: null,
|
||||
base: housingFundBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建初始薪资变更记录
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
oldSalary: 0,
|
||||
newSalary: salaryNum,
|
||||
effectiveDate: hireDate,
|
||||
effectiveMonth: hireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建初始部门记录
|
||||
await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
oldDepartment: '',
|
||||
newDepartment: data.department,
|
||||
effectiveMonth: hireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
@@ -227,6 +306,38 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
|
||||
}
|
||||
|
||||
const newHireMonth = dateToMonth(newHireDate)
|
||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
|
||||
const prevHireMonth = prevMonth(newHireMonth)
|
||||
|
||||
// 关闭旧社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
// 关闭旧公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
// 关闭旧薪资记录
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
// 关闭旧部门记录
|
||||
await prisma.employeeDepartmentRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -236,6 +347,67 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
isPregnant: false,
|
||||
isInMedicalPeriod: false,
|
||||
isWorkInjured: false,
|
||||
socialInsBase,
|
||||
housingFundBase,
|
||||
socialInsStartMonth,
|
||||
socialInsEndMonth: null,
|
||||
housingFundStartMonth,
|
||||
housingFundEndMonth: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
startMonth: socialInsStartMonth,
|
||||
endMonth: null,
|
||||
base: socialInsBase,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
startMonth: housingFundStartMonth,
|
||||
endMonth: null,
|
||||
base: housingFundBase,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新薪资记录
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldSalary: salaryNum,
|
||||
newSalary: salaryNum,
|
||||
effectiveDate: newHireDate,
|
||||
effectiveMonth: newHireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新部门记录
|
||||
await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldDepartment: employee.department,
|
||||
newDepartment: data.department || employee.department,
|
||||
effectiveMonth: newHireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -287,13 +459,23 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
// 记录薪资变更
|
||||
if (oldSalary !== newSalary) {
|
||||
const now = new Date()
|
||||
const nowMonth = dateToMonth(now)
|
||||
// 关闭之前有效记录
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevMonth(nowMonth) },
|
||||
})
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldSalary,
|
||||
newSalary,
|
||||
effectiveDate: new Date(),
|
||||
effectiveDate: now,
|
||||
effectiveMonth: nowMonth,
|
||||
endMonth: null,
|
||||
changeType: 'SALARY_CHANGE',
|
||||
reason: data.salaryChangeReason || '手动调整',
|
||||
createdBy: '',
|
||||
},
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function calcBatchEntry(
|
||||
batchType: string = 'REGULAR',
|
||||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
|
||||
) {
|
||||
const [employee, socialConfig] = await Promise.all([
|
||||
const [employee, socialConfig, housingConfig] = await Promise.all([
|
||||
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
@@ -127,6 +127,14 @@ export async function calcBatchEntry(
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
@@ -136,13 +144,15 @@ export async function calcBatchEntry(
|
||||
|
||||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
||||
|
||||
// 年终奖/奖金批次:不扣社保公积金
|
||||
if (batchType !== 'BONUS' && !options?.skipSocial) {
|
||||
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
|
||||
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
|
||||
if (socialConfig) {
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
const housing = calcHousingFund(housingBase, socialConfig)
|
||||
socialEmp = social.socialEmp
|
||||
socialOrg = social.socialOrg
|
||||
}
|
||||
if (housingConfig) {
|
||||
const housing = calcHousingFund(housingBase, housingConfig)
|
||||
housingEmp = housing.housingEmp
|
||||
housingOrg = housing.housingOrg
|
||||
}
|
||||
@@ -164,7 +174,7 @@ export async function calcBatchEntry(
|
||||
// 年终奖单独计税
|
||||
tax = calcBonusTax(inputs.bonus)
|
||||
} else {
|
||||
// 累计预扣法
|
||||
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
|
||||
const year = month.slice(0, 4)
|
||||
const prevPayslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
|
||||
@@ -282,7 +282,7 @@ export async function getDashboardData(orgId: string) {
|
||||
|
||||
const [
|
||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
||||
overtimeRecords, payslips, batchEntries, socialConfig,
|
||||
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
|
||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||
monthSeverancePay,
|
||||
] = await Promise.all([
|
||||
@@ -315,6 +315,7 @@ export async function getDashboardData(orgId: string) {
|
||||
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.findFirst({ where: { orgId, isCurrent: true } }),
|
||||
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
||||
prisma.laborContract.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
@@ -408,8 +409,8 @@ export async function getDashboardData(orgId: string) {
|
||||
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
|
||||
socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount
|
||||
housingOrgTotal = avgBase * socialConfig.housingOrg / 100 * employeeCount
|
||||
housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount
|
||||
housingOrgTotal = avgBase * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount
|
||||
housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount
|
||||
}
|
||||
|
||||
// 个税:优先用归档批次的实际计算值,否则估算
|
||||
|
||||
@@ -6,6 +6,12 @@ function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
export interface ChecklistItem {
|
||||
key: string
|
||||
label: string
|
||||
@@ -152,14 +158,21 @@ export async function createTermination(orgId: string, userId: string, data: any
|
||||
|
||||
const { level } = assessRisk(employee, data.reason)
|
||||
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const termMonth = dateToMonth(termDate)
|
||||
const socialInsEndMonth = data.socialInsEndMonth || termMonth
|
||||
const housingFundEndMonth = data.housingFundEndMonth || termMonth
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: 'TERMINATION',
|
||||
reason: data.reason,
|
||||
terminationDate: new Date(data.terminationDate),
|
||||
terminationDate: termDate,
|
||||
compensation: data.compensation || 0,
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
riskLevel: level,
|
||||
checklist: data.checklist || {},
|
||||
remark: data.remark,
|
||||
@@ -167,15 +180,30 @@ export async function createTermination(orgId: string, userId: string, data: any
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭社保缴费记录(设置 endMonth)
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 关闭公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 根据解聘日期判断在职/离职状态
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = termDate <= today
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: data.employeeId },
|
||||
data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' },
|
||||
data: {
|
||||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.riskItem.updateMany({
|
||||
@@ -202,15 +230,22 @@ export async function createResignation(orgId: string, userId: string, data: any
|
||||
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
|
||||
}
|
||||
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const termMonth = dateToMonth(termDate)
|
||||
const socialInsEndMonth = data.socialInsEndMonth || termMonth
|
||||
const housingFundEndMonth = data.housingFundEndMonth || termMonth
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: 'RESIGNATION',
|
||||
reason: 'RESIGNATION',
|
||||
terminationDate: new Date(data.terminationDate),
|
||||
terminationDate: termDate,
|
||||
resignationReason: data.resignationReason || null,
|
||||
compensation: 0,
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
riskLevel: 'SAFE',
|
||||
checklist: {},
|
||||
remark: data.remark || null,
|
||||
@@ -218,15 +253,30 @@ export async function createResignation(orgId: string, userId: string, data: any
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 关闭公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 根据离职日期判断在职/离职状态
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = termDate <= today
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: data.employeeId },
|
||||
data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' },
|
||||
data: {
|
||||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.riskItem.updateMany({
|
||||
|
||||
Reference in New Issue
Block a user