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:
freedakgmail
2026-08-19 08:23:51 +08:00
parent 2f9fa8b5df
commit 8bcf1460cf
3 changed files with 200 additions and 35 deletions
+3 -1
View File
@@ -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)
+117
View File
@@ -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
}
+80 -34
View File
@@ -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