feat: Phase 1-3 优化全部完成
Phase 1 紧急修复(8项): - 社保城市选择改为可输入 - 社保上下限拆分(三险/医保独立基数) - 公积金试算结果展示修复 - 花名册合同保存修复(日期ISO格式) - 薪酬批次创建失败修复(城市过滤+错误处理) - 证据链查看修复 - 个税计算修复(blank_employees读取基本工资) - 加班费倍率读取配置 Phase 2 功能完善(3项): - 批量导入per-row异常捕获+导入按钮 - 单人发薪UI入口优化 - 解除协议模板补充(员工提出离职版) Phase 3 后期规划(4项): - 工资表导入功能(POST /import/payroll + 前端入口) - 大病险/长护险附加险种(extraInsurances JSON + 计算适配) - 专项附加扣除按月录入(SpecialDeductionRecord模型 + 前端Tab) - 预置河北省社保政策(seed数据)
This commit is contained in:
@@ -19,6 +19,9 @@ const socialConfigFields = {
|
||||
maternityOrg: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
medicalBaseMin: z.number().optional(),
|
||||
medicalBaseMax: z.number().optional(),
|
||||
extraInsurances: z.any().optional(),
|
||||
}
|
||||
|
||||
const housingConfigFields = {
|
||||
@@ -408,23 +411,49 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = actualBase * config.medicalOrg / 100
|
||||
const medicalEmp = actualBase * config.medicalEmp / 100
|
||||
const medicalOrg = medicalBase * config.medicalOrg / 100
|
||||
const medicalEmp = medicalBase * config.medicalEmp / 100
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
const maternityOrg = medicalBase * config.maternityOrg / 100
|
||||
let totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
let totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
|
||||
// 附加险种
|
||||
const extraItems: any[] = []
|
||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||||
for (const ins of config.extraInsurances) {
|
||||
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
|
||||
if (ins.baseType === 'fixed' && ins.fixedAmount) {
|
||||
const orgAmt = ins.fixedAmount
|
||||
const empAmt = ins.empFixedAmount || 0
|
||||
totalOrg += orgAmt
|
||||
totalEmp += empAmt
|
||||
extraItems.push({ name: ins.name, orgRate: 0, empRate: 0, orgAmount: orgAmt, empAmount: empAmt })
|
||||
} else {
|
||||
const orgAmt = insBase * (ins.orgRate || 0) / 100
|
||||
const empAmt = insBase * (ins.empRate || 0) / 100
|
||||
totalOrg += orgAmt
|
||||
totalEmp += empAmt
|
||||
extraItems.push({ name: ins.name, orgRate: ins.orgRate || 0, empRate: ins.empRate || 0, orgAmount: orgAmt, empAmount: empAmt })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
medicalBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
@@ -435,6 +464,7 @@ 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 },
|
||||
...extraItems,
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
@@ -790,16 +820,30 @@ router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res
|
||||
/** 根据基数和社保配置计算各项企业/个人缴费明细 */
|
||||
function calcSocialDetail(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||
const items = [
|
||||
{ name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 },
|
||||
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: actualBase * config.medicalOrg / 100, empAmount: actualBase * config.medicalEmp / 100 },
|
||||
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalBase * config.medicalOrg / 100, empAmount: medicalBase * config.medicalEmp / 100 },
|
||||
{ name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 },
|
||||
{ name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 },
|
||||
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: actualBase * config.maternityOrg / 100, empAmount: 0 },
|
||||
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 },
|
||||
]
|
||||
// 附加险种
|
||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||||
for (const ins of config.extraInsurances) {
|
||||
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
|
||||
if (ins.baseType === 'fixed' && ins.fixedAmount) {
|
||||
items.push({ name: ins.name, orgRate: 0, empRate: 0, orgAmount: ins.fixedAmount, empAmount: ins.empFixedAmount || 0 })
|
||||
} else {
|
||||
items.push({ name: ins.name, orgRate: ins.orgRate || 0, empRate: ins.empRate || 0, orgAmount: insBase * (ins.orgRate || 0) / 100, empAmount: insBase * (ins.empRate || 0) / 100 })
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalOrg = items.reduce((s, i) => s + i.orgAmount, 0)
|
||||
const totalEmp = items.reduce((s, i) => s + i.empAmount, 0)
|
||||
return { actualBase, items, totalOrg, totalEmp }
|
||||
return { actualBase, medicalBase, items, totalOrg, totalEmp }
|
||||
}
|
||||
|
||||
/** 根据基数和公积金配置计算企业/个人缴费明细 */
|
||||
@@ -1274,4 +1318,139 @@ router.put('/records/housing/:id/correct', async (req: AuthRequest, res: Respons
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 专项附加扣除按月录入 ==========
|
||||
|
||||
// 查询员工某月专项附加扣除
|
||||
router.get('/special-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month } = req.query
|
||||
if (!employeeId || !month) return res.status(400).json({ success: false, message: '缺少 employeeId 或 month' })
|
||||
const record = await prisma.specialDeductionRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: employeeId as string, month: month as string } },
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量查询员工某月专项附加扣除
|
||||
router.get('/special-deduction/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.query
|
||||
if (!month) return res.status(400).json({ success: false, message: '缺少 month' })
|
||||
const records = await prisma.specialDeductionRecord.findMany({
|
||||
where: { orgId: req.user!.orgId, month: month as string },
|
||||
include: { employee: { select: { name: true, department: true } } },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建/更新专项附加扣除
|
||||
const upsertDeductionSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
amount: z.number().min(0).optional(),
|
||||
children: z.number().min(0).optional(),
|
||||
elderly: z.number().min(0).optional(),
|
||||
housing: z.number().min(0).optional(),
|
||||
education: z.number().min(0).optional(),
|
||||
infant: z.number().min(0).optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/special-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = upsertDeductionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
const amount = data.amount ?? (data.children || 0) + (data.elderly || 0) + (data.housing || 0) + (data.education || 0) + (data.infant || 0)
|
||||
|
||||
const record = await prisma.specialDeductionRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
amount,
|
||||
children: data.children || 0,
|
||||
elderly: data.elderly || 0,
|
||||
housing: data.housing || 0,
|
||||
education: data.education || 0,
|
||||
infant: data.infant || 0,
|
||||
remark: data.remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
amount,
|
||||
children: data.children || 0,
|
||||
elderly: data.elderly || 0,
|
||||
housing: data.housing || 0,
|
||||
education: data.education || 0,
|
||||
infant: data.infant || 0,
|
||||
remark: data.remark,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步员工便捷字段
|
||||
await prisma.employee.update({ where: { id: data.employeeId }, data: { specialDeduction: amount } })
|
||||
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量录入专项附加扣除
|
||||
router.post('/special-deduction/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, items } = req.body as { month: string; items: any[] }
|
||||
if (!month || !items || !Array.isArray(items)) return res.status(400).json({ success: false, message: '缺少 month 或 items' })
|
||||
const orgId = req.user!.orgId
|
||||
const result = { total: items.length, updated: 0, errors: [] as string[] }
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
try {
|
||||
const amount = item.amount ?? (item.children || 0) + (item.elderly || 0) + (item.housing || 0) + (item.education || 0) + (item.infant || 0)
|
||||
await prisma.specialDeductionRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: item.employeeId, month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
month,
|
||||
amount,
|
||||
children: item.children || 0,
|
||||
elderly: item.elderly || 0,
|
||||
housing: item.housing || 0,
|
||||
education: item.education || 0,
|
||||
infant: item.infant || 0,
|
||||
remark: item.remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
amount,
|
||||
children: item.children || 0,
|
||||
elderly: item.elderly || 0,
|
||||
housing: item.housing || 0,
|
||||
education: item.education || 0,
|
||||
infant: item.infant || 0,
|
||||
remark: item.remark,
|
||||
},
|
||||
})
|
||||
await prisma.employee.update({ where: { id: item.employeeId }, data: { specialDeduction: amount } })
|
||||
result.updated++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`第${i + 1}行:${e?.message || '录入失败'}`)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user