SpringBoot3+Vue3 企业算薪引擎:SalaryCalcContext 驱动规则、公式与数据源解析
🌐文档地址:https://ruoyioffice.com
📦源码1·GitHub:https://github.com/yuqing2026/ruoyi-office
📦源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
📦源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬微信:17156169080(备注「RuoYi Office」)
一句话:算薪引擎要验收的不是「能不能加出总数」,而是每一次试算能不能说清楚每个数字从哪来。RuoYi Office 把档案、岗位、绩效、考勤先装进
SalaryCalcContext,规则只认三种calcType,项目金额按编码回填,整份上下文打成 JSON 快照。
▲ 先装上下文,再算应发,考勤按应出勤日入账,社保个税夹紧基数,最后按方案项目回填并打异常标签
引言:配置页配完了,真正难的是「算这一跳」
薪酬模块很容易被拆成三堆菜单:方案怎么配、个税怎么累计、工资条怎么发。这三件事都重要,但现场真正炸掉的往往是中间那一跳——点「试算」之后,引擎按什么顺序取数、公式引用哪个键、缺勤天数从哪来、算完的数怎么对回项目编码。
| 现场原话 | 引擎必须回答 |
|---|---|
| 「基本工资到底取档案还是取岗位?」 | 规则dataSourceConfig是哪个键 |
| 「绩效没出结果,工资还发不发?」 | 正式结果 → 岗位绩效基数 → 规则固定值,哪一层兜底 |
| 「这个月没打卡,为什么应发被扣光?」 | 有没有考勤规则;没有规则则扣款为 0 |
| 「公式里写了『基本工资*0.1』,为什么是 0?」 | 上下文里这个键当时有没有已经 put 进去 |
方案工作台、累计预扣个税、发薪发条这三件事,已有专文拆过。本文只写试算引擎本身:上下文、三种计算方式、考勤入账、项目回填与快照。
技术栈锚点(2026):Spring Boot 3.5 + Java 17,PC 为 Vue3 方案工作台配置规则,试算入口在薪资批次。
一、SalaryCalcContext 是什么
结论:SalaryCalcContext是一次员工试算的只读工作台,规则、公式、取数全部问它,不允许再去翻 Mapper。
它不是一个独立 Spring Bean,而是SalaryBatchServiceImpl里的内部类。构造时一次性塞进:
| 成员 | 来源 | 试算时干什么 |
|---|---|---|
batch | 薪资批次 | 提供期间年、月 |
archive/version | 员工薪资档案及生效版本 | 固定薪、补贴、社保个税城市 |
employee | 员工档案 | 部门、是否已生成系统用户 |
positionRate | 岗位薪资规则最优匹配 | 岗位工资、绩效基数 |
performanceResult | 已发布且期间相交的绩效结果 | 金额 / 分数 / 系数 |
attendanceSummary | 打卡记录 + 部门考勤配置 | 应出勤、缺勤、迟到早退缺卡 |
amounts | 引擎自己边算边 put | 公式和系统取数都从这张表读 |
键名是中英双语都写一份的。basicSalary和「基本工资」指向同一个数,这样公式既可以写basicSalary*0.1,也可以写基本工资*0.1。
privateclassSalaryCalcContext{privatefinalMap<String,BigDecimal>amounts=newHashMap<>();privateSalaryCalcContext(...,AttendanceSummaryattendanceSummary){putAmount("periodYear",newBigDecimal(batch.getPeriodYear()));putAmount("periodMonth",newBigDecimal(batch.getPeriodMonth()));putAmount("performance.result_score",scoreOrZero(performanceResult));putAmount("performance.result_coefficient",coefOrZero(performanceResult));putAmount("performance.result_amount",amountOrZero(performanceResult));refreshAttendanceAmounts();}privatevoidrefreshAttendanceAmounts(){putAmount("attendance.work_days",attendanceSummary.workDays);putAmount("attendance.absent_days",attendanceSummary.absentDays);putAmount("attendance.absence_deduction",attendanceSummary.absenceDeduct);// 迟到 / 早退 / 缺卡次数同样 put 进表,供公式引用}}顺序约束:应发四项(基本、岗位、绩效、补贴)必须先算完并putAmount,考勤扣款才能用「日薪 = 应发合计 / 应出勤日」。考勤汇总刷新过一次之后,社保个人、公积金、个税再 put。公式如果提前引用还没 put 的键,得到的是0,不是报错——这是设计取舍,试算不能因为一条自定义公式写错就把整批人打挂。
二、三种 calcType:固定值、公式、系统取数
结论:计薪规则只有三种算法。系统取数只认单个键名,加减乘除必须改走公式。
前端工作台把这三种算法展示给 HR:
▲ 深圳研发中心月薪方案:配置完整度六项打勾;「基本工资」计算方式是系统取数,来源说明指向薪资档案固定薪资。高级配置里才改公式或条件
对应后端SalaryRuleDO.calcType:
| calcType | 页面文案 | 求值 |
|---|---|---|
| 1 | 固定值 | rule.fixedValue |
| 2 | 公式计算 | 中缀转后缀,再按上下文取标识符 |
| 3 | 系统取数 | dataSourceConfig单个键 |
条件表达式先于求值:conditionExpression不满足,该项直接0,不再跑公式。上下限、小数位、舍入模式在算出裸值之后再夹。
privateBigDecimalcalculateByRule(SalaryRuleDOrule,SalaryCalcContextcontext,BigDecimalfallback){if(rule==null){returnscale(fallback);}if(!matchesCondition(rule.getConditionExpression(),context)){returnBigDecimal.ZERO;}BigDecimalvalue;if(Objects.equals(rule.getCalcType(),1)){value=defaultValue(rule.getFixedValue());}elseif(Objects.equals(rule.getCalcType(),2)){value=evaluateFormula(rule.getFormulaExpression(),context);}elseif(Objects.equals(rule.getCalcType(),3)){value=resolveDataSource(rule.getDataSourceConfig(),context,fallback);}else{value=resolveDataSource(rule.getDataSourceConfig(),context,fallback);}returnapplyRuleBoundsAndRound(value,rule);}前端有一句硬提示:系统取数不能写archive.fixed_salary+archive.allowance_amount。键名解析是精确匹配字符串,加号会被当成键名的一部分,永远 miss,结果落到 fallback。要「固定薪 + 补贴 − 缺勤」,把calcType改成 2,公式写成archive.fixed_salary+archive.allowance_amount-attendance.absence_deduction。
三、系统取数:白名单键,而不是随意拼 SQL
结论:系统取数是一张白名单。引擎不会按字符串去反射档案对象,更不会拼 SQL。
resolveDataSource认这些键(与工作台下拉SALARY_DATA_SOURCE_OPTIONS对齐):
| 键 | 含义 | miss 时 |
|---|---|---|
archive.fixed_salary | 档案固定薪,兼看转正薪、试用薪 | 0 |
archive.allowance_amount | 档案固定补贴 | 0 |
position.post_salary | 岗位匹配结果 | 岗位未命中则 0 |
performance.base | 正式绩效金额,否则岗位绩效基数 | 0 |
performance.result_amount/result_score/result_coefficient | 已发布绩效 | 无结果则 0 |
attendance.absence_deduction | 已按考勤规则算出的缺勤扣款 | 刷新前是 0 |
认不出的字符串,退回context.amount(source),也就是中间金额表。所以自定义项目可以把键写成基本工资或payableAmount,前提是引擎已经 put 过。
开发库里现有规则绝大多数是calcType=3。公式和固定值是给「这个部门有特殊津贴」留的口子,不是默认路径。
四、公式:中缀转后缀,标识符允许汉字
结论:公式引擎是手写的调度场算法,不是 Groovy、不是 Aviator。标识符允许字母、数字、下划线、点号和汉字。
这是为了让 HR 能写基本工资*0.08,而不必先学英文变量。全角括号会先替换成半角。除数为 0 时结果是 0,不抛异常。
比较运算符挂在条件表达式上:>=、<=、==、!=、>、<左右两边各自再走一遍公式求值。没有运算符时,公式结果非 0 视为条件成立。
privateBigDecimalparseFormulaValue(Stringtoken,SalaryCalcContextcontext){try{returnnewBigDecimal(token);}catch(NumberFormatExceptionignored){returncontext.amount(token);}}privateBigDecimalcalculate(BigDecimalleft,BigDecimalright,charoperator){returnswitch(operator){case'+'->left.add(right);case'-'->left.subtract(right);case'*'->left.multiply(right);case'/'->right.compareTo(BigDecimal.ZERO)==0?BigDecimal.ZERO:left.divide(right,8,RoundingMode.HALF_UP);default->BigDecimal.ZERO;};}边界要讲清楚:没有函数、没有IF()、没有日期。复杂分支用conditionExpression拆成多条规则,而不是在一条公式里写脚本。这和「把 Excel 整张表贴进系统」不是一条路。
五、考勤入账:先数应出勤日,再谈扣款
结论:缺勤天数不是「日历 30 天 − 打卡天数」,是考勤规则引擎逐日判定的应出勤日,减去有进或出记录的工作日。
buildAttendanceSummary做了三件容易被忽略的事:
- 按员工部门取考勤配置,沿部门树向上继承,跟打卡页是同一套
getAttendanceConfigByDeptId。 - 用
isWorkDay逐日数应出勤,接入大小周、节假日、夏冬令时;休息日加班打卡不计入出勤天数。 - 员工还没生成系统用户,汇总标记
available=false,后面扣款直接为 0,并打异常标签,而不是按全月缺勤把应发扣光。
扣款公式在AttendanceSummary.calculateDeduct。方案如果没配考勤规则,扣款为 0——这是有意的,避免「规则没配等于按默认比例扣光」。
▲ 深圳研发考勤扣款规则:缺勤按日薪比例 1 扣,迟到/早退每次 20,缺卡每次 50。这些数进入试算上下文的attendance.absence_deduction
privatevoidcalculateDeduct(BigDecimalpayableBase,SalaryAttendanceRuleDOattendanceRule){if(!available||attendanceRule==null){absenceDeduct=BigDecimal.ZERO;return;}BigDecimaldailySalary=workDays.compareTo(BigDecimal.ZERO)==0?BigDecimal.ZERO:payableBase.divide(workDays,8,RoundingMode.HALF_UP);BigDecimalabsentDeduct=Objects.equals(attendanceRule.getAbsenceDeductType(),2)?absenceDayAmount.multiply(absentDays):dailySalary.multiply(absentDays).multiply(absenceDayRatio);BigDecimallateDeduct=newBigDecimal(lateCount).multiply(lateAmount);BigDecimalearlyLeaveDeduct=newBigDecimal(earlyLeaveCount).multiply(earlyAmount);BigDecimalmissingDeduct=newBigDecimal(missingPunchCount).multiply(missingAmount);absenceDeduct=scale(absentDeduct.add(lateDeduct).add(earlyLeaveDeduct).add(missingDeduct));}缺勤类型 1 是「日薪 × 缺勤天数 × 比例」,类型 2 是「固定额 × 缺勤天数」。迟到早退缺卡是按次加。全部加完才putAmount("attendance.absence_deduction"),缺勤项目用系统取数去读这个键。
六、按方案项目回填,并留下快照
结论:引擎内部先算「应发合计 / 应扣合计 / 实发 / 人工成本」四个汇总,再按方案勾选的每一个薪资项目把金额填回去。工资条上看到的每一行,都能对上itemCode。
resolveItemAmount用编码关键字认标准项:BASIC、POST、PERFORMANCE、ALLOW、ATTENDANCE、SOCIAL_PERSONAL、FUND_PERSONAL、TAX、NET、SOCIAL_COMPANY、FUND_COMPANY、LABOR_COST。认不出的自定义项,再走一遍calculateByRule。
每个员工一行结果写入hrm_salary_batch_employee,分项写入hrm_salary_batch_employee_item,整份上下文打进calc_snapshot_json:批次号、档案版本、岗位匹配、绩效结果、考勤汇总、异常标签、个税累计口径。
▲ 试算结果弹窗写明绩效工资的取值优先级;黄标签是配置提示,红标签是实发为负、缺方案、缺社保规则、员工公司与薪酬主体不一致这类硬异常
社保基数按基本工资夹在上下限之间,个税走累计预扣(专文已写,这里只强调引擎把taxAmountput 进上下文后,个税项目按编码取走)。非居民规则会打「未支持累计预扣」标签,税额按 0 处理,不假装算过。
七、异常标签:能继续算,但必须看见
结论:试算默认不中断整批。缺配置用标签标出来,HR 决定是补数据再跑,还是带着提示送确认审批。
| 标签 | 含义 |
|---|---|
| 期间无生效版本 | 档案版本生效日晚于本期结束 |
| 未匹配岗位工资规则 | 方案勾了岗位项,但岗位规则没命中 |
| 绩效模块未接入 | 期间没有已发布结果,用了岗位基数或规则兜底 |
| 无考勤数据,缺勤扣款按全月缺勤测算 | 有用户、有应出勤日、打卡行为 0 |
| 缺少社保公积金规则 / 缺少个税规则 | 城市规则 miss |
| 累计期初缺失 | 2 月及以后首次跑,历史台账为空 |
| 实发为负数 | 扣项大于应发,硬异常 |
硬异常在弹窗里用红色标。配置提示用黄色,允许继续。这和「试算失败就整批回滚」不同:企业发薪经常是 90% 的人没问题,剩下的人缺一张社保城市,HR 需要看见名单而不是整批重来。
八、数据结构(试算相关)
| 表 | 角色 |
|---|---|
hrm_salary_plan/hrm_salary_plan_item | 方案及勾选的薪资项目 |
hrm_salary_rule | calcType、公式、取数键、条件、上下限 |
hrm_salary_attendance_rule | 缺勤类型、迟到早退缺卡金额 |
hrm_salary_social_fund_rule/hrm_salary_tax_rule | 按城市匹配 |
hrm_salary_batch | 期间批次,确认审批后禁止重试算 |
hrm_salary_batch_employee | 人级应发应扣实发 +calc_snapshot_json |
hrm_salary_batch_employee_item | 项目级金额、来源类型、备注 |
设计要点:快照是试算当时的事实,发薪后档案再改不影响已确认批次;来源备注会写成「来自打卡汇总:缺勤 x 天,迟到 y 次」,对账时不用再反推。
九、技术亮点
| 设计要点 | 实现 | 价值 |
|---|---|---|
| 一次装上下文 | 内部类SalaryCalcContext | 规则不再各自查库 |
| 三种 calcType | 固定值 / 公式 / 系统取数 | HR 能配,工程师能审计 |
| 取数白名单 | 精确匹配键名 | 禁止在取数框里写加减 |
| 中文公式 | 标识符含汉字 | 基本工资*0.1可跑 |
| 考勤按应出勤日 | 复用打卡规则引擎 | 大小周不会算错缺勤 |
| 无规则不扣款 | attendanceRule == null则 0 | 避免无配置扣光应发 |
| 绩效三层兜底 | 正式结果 → 岗位基数 → 规则 | 绩效晚出也能先发工资 |
| 快照可对账 | JSON 含分项与异常 | 工资条每一行有出处 |
| 标签不中断整批 | 黄提示 / 红硬异常 | 少数人缺配置不影响多数人 |
十、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
建议按这条路径看引擎,而不是只看方案列表:
- 人力 → 薪酬管理 → 薪酬方案,打开「深圳研发中心月薪方案」工作台。
- 在「项目与计薪」看
BASIC_SALARY的计算方式是否为系统取数。 - 点某一行「高级」,看
calcType、取数键、条件表达式。 - 切到「考勤」,看缺勤比例和迟到早退缺卡金额。
- 打开薪资批次,对已有批次点「查看结果」,看绩效来源和异常色。
- 若批次尚未确认,可再点「试算」(确认审批后会被后端拒绝重算)。
源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
常见问题(FAQ)
算薪引擎和薪酬方案工作台是一回事吗?
不是。工作台负责把项目、岗位、社保、个税、考勤配齐并生成默认规则;引擎负责批次试算时按SalaryCalcContext求值。工作台配错,引擎会算出带标签的结果,而不是在配置页假装已经发薪。
系统取数能不能写「固定薪加补贴」?
不能。系统取数只匹配单个键,例如archive.fixed_salary。加减乘除把calcType改成公式计算。
员工这个月完全没打卡,会不会把应发扣成 0?
分两层。还没生成系统用户:考勤available=false,扣款为 0,打标签。已经有用户但打卡记录为 0:按应出勤日计全月缺勤,再乘方案里的缺勤比例。方案若没配考勤规则,扣款仍是 0。
公式支持 Excel 那种 IF 吗?
不支持函数。分支用规则上的条件表达式拆开。标识符可以写中文键名,四则运算和括号可以,IF/SUM/VLOOKUP不行。
试算过的批次还能改方案再算一遍吗?
发起确认审批、已确认、已发薪或已发布工资条后,后端拒绝重试算。要改口径,走新批次,不要改已经送审的快照。
结语
企业算薪引擎的核心不是「把四则运算写进 Java」,而是给每一次试算一个封闭的上下文:档案版本、岗位匹配、绩效结果、考勤汇总进同一个SalaryCalcContext;规则只暴露固定值、公式、系统取数三种算法;项目按编码把金额领走;算不清的用标签说出来,而不是静默填 0 或整批失败。
方案怎么配、个税怎么累计、工资条怎么发,可以各自成文。中间这一跳如果讲不清,前面配得再漂亮,发下去的数也经不起对账。
你们团队现在的算薪,是脚本拍脑袋,还是每次试算都能打开快照看每个项目的来源?欢迎在评论里说说卡在哪一层。
💡想要体验 RuoYi Office 的强大功能?
🌐在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬技术咨询:添加微信17156169080,备注「RuoYi Office」
⭐如果觉得不错,请给个 Star 支持一下!