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:
@@ -966,7 +966,9 @@ model PayslipItem {
|
||||
name String // 显示名称
|
||||
code String // 字段代码
|
||||
type PayslipItemType @default(INPUT)
|
||||
formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction"
|
||||
formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction";SYSTEM 类型存系统函数标识如 SOCIAL_EMP
|
||||
calcType String @default("FORMULA") // FORMULA: 公式解析; SYSTEM: 系统函数计算(仅 CALCULATED 类型有效)
|
||||
dependencies String @default("[]") // JSON数组:该字段依赖哪些其他字段代码
|
||||
order Int @default(0)
|
||||
isDefault Boolean @default(true) // 系统预置项不可删除
|
||||
isEditable Boolean @default(true)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 公式解析引擎
|
||||
* 支持运算符: + - * / ( ) 以及 min(), max(), round() 函数
|
||||
* 变量名规则: 字母开头,字母数字下划线组合
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从公式中提取依赖的变量名
|
||||
*/
|
||||
export function extractDependencies(formula: string): string[] {
|
||||
const vars = new Set<string>()
|
||||
// 匹配标识符(排除函数名和数字)
|
||||
const matches = formula.match(/[a-zA-Z_]\w*/g) || []
|
||||
const builtins = new Set(['min', 'max', 'round', 'Math'])
|
||||
for (const m of matches) {
|
||||
if (!builtins.has(m)) vars.add(m)
|
||||
}
|
||||
return [...vars]
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的公式求值器
|
||||
* 仅支持 +、-、*、/、()、数字、变量名、min()、max()、round()
|
||||
*/
|
||||
export function evalFormula(formula: string, variables: Record<string, number>): number {
|
||||
// 替换变量为值
|
||||
const expr = formula.replace(/[a-zA-Z_]\w*/g, (name) => {
|
||||
if (name === 'min' || name === 'max' || name === 'round') return name
|
||||
if (name in variables) return String(variables[name])
|
||||
// 未知变量视为 0(兼容可选字段未传值的场景)
|
||||
return '0'
|
||||
})
|
||||
|
||||
// 安全校验:只允许数字、运算符、括号、min/max/round
|
||||
if (!/^[\d\s+\-*/().,minaxroud]+$/.test(expr)) {
|
||||
throw new Error(`公式包含非法字符: ${expr}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function('min', 'max', 'round', `return ${expr}`)(Math.min, Math.max, Math.round)
|
||||
return Math.round(result * 100) / 100
|
||||
} catch (e: any) {
|
||||
throw new Error(`公式求值失败: ${formula}, 错误: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拓扑排序:按依赖关系排列计算项
|
||||
* 输入项在前,计算项按依赖顺序排列
|
||||
*/
|
||||
export function topologicalSort(items: { code: string; type: string; calcType?: string; formula?: string | null; dependencies?: string }[]): typeof items {
|
||||
const inDegree = new Map<string, number>()
|
||||
const graph = new Map<string, string[]>()
|
||||
|
||||
for (const item of items) {
|
||||
inDegree.set(item.code, 0)
|
||||
graph.set(item.code, [])
|
||||
}
|
||||
|
||||
// 解析依赖,构建 DAG
|
||||
for (const item of items) {
|
||||
let deps: string[] = []
|
||||
if (item.dependencies) {
|
||||
try { deps = JSON.parse(item.dependencies) } catch { deps = [] }
|
||||
} else if (item.formula && item.type === 'CALCULATED' && item.calcType !== 'SYSTEM') {
|
||||
// 如果 dependencies 未设置,从公式中自动提取
|
||||
deps = extractDependencies(item.formula)
|
||||
}
|
||||
for (const dep of deps) {
|
||||
if (graph.has(dep)) {
|
||||
graph.get(dep)!.push(item.code)
|
||||
inDegree.set(item.code, (inDegree.get(item.code) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn 算法
|
||||
const queue: string[] = []
|
||||
for (const [code, degree] of inDegree.entries()) {
|
||||
if (degree === 0) queue.push(code)
|
||||
}
|
||||
|
||||
const sorted: typeof items = []
|
||||
// 先排输入项(保持原顺序)
|
||||
for (const item of items) {
|
||||
if (item.type === 'INPUT' && inDegree.get(item.code) === 0) {
|
||||
sorted.push(item)
|
||||
}
|
||||
}
|
||||
queue.length = 0
|
||||
for (const [code, degree] of inDegree.entries()) {
|
||||
if (degree === 0 && !items.find(i => i.code === code && i.type === 'INPUT')) {
|
||||
queue.push(code)
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length) {
|
||||
const code = queue.shift()!
|
||||
const item = items.find(i => i.code === code)
|
||||
if (item && item.type !== 'INPUT') {
|
||||
sorted.push(item)
|
||||
}
|
||||
for (const next of graph.get(code) || []) {
|
||||
inDegree.set(next, (inDegree.get(next) || 0) - 1)
|
||||
if (inDegree.get(next) === 0) queue.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查循环依赖
|
||||
if (sorted.length < items.length) {
|
||||
const missing = items.filter(i => !sorted.find(s => s.code === i.code))
|
||||
throw new Error(`薪酬模板存在循环依赖,涉及字段: ${missing.map(m => m.code).join(', ')}`)
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
@@ -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