feat: P2+P3 薪酬模板驱动计算 - SYSTEM注册表 + 前端动态化

P2: 社保/公积金/个税改为 SYSTEM 注册表驱动
- 新增 SystemCalcContext 和 systemCalculators 注册表
- ensureSocialHousingCalculated 缓存社保公积金计算
- calcTaxSystem 提取个税计算为系统函数
- calcBatchEntry 重构为模板拓扑排序调度

P3: 前端批次表格动态化 + 模板编辑器增强
- 后端模板 CRUD 支持 calcType/dependencies 字段
- TemplateTab 表格增加计算方式列
- TemplateTab 编辑表单增加 calcType 选择(公式/系统)
- BatchTab editableFields 从模板 isEditable 动态获取
This commit is contained in:
freedakgmail
2026-08-19 08:32:57 +08:00
parent 8bcf1460cf
commit 60951e7fd6
4 changed files with 302 additions and 197 deletions
+233 -182
View File
@@ -205,6 +205,177 @@ export function calcBonusTax(bonusAmount: number): number {
return Math.max(0, Math.round(tax * 100) / 100)
}
// ========== 系统计算器注册表 ==========
interface SystemCalcContext {
orgId: string
employeeId: string
month: string
batchType: string
employee: any
socialConfig: any
housingConfig: any
supplementaryHousingConfig: any
socialBase: number
housingBase: number
isNoSocialContract: boolean
options: any
values: Record<string, number>
// Side-effect outputs (org-side and supplementary amounts not in template)
socialOrg: number
housingOrg: number
supplementaryHousingEmp: number
supplementaryHousingOrg: number
taxBreakdown: any
// System values (before override)
systemSocialEmp: number
systemSocialOrg: number
systemHousingEmp: number
systemHousingOrg: number
systemSuppHousingEmp: number
systemSuppHousingOrg: number
// Internal cache flag
_socialCalculated: boolean
}
type SystemCalcFn = (ctx: SystemCalcContext) => Promise<number>
/**
* 社保+公积金联合计算(缓存结果,避免重复查询数据库)
*/
async function ensureSocialHousingCalculated(ctx: SystemCalcContext) {
if (ctx._socialCalculated) return
ctx._socialCalculated = true
const { orgId, employeeId, month, batchType, socialConfig, housingConfig, supplementaryHousingConfig, socialBase, housingBase, isNoSocialContract, options } = ctx
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
let suppHousingEmp = 0, suppHousingOrg = 0
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) {
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
let fullSuppHousingEmp = 0, fullSuppHousingOrg = 0
if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig)
fullSocialEmp = social.socialEmp
fullSocialOrg = social.socialOrg
}
if (housingConfig) {
const housing = calcHousingFund(housingBase, housingConfig)
fullHousingEmp = housing.housingEmp
fullHousingOrg = housing.housingOrg
}
if (supplementaryHousingConfig) {
const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig)
fullSuppHousingEmp = suppHousing.housingEmp
fullSuppHousingOrg = suppHousing.housingOrg
}
const archivedEntries = await prisma.batchEntry.findMany({
where: { orgId, employeeId, batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } } },
select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, supplementaryHousingEmp: true, supplementaryHousingOrg: true },
})
const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0)
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0)
const deductedSuppHousingEmp = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingEmp || 0), 0)
const deductedSuppHousingOrg = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingOrg || 0), 0)
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp)
suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg)
if (options?.prevDeferred) {
socialEmp += options.prevDeferred.socialEmp || 0
housingEmp += options.prevDeferred.housingEmp || 0
}
}
ctx.values.socialEmp = socialEmp
ctx.values.housingEmp = housingEmp
ctx.socialOrg = socialOrg
ctx.housingOrg = housingOrg
ctx.supplementaryHousingEmp = suppHousingEmp
ctx.supplementaryHousingOrg = suppHousingOrg
ctx.systemSocialEmp = socialEmp
ctx.systemSocialOrg = socialOrg
ctx.systemHousingEmp = housingEmp
ctx.systemHousingOrg = housingOrg
ctx.systemSuppHousingEmp = suppHousingEmp
ctx.systemSuppHousingOrg = suppHousingOrg
}
/**
* 个税系统计算函数
*/
async function calcTaxSystem(ctx: SystemCalcContext): Promise<number> {
const { orgId, employeeId, month, batchType, employee, values } = ctx
const totalPay = values.totalPay || 0
const socialEmp = values.socialEmp || 0
const housingEmp = values.housingEmp || 0
const suppHousingEmp = ctx.supplementaryHousingEmp
if (batchType === 'BONUS') {
const tax = calcBonusTax(values.bonus || 0)
ctx.taxBreakdown = { method: '单独计税(年终奖)', bonus: values.bonus || 0, tax }
return tax
}
const year = month.slice(0, 4)
const archivedEntries = await prisma.batchEntry.findMany({
where: { orgId, employeeId, batch: { month: { startsWith: year }, status: 'ARCHIVED' } },
select: { totalPay: true, socialEmp: true, housingEmp: true, supplementaryHousingEmp: true, tax: true },
})
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp
const deductionRecords = await prisma.specialDeductionRecord.findMany({
where: { orgId, employeeId, month: { startsWith: year, lte: month } },
select: { amount: true, month: true },
})
let ytdSpecialDeduction: number
if (deductionRecords.length > 0) {
ytdSpecialDeduction = deductionRecords.reduce((s, r) => s + r.amount, 0)
} else {
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
}
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
const deductionAmount = 5000 * Number(month.slice(5, 7))
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
const tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
ctx.taxBreakdown = {
method: '累计预扣法',
month: Number(month.slice(5, 7)),
ytdIncome, deductionAmount, ytdSocialEmp, ytdHousingEmp, ytdSpecialDeduction,
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
specialDeductionRecords: deductionRecords.length,
ytdTaxableIncome, ytdTaxDeducted, currentMonthTax: tax, archivedCount: archivedEntries.length,
}
return tax
}
/**
* 系统计算器注册表
* key 对应 PayslipItem.formula 中的 SYSTEM 标识
*/
const systemCalculators: Record<string, SystemCalcFn> = {
SOCIAL_EMP: async (ctx) => {
await ensureSocialHousingCalculated(ctx)
return ctx.values.socialEmp
},
HOUSING_EMP: async (ctx) => {
await ensureSocialHousingCalculated(ctx)
return ctx.values.housingEmp
},
TAX: calcTaxSystem,
}
// ========== 批次计算 ==========
export async function calcBatchEntry(
@@ -261,179 +432,72 @@ export async function calcBatchEntry(
const socialBase = isNoSocialContract ? 0 : (employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary)
const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary)
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
let suppHousingEmp = 0, suppHousingOrg = 0
// 年终奖/奖金批次、补偿金批次、劳务协议/实习协议人员:不扣社保公积金
// 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) {
// 1. 计算当月应缴社保公积金全额
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
let fullSuppHousingEmp = 0, fullSuppHousingOrg = 0
if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig)
fullSocialEmp = social.socialEmp
fullSocialOrg = social.socialOrg
}
if (housingConfig) {
const housing = calcHousingFund(housingBase, housingConfig)
fullHousingEmp = housing.housingEmp
fullHousingOrg = housing.housingOrg
}
// 补充公积金(用同样的公积金基数)
if (supplementaryHousingConfig) {
const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig)
fullSuppHousingEmp = suppHousing.housingEmp
fullSuppHousingOrg = suppHousing.housingOrg
}
// 2. 查询该员工当月已归档批次中已扣的社保公积金金额
const archivedEntries = await prisma.batchEntry.findMany({
where: {
orgId,
employeeId,
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
},
select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, supplementaryHousingEmp: true, supplementaryHousingOrg: true },
})
const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0)
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0)
const deductedSuppHousingEmp = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingEmp || 0), 0)
const deductedSuppHousingOrg = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingOrg || 0), 0)
// 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0)
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp)
suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg)
// 4. 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
if (options?.prevDeferred) {
socialEmp += options.prevDeferred.socialEmp || 0
housingEmp += options.prevDeferred.housingEmp || 0
}
}
// 保存系统计算值(覆盖前,含递延)
const systemSocialEmp = socialEmp
const systemSocialOrg = socialOrg
const systemHousingEmp = housingEmp
const systemHousingOrg = housingOrg
const systemSupplementaryHousingEmp = suppHousingEmp
const systemSupplementaryHousingOrg = suppHousingOrg
// 手动覆盖社保值
if (options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg
if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
}
// 应发合计:从模板公式计算(默认公式与之前硬编码一致)
// ── 模板驱动计算 ──
const templateItems = await getTemplate(orgId)
const totalPayItem = templateItems.find(i => i.code === 'totalPay')
let totalPay: number
if (totalPayItem && totalPayItem.formula) {
const formulaVars: Record<string, number> = {
baseSalary: inputs.baseSalary,
positionSalary: inputs.positionSalary || 0,
performanceSalary: inputs.performanceSalary || 0,
senioritySalary: inputs.senioritySalary || 0,
overtimePay: inputs.overtimePay,
transportAllowance: inputs.transportAllowance || 0,
mealAllowance: inputs.mealAllowance || 0,
housingAllowance: inputs.housingAllowance || 0,
communicationAllowance: inputs.communicationAllowance || 0,
allowance: inputs.allowance,
bonus: inputs.bonus,
deduction: inputs.deduction,
otherDeduction: inputs.otherDeduction || 0,
}
totalPay = evalFormula(totalPayItem.formula, formulaVars)
} else {
// 回退到硬编码(兼容模板未配置的情况)
totalPay = inputs.baseSalary
+ (inputs.positionSalary || 0)
+ (inputs.performanceSalary || 0)
+ (inputs.senioritySalary || 0)
+ inputs.overtimePay
+ (inputs.transportAllowance || 0)
+ (inputs.mealAllowance || 0)
+ (inputs.housingAllowance || 0)
+ (inputs.communicationAllowance || 0)
+ inputs.allowance
+ inputs.bonus
- inputs.deduction
- (inputs.otherDeduction || 0)
// 构建系统计算上下文
const ctx: SystemCalcContext = {
orgId, employeeId, month, batchType, employee,
socialConfig, housingConfig, supplementaryHousingConfig,
socialBase, housingBase, isNoSocialContract, options,
values: {},
socialOrg: 0, housingOrg: 0,
supplementaryHousingEmp: 0, supplementaryHousingOrg: 0,
taxBreakdown: null,
systemSocialEmp: 0, systemSocialOrg: 0,
systemHousingEmp: 0, systemHousingOrg: 0,
systemSuppHousingEmp: 0, systemSuppHousingOrg: 0,
_socialCalculated: false,
}
// 个税计算
let tax = 0
let taxBreakdown: any = null
if (batchType === 'BONUS') {
// 年终奖单独计税
tax = calcBonusTax(inputs.bonus)
taxBreakdown = { method: '单独计税(年终奖)', bonus: inputs.bonus, tax }
} else {
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
const year = month.slice(0, 4)
// 从已归档批次的 BatchEntry 获取当年历史数据(不依赖工资条是否已生成)
const archivedEntries = await prisma.batchEntry.findMany({
where: {
orgId,
employeeId,
batch: {
month: { startsWith: year },
status: 'ARCHIVED',
},
},
select: { totalPay: true, socialEmp: true, housingEmp: true, supplementaryHousingEmp: true, tax: true },
})
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp
// 专项附加扣除:按月读取 SpecialDeductionRecord 实际填报金额累加
// 优先使用按月记录;若无按月记录,回退到员工便捷字段 × 月数(兼容旧数据)
const deductionRecords = await prisma.specialDeductionRecord.findMany({
where: { orgId, employeeId, month: { startsWith: year, lte: month } },
select: { amount: true, month: true },
})
let ytdSpecialDeduction: number
if (deductionRecords.length > 0) {
// 按月实际填报金额累加
ytdSpecialDeduction = deductionRecords.reduce((s, r) => s + r.amount, 0)
} else {
// 回退:员工便捷字段 × 月数(兼容未填报按月记录的旧数据)
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
}
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
const deductionAmount = 5000 * Number(month.slice(5, 7))
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
taxBreakdown = {
method: '累计预扣法',
month: Number(month.slice(5, 7)),
ytdIncome,
deductionAmount,
ytdSocialEmp,
ytdHousingEmp,
ytdSpecialDeduction,
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
specialDeductionRecords: deductionRecords.length,
ytdTaxableIncome,
ytdTaxDeducted,
currentMonthTax: tax,
archivedCount: archivedEntries.length,
// 1. 初始化输入项
for (const item of templateItems) {
if (item.type === 'INPUT') {
ctx.values[item.code] = (inputs as any)[item.code] || 0
}
}
// 2. 按拓扑排序计算所有项
const sorted = topologicalSort(templateItems)
for (const item of sorted) {
if (item.type === 'INPUT') continue
// 在计算 tax 前应用手动覆盖(覆盖社保值)
if (item.code === 'tax' && options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) ctx.values.socialEmp = options.overrideSocial.socialEmp
if (options.overrideSocial.housingEmp !== undefined) ctx.values.housingEmp = options.overrideSocial.housingEmp
if (options.overrideSocial.socialOrg !== undefined) ctx.socialOrg = options.overrideSocial.socialOrg
if (options.overrideSocial.housingOrg !== undefined) ctx.housingOrg = options.overrideSocial.housingOrg
}
if (item.calcType === 'SYSTEM' && item.formula) {
const fn = systemCalculators[item.formula]
if (fn) {
ctx.values[item.code] = await fn(ctx)
}
} else if (item.formula) {
ctx.values[item.code] = evalFormula(item.formula, ctx.values)
}
}
// 3. 提取计算结果
let socialEmp = ctx.values.socialEmp || 0
let socialOrg = ctx.socialOrg
let housingEmp = ctx.values.housingEmp || 0
let housingOrg = ctx.housingOrg
let suppHousingEmp = ctx.supplementaryHousingEmp
let suppHousingOrg = ctx.supplementaryHousingOrg
const systemSocialEmp = ctx.systemSocialEmp
const systemSocialOrg = ctx.systemSocialOrg
const systemHousingEmp = ctx.systemHousingEmp
const systemHousingOrg = ctx.systemHousingOrg
const systemSupplementaryHousingEmp = ctx.systemSuppHousingEmp
const systemSupplementaryHousingOrg = ctx.systemSuppHousingOrg
let tax = ctx.values.tax || 0
const taxBreakdown = ctx.taxBreakdown
let totalPay = ctx.values.totalPay || 0
let netPay = ctx.values.netPay || 0
// ── 最低工资保护 + 递延扣款逻辑 ──
// 读取最低工资标准(从年度标准或旧配置表)
let minWage = 0
@@ -444,21 +508,8 @@ export async function calcBatchEntry(
// 上月递延的最低工资补齐差额(本批次需扣回)
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
// 初始实发:优先从模板公式计算,再减去补充公积金和递延项(模板公式不含这两个系统内部字段)
const netPayItem = templateItems.find(i => i.code === 'netPay')
let netPay: number
if (netPayItem && netPayItem.formula) {
const formulaVars: Record<string, number> = {
totalPay,
socialEmp,
housingEmp,
tax,
}
netPay = evalFormula(netPayItem.formula, formulaVars) - suppHousingEmp - prevDeferredMinWage
} else {
// 回退到硬编码
netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage
}
// netPay 已由模板公式计算(公式中不含补充公积金和递延项),此处补扣
netPay = netPay - suppHousingEmp - prevDeferredMinWage
// 递延金额(本批次扣不动、递延到次月的部分)
let deferredSocialEmp = 0