From 8bcf1460cf39886850d483dfd349167a504f1367 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Wed, 19 Aug 2026 08:23:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P1=20=E8=96=AA=E9=85=AC=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E9=A9=B1=E5=8A=A8=E8=AE=A1=E7=AE=97=20-=20=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E5=BC=95=E6=93=8E=20+=20totalPay/netPay=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 formula-engine.ts: 公式解析、依赖提取、拓扑排序 - PayslipItem 增加 calcType(FORMULA/SYSTEM) 和 dependencies 字段 - DEFAULT_ITEMS 补充 calcType 和 dependencies - ensureDefaultTemplate 增加已有组织新字段迁移逻辑 - calcBatchEntry: totalPay 从模板公式计算(回退硬编码) - calcBatchEntry: netPay 从模板公式计算(回退硬编码) --- backend/prisma/schema.prisma | 4 +- backend/src/services/formula-engine.ts | 117 ++++++++++++++++++++++++ backend/src/services/payroll.service.ts | 114 ++++++++++++++++------- 3 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 backend/src/services/formula-engine.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9c96a7f..b053b21 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) diff --git a/backend/src/services/formula-engine.ts b/backend/src/services/formula-engine.ts new file mode 100644 index 0000000..26fe764 --- /dev/null +++ b/backend/src/services/formula-engine.ts @@ -0,0 +1,117 @@ +/** + * 公式解析引擎 + * 支持运算符: + - * / ( ) 以及 min(), max(), round() 函数 + * 变量名规则: 字母开头,字母数字下划线组合 + */ + +/** + * 从公式中提取依赖的变量名 + */ +export function extractDependencies(formula: string): string[] { + const vars = new Set() + // 匹配标识符(排除函数名和数字) + 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): 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() + const graph = new Map() + + 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 +} diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index 76c49f7..cc9f31a 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -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 = { + 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 = { + totalPay, + socialEmp, + housingEmp, + tax, + } + netPay = evalFormula(netPayItem.formula, formulaVars) - suppHousingEmp - prevDeferredMinWage + } else { + // 回退到硬编码 + netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage + } // 递延金额(本批次扣不动、递延到次月的部分) let deferredSocialEmp = 0