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 // 显示名称
|
name String // 显示名称
|
||||||
code String // 字段代码
|
code String // 字段代码
|
||||||
type PayslipItemType @default(INPUT)
|
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)
|
order Int @default(0)
|
||||||
isDefault Boolean @default(true) // 系统预置项不可删除
|
isDefault Boolean @default(true) // 系统预置项不可删除
|
||||||
isEditable 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 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 }[] = [
|
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, order: 1, isDefault: true, isEditable: true },
|
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 1, isDefault: true, isEditable: true },
|
||||||
{ name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, order: 2, 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, order: 3, 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, order: 4, 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', order: 5, isDefault: true, isEditable: false },
|
{ 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, order: 6, isDefault: true, isEditable: true },
|
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 6, isDefault: true, isEditable: true },
|
||||||
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, order: 7, 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, order: 8, 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, order: 9, 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, order: 10, 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, order: 11, 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, order: 12, 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, order: 13, 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', order: 14, isDefault: true, isEditable: false },
|
{ 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', order: 15, 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', order: 16, 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', order: 17, 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', order: 18, 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) {
|
export async function ensureDefaultTemplate(orgId: string) {
|
||||||
@@ -93,6 +94,14 @@ export async function ensureDefaultTemplate(orgId: string) {
|
|||||||
await prisma.payslipItem.createMany({
|
await prisma.payslipItem.createMany({
|
||||||
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
|
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,7 +334,30 @@ export async function calcBatchEntry(
|
|||||||
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
|
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPay = inputs.baseSalary
|
// 应发合计:从模板公式计算(默认公式与之前硬编码一致)
|
||||||
|
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.positionSalary || 0)
|
||||||
+ (inputs.performanceSalary || 0)
|
+ (inputs.performanceSalary || 0)
|
||||||
+ (inputs.senioritySalary || 0)
|
+ (inputs.senioritySalary || 0)
|
||||||
@@ -338,6 +370,7 @@ export async function calcBatchEntry(
|
|||||||
+ inputs.bonus
|
+ inputs.bonus
|
||||||
- inputs.deduction
|
- inputs.deduction
|
||||||
- (inputs.otherDeduction || 0)
|
- (inputs.otherDeduction || 0)
|
||||||
|
}
|
||||||
|
|
||||||
// 个税计算
|
// 个税计算
|
||||||
let tax = 0
|
let tax = 0
|
||||||
@@ -411,8 +444,21 @@ export async function calcBatchEntry(
|
|||||||
// 上月递延的最低工资补齐差额(本批次需扣回)
|
// 上月递延的最低工资补齐差额(本批次需扣回)
|
||||||
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
|
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
|
let deferredSocialEmp = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user