feat: P1 薪酬模板驱动计算 - 公式引擎 + totalPay/netPay 模板化
- 新增 formula-engine.ts: 公式解析、依赖提取、拓扑排序 - PayslipItem 增加 calcType(FORMULA/SYSTEM) 和 dependencies 字段 - DEFAULT_ITEMS 补充 calcType 和 dependencies - ensureDefaultTemplate 增加已有组织新字段迁移逻辑 - calcBatchEntry: totalPay 从模板公式计算(回退硬编码) - calcBatchEntry: netPay 从模板公式计算(回退硬编码)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { evalFormula, topologicalSort } from './formula-engine'
|
||||
|
||||
// ========== 社保公积金账户辅助函数 ==========
|
||||
|
||||
@@ -66,25 +67,25 @@ async function getStandardByAccountAndMonth(accountId: string, month: string) {
|
||||
|
||||
// ========== 薪酬模版 ==========
|
||||
|
||||
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: 'positionSalary', type: 'INPUT', formula: null, order: 2, isDefault: true, isEditable: true },
|
||||
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
|
||||
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
|
||||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 5, isDefault: true, isEditable: false },
|
||||
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, order: 6, isDefault: true, isEditable: true },
|
||||
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, order: 7, isDefault: true, isEditable: true },
|
||||
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, order: 8, isDefault: true, isEditable: true },
|
||||
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, order: 9, isDefault: true, isEditable: true },
|
||||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 10, isDefault: true, isEditable: true },
|
||||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 11, isDefault: true, isEditable: true },
|
||||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 12, isDefault: true, isEditable: true },
|
||||
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, order: 13, isDefault: true, isEditable: true },
|
||||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', order: 14, isDefault: true, isEditable: false },
|
||||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 15, isDefault: true, isEditable: false },
|
||||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 16, isDefault: true, isEditable: false },
|
||||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 17, isDefault: true, isEditable: false },
|
||||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 18, isDefault: true, isEditable: false },
|
||||
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; calcType: string; dependencies: string; order: number; isDefault: boolean; isEditable: boolean }[] = [
|
||||
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 1, isDefault: true, isEditable: true },
|
||||
{ name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 2, isDefault: true, isEditable: true },
|
||||
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 3, isDefault: true, isEditable: true },
|
||||
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 4, isDefault: true, isEditable: true },
|
||||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', calcType: 'FORMULA', dependencies: '["weekdayOvertimePay","weekendOvertimePay","holidayOvertimePay"]', order: 5, isDefault: true, isEditable: false },
|
||||
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 6, isDefault: true, isEditable: true },
|
||||
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 7, isDefault: true, isEditable: true },
|
||||
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 8, isDefault: true, isEditable: true },
|
||||
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 9, isDefault: true, isEditable: true },
|
||||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 10, isDefault: true, isEditable: true },
|
||||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 11, isDefault: true, isEditable: true },
|
||||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 12, isDefault: true, isEditable: true },
|
||||
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 13, isDefault: true, isEditable: true },
|
||||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', calcType: 'FORMULA', dependencies: '["baseSalary","positionSalary","performanceSalary","senioritySalary","overtimePay","transportAllowance","mealAllowance","housingAllowance","communicationAllowance","allowance","bonus","deduction","otherDeduction"]', order: 14, isDefault: true, isEditable: false },
|
||||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', calcType: 'SYSTEM', dependencies: '[]', order: 15, isDefault: true, isEditable: false },
|
||||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', calcType: 'SYSTEM', dependencies: '[]', order: 16, isDefault: true, isEditable: false },
|
||||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', calcType: 'SYSTEM', dependencies: '["totalPay","socialEmp","housingEmp"]', order: 17, isDefault: true, isEditable: false },
|
||||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', calcType: 'FORMULA', dependencies: '["totalPay","socialEmp","housingEmp","tax"]', order: 18, isDefault: true, isEditable: false },
|
||||
]
|
||||
|
||||
export async function ensureDefaultTemplate(orgId: string) {
|
||||
@@ -93,6 +94,14 @@ export async function ensureDefaultTemplate(orgId: string) {
|
||||
await prisma.payslipItem.createMany({
|
||||
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
|
||||
})
|
||||
} else {
|
||||
// 迁移:为已有记录补充 calcType 和 dependencies 字段(仅对缺失的预置项)
|
||||
for (const item of DEFAULT_ITEMS) {
|
||||
const existing = await prisma.payslipItem.findFirst({ where: { orgId, code: item.code } })
|
||||
if (existing && (!existing.calcType || existing.calcType === 'FORMULA') && item.calcType === 'SYSTEM') {
|
||||
await prisma.payslipItem.update({ where: { id: existing.id }, data: { calcType: 'SYSTEM', dependencies: item.dependencies } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,19 +334,43 @@ export async function calcBatchEntry(
|
||||
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
|
||||
}
|
||||
|
||||
const 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 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)
|
||||
}
|
||||
|
||||
// 个税计算
|
||||
let tax = 0
|
||||
@@ -411,8 +444,21 @@ export async function calcBatchEntry(
|
||||
// 上月递延的最低工资补齐差额(本批次需扣回)
|
||||
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
|
||||
|
||||
// 初始实发 = 应发 - 社保 - 公积金 - 补充公积金 - 个税 - 上月递延最低工资补扣
|
||||
let netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage
|
||||
// 初始实发:优先从模板公式计算,再减去补充公积金和递延项(模板公式不含这两个系统内部字段)
|
||||
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
|
||||
}
|
||||
|
||||
// 递延金额(本批次扣不动、递延到次月的部分)
|
||||
let deferredSocialEmp = 0
|
||||
|
||||
Reference in New Issue
Block a user