跳转到内容

案例 35:薪资补贴自动核算

本案例对应 JQuick-Java USE-CASES 场景 35(诉求 43:薪资补贴自动核算),属于人力资源/薪酬管理领域,决策内容是按员工月度考勤与绩效要素自动核算八项补贴分项、执行合规核减,并汇总补贴合计与应发工资。

企业每月薪酬核算要汇总多种政策性与考勤性补贴。入参模型 PayrollInput 承载工号、姓名、基本工资、本单位工龄、当月夜班次数、工资所属月份、高温作业天数及是否户外岗位、加班时长与加班小时基数、加班类型(WORKDAY 工作日 / WEEKEND 休息日 / HOLIDAY 法定节假日)、出勤餐补天数、是否边远地区岗位、是否全勤、月度绩效分等 14 个要素。

八项补贴各自有独立口径并全部外置在 XML 脚本中:工龄津贴按 20/10/5/1 年分四档(8%、5%、3%、1%);夜班补贴 50 元/班;高温津贴户外 30 元/天、室内 15 元/天;加班工资按工作日 1.5 倍、休息日 2 倍、法定节假日 3 倍;餐补 30 元/天;边远地区津贴为基本工资 10%;全勤奖 300 元;绩效奖金按 95/85/70/60 分四档(30%、20%、10%、5%)。编排层另设两道合规闸:高温津贴仅 6-9 月核发,其他月份申报直接清零并备注防错发;月加班超过法定 36 小时上限部分核减并备注,只按 36 小时计发。

决策结果由 PayrollDecision 承载:八个补贴分项金额、补贴合计 totalAllowance、应发工资 grossPay(基本工资加补贴),以及 notes 核算说明清单。所有金额在 Java 侧经 BigDecimal 统一保留两位小数、四舍五入,避免 double 直加产生分位误差。

  • 补贴政策随地区与年度频繁调整:高温津贴标准、夜班单价、绩效档位系数、工龄比例各省与各年度口径不同,硬编码进薪资系统后每次调标都要赶在月末封账前发版。
  • 八项补贴耦合在一个薪资公式里:任一单项标准变动都要回归整套核算,HR 与财务无法对单项口径独立调参、独立复核。
  • 合规核减容易遗漏:高温津贴发放期、法定月加班 36 小时上限属于硬性合规要求,混在计算脚本里容易被申报数据绕过,产生错发与劳动合规风险。
  • 金额精度与核算依据要求高:double 直接连加出现分位尾差,员工工资条上又需要每一分钱都有口径与核减说明可查。
  • HR 系统老旧、信创环境难承载重型平台:大量薪酬系统仍运行在 JDK 8 与国产中间件上,不宜为补贴规则单独引入重量级 HR 规则平台。

本案例采用 JQuick-Java 标准六件套:入参模型、决策结果模型、Rules 规则接口、XML 规则脚本、Service 编排服务、JUnit 测试,全部代码在 Oracle JDK 1.8 下实测通过。

层次 职责 对应文件
领域模型 承载月度薪资核算要素与八项分项、合计决策 PayrollInput.java、PayrollDecision.java
规则层 八项补贴各一方法,比例/单价/档位全部外置 SalaryAllowanceRules.java、salary-allowance.xml
编排层 高温季防错发、加班 36 小时封顶核减、金额两位四舍五入与合计 SalaryAllowanceService.java
测试 6 组薪资核算真实形态 SalaryAllowanceServiceTest.java
规则方法 口径 分值/输出
seniorityAllowance 工龄 ≥20 年发基本工资 8%;≥10 年 5%;≥5 年 3%;满 1 年 1%;不满 1 年为 0 基本工资 × 0.08/0.05/0.03/0.01
nightShiftAllowance 50 元/班 × 当月夜班次数 shifts × 50(元)
highTempAllowance 户外岗位 30 元/天,室内岗位 15 元/天 days × 30 或 days × 15(元)
overtimePay 法定节假日 3 倍、休息日 2 倍、工作日 1.5 倍:小时基数 × 小时数 × 倍率 double 金额
mealAllowance 30 元/天 × 出勤餐补天数 days × 30(元)
remoteAreaAllowance 边远地区岗位发基本工资 10%,否则 0 基本工资 × 0.10 或 0
perfectAttendanceBonus 全勤 300 元,否则 0 300 或 0(元)
performanceBonus 绩效 ≥95 发 30%;≥85 发 20%;≥70 发 10%;≥60 发 5%;其余 0 基本工资 × 0.30/0.20/0.10/0.05

将两个模型类放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口放入 demo/,Service 放入 demo/service/,XML 脚本放入 src/test/resources/demo/,测试类放入 demo/,包名保持源码原样。工程根目录执行 mvn test -Dtest=SalaryAllowanceServiceTest 即可复现全部 6 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 员工月度薪资补贴核算要素
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PayrollInput {
/**
* 工号
*/
private String employeeId;
/**
* 姓名
*/
private String employeeName;
/**
* 基本工资
*/
private double baseSalary;
/**
* 本单位工龄(年)
*/
private int yearsOfService;
/**
* 当月夜班次数
*/
private int nightShifts;
/**
* 工资所属月份(1-12)
*/
private int payrollMonth;
/**
* 高温作业天数
*/
private int highTempDays;
/**
* 是否户外高温岗位
*/
private boolean highTempOutdoor;
/**
* 加班时长(小时)
*/
private double overtimeHours;
/**
* 加班小时基数(元/小时)
*/
private double overtimeHourlyBase;
/**
* 加班类型:WORKDAY 工作日 / WEEKEND 休息日 / HOLIDAY 法定节假日
*/
private String overtimeType;
/**
* 出勤餐补天数
*/
private int mealAllowanceDays;
/**
* 是否边远地区岗位
*/
private boolean remoteArea;
/**
* 是否全勤
*/
private boolean attendancePerfect;
/**
* 月度绩效分(0-100)
*/
private int performanceScore;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 月度补贴分项与应发合计
*/
@Data
public class PayrollDecision {
/**
* 工号
*/
private String employeeId;
private double seniorityAllowance;
private double nightShiftAllowance;
private double highTempAllowance;
private double overtimePay;
private double mealAllowance;
private double remoteAreaAllowance;
private double perfectAttendanceBonus;
private double performanceBonus;
/**
* 补贴合计
*/
private double totalAllowance;
/**
* 应发工资(基本工资 + 补贴)
*/
private double grossPay;
/**
* 核算说明(防错发、超限提示)
*/
private List<String> notes = new ArrayList<String>();
public void addNote(String note) {
this.notes.add(note);
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/**
* 场景 35:薪资补贴各分项核算规则
*/
public interface SalaryAllowanceRules {
/**
* 工龄津贴:20 年 8%、10 年 5%、5 年 3%、满 1 年 1%
*/
double seniorityAllowance(@Param("baseSalary") double baseSalary,
@Param("years") int years);
/**
* 夜班补贴:50 元/班
*/
int nightShiftAllowance(@Param("shifts") int shifts);
/**
* 高温津贴:户外 30 元/天,室内 15 元/天
*/
int highTempAllowance(@Param("days") int days,
@Param("outdoor") boolean outdoor);
/**
* 加班工资:工作日 1.5 倍、休息日 2 倍、法定节假日 3 倍
*/
double overtimePay(@Param("hourlyBase") double hourlyBase,
@Param("hours") double hours,
@Param("type") String type);
/**
* 餐补:30 元/天
*/
int mealAllowance(@Param("days") int days);
/**
* 边远地区津贴:基本工资 10%
*/
double remoteAreaAllowance(@Param("baseSalary") double baseSalary,
@Param("remote") boolean remote);
/**
* 全勤奖:300 元
*/
int perfectAttendanceBonus(@Param("perfect") boolean perfect);
/**
* 绩效奖金:≥95 30%、≥85 20%、≥70 10%、≥60 5%,其余为 0
*/
double performanceBonus(@Param("baseSalary") double baseSalary,
@Param("score") int score);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN"
"classpath:paohaijiao/dtd/Jquick-java.dtd">
<javas namespace="com.github.paohaijiao.demo.SalaryAllowanceRules">
<java name="seniorityAllowance" returnClass="double">
<![CDATA[
double def seniorityAllowance(double:baseSalary, int:years) {
if (years >= 20) {
return baseSalary * 0.08;
} else if (years >= 10) {
return baseSalary * 0.05;
} else if (years >= 5) {
return baseSalary * 0.03;
} else if (years >= 1) {
return baseSalary * 0.01;
} else {
return 0.0;
}
}
]]>
</java>
<java name="nightShiftAllowance" returnClass="int">
<![CDATA[
int def nightShiftAllowance(int:shifts) {
return shifts * 50;
}
]]>
</java>
<java name="highTempAllowance" returnClass="int">
<![CDATA[
int def highTempAllowance(int:days, boolean:outdoor) {
if (outdoor) {
return days * 30;
} else {
return days * 15;
}
}
]]>
</java>
<java name="overtimePay" returnClass="double">
<![CDATA[
double def overtimePay(double:hourlyBase, double:hours, str:type) {
if (type == "HOLIDAY") {
return hourlyBase * hours * 3.0;
} else if (type == "WEEKEND") {
return hourlyBase * hours * 2.0;
} else {
return hourlyBase * hours * 1.5;
}
}
]]>
</java>
<java name="mealAllowance" returnClass="int">
<![CDATA[
int def mealAllowance(int:days) {
return days * 30;
}
]]>
</java>
<java name="remoteAreaAllowance" returnClass="double">
<![CDATA[
double def remoteAreaAllowance(double:baseSalary, boolean:remote) {
if (remote) {
return baseSalary * 0.10;
} else {
return 0.0;
}
}
]]>
</java>
<java name="perfectAttendanceBonus" returnClass="int">
<![CDATA[
int def perfectAttendanceBonus(boolean:perfect) {
if (perfect) {
return 300;
} else {
return 0;
}
}
]]>
</java>
<java name="performanceBonus" returnClass="double">
<![CDATA[
double def performanceBonus(double:baseSalary, int:score) {
if (score >= 95) {
return baseSalary * 0.30;
} else if (score >= 85) {
return baseSalary * 0.20;
} else if (score >= 70) {
return baseSalary * 0.10;
} else if (score >= 60) {
return baseSalary * 0.05;
} else {
return 0.0;
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.SalaryAllowanceRules;
import com.github.paohaijiao.demo.model.PayrollDecision;
import com.github.paohaijiao.demo.model.PayrollInput;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 场景 35:薪资补贴自动核算(工龄/夜班/高温/加班/餐补/边远/全勤/绩效)
*/
public class SalaryAllowanceService {
/**
* 法定月度加班时长上限(小时)
*/
private static final double MAX_LEGAL_OVERTIME_HOURS = 36.0;
private final SalaryAllowanceRules rules;
public SalaryAllowanceService(SalaryAllowanceRules rules) {
this.rules = rules;
}
public PayrollDecision calculate(PayrollInput input) {
PayrollDecision d = new PayrollDecision();
d.setEmployeeId(input.getEmployeeId());
d.setSeniorityAllowance(money(rules.seniorityAllowance(
input.getBaseSalary(), input.getYearsOfService())));
d.setNightShiftAllowance(rules.nightShiftAllowance(input.getNightShifts()));
if (input.getHighTempDays() > 0 && (input.getPayrollMonth() < 6 || input.getPayrollMonth() > 9)) {
d.setHighTempAllowance(0.0);
d.addNote("申报高温作业 " + input.getHighTempDays()
+ " 天,但 " + input.getPayrollMonth()
+ " 月不在高温津贴发放期(6-9 月),该补贴不予核发");
} else {
d.setHighTempAllowance(rules.highTempAllowance(
input.getHighTempDays(), input.isHighTempOutdoor()));
}
double payableOvertimeHours = input.getOvertimeHours();
if (payableOvertimeHours > MAX_LEGAL_OVERTIME_HOURS) {
d.addNote("申报加班 " + input.getOvertimeHours()
+ " 小时超出法定上限 36 小时,超出部分按 "
+ (payableOvertimeHours - MAX_LEGAL_OVERTIME_HOURS) + " 小时核减");
payableOvertimeHours = MAX_LEGAL_OVERTIME_HOURS;
}
d.setOvertimePay(money(rules.overtimePay(input.getOvertimeHourlyBase(),
payableOvertimeHours, input.getOvertimeType())));
d.setMealAllowance(rules.mealAllowance(input.getMealAllowanceDays()));
d.setRemoteAreaAllowance(money(rules.remoteAreaAllowance(
input.getBaseSalary(), input.isRemoteArea())));
d.setPerfectAttendanceBonus(rules.perfectAttendanceBonus(input.isAttendancePerfect()));
d.setPerformanceBonus(money(rules.performanceBonus(
input.getBaseSalary(), input.getPerformanceScore())));
double total = money(d.getSeniorityAllowance() + d.getNightShiftAllowance()
+ d.getHighTempAllowance() + d.getOvertimePay() + d.getMealAllowance()
+ d.getRemoteAreaAllowance() + d.getPerfectAttendanceBonus()
+ d.getPerformanceBonus());
d.setTotalAllowance(total);
d.setGrossPay(money(input.getBaseSalary() + total));
return d;
}
private double money(double v) {
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.PayrollDecision;
import com.github.paohaijiao.demo.model.PayrollInput;
import com.github.paohaijiao.demo.service.SalaryAllowanceService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 35:薪资补贴自动核算
* <p>
* 6 组形态:全补贴标杆、新人基础补贴、法定节假日三倍加班、
* 非高温季防错发、加班超 36 小时核减、绩效不达标与 60 分临界。
*/
public class SalaryAllowanceServiceTest {
private SalaryAllowanceService service;
@Before
public void setUp() {
SalaryAllowanceRules rules = JQuickJava.create()
.createApi(SalaryAllowanceRules.class, "demo/salary-allowance.xml");
this.service = new SalaryAllowanceService(rules);
}
/**
* 12 年工龄、8 夜班、12 户外高温、10 小时平日加班、边远、全勤、90 分绩效
*/
@Test
public void shouldCalculateAllAllowanceItems() {
PayrollInput i = base("E001", 10000, 7);
i.setYearsOfService(12);
i.setNightShifts(8);
i.setHighTempDays(12);
i.setHighTempOutdoor(true);
i.setOvertimeHours(10);
i.setOvertimeHourlyBase(60);
i.setOvertimeType("WORKDAY");
i.setMealAllowanceDays(22);
i.setRemoteArea(true);
i.setAttendancePerfect(true);
i.setPerformanceScore(90);
PayrollDecision d = service.calculate(i);
Assert.assertEquals(500.0, d.getSeniorityAllowance(), 0.0001);
Assert.assertEquals(400.0, d.getNightShiftAllowance(), 0.0001);
Assert.assertEquals(360.0, d.getHighTempAllowance(), 0.0001);
Assert.assertEquals(900.0, d.getOvertimePay(), 0.0001);
Assert.assertEquals(660.0, d.getMealAllowance(), 0.0001);
Assert.assertEquals(1000.0, d.getRemoteAreaAllowance(), 0.0001);
Assert.assertEquals(300.0, d.getPerfectAttendanceBonus(), 0.0001);
Assert.assertEquals(2000.0, d.getPerformanceBonus(), 0.0001);
Assert.assertEquals(6120.0, d.getTotalAllowance(), 0.0001);
Assert.assertEquals(16120.0, d.getGrossPay(), 0.0001);
}
/**
* 新人无工龄/夜班/加班/全勤,仅餐补与 72 分绩效
*/
@Test
public void shouldCalculateFreshmanBasicAllowance() {
PayrollInput i = base("E002", 6000, 6);
i.setYearsOfService(0);
i.setMealAllowanceDays(20);
i.setPerformanceScore(72);
PayrollDecision d = service.calculate(i);
Assert.assertEquals(0.0, d.getSeniorityAllowance(), 0.0001);
Assert.assertEquals(600.0, d.getMealAllowance(), 0.0001);
Assert.assertEquals(600.0, d.getPerformanceBonus(), 0.0001);
Assert.assertEquals(1200.0, d.getTotalAllowance(), 0.0001);
Assert.assertEquals(7200.0, d.getGrossPay(), 0.0001);
}
/**
* 6 年工龄 + 4 夜班 + 法定节假日 8 小时三倍加班 + 85 分绩效
*/
@Test
public void shouldPayTripleOvertimeOnHoliday() {
PayrollInput i = base("E003", 8000, 10);
i.setYearsOfService(6);
i.setNightShifts(4);
i.setHighTempDays(0);
i.setOvertimeHours(8);
i.setOvertimeHourlyBase(50);
i.setOvertimeType("HOLIDAY");
i.setMealAllowanceDays(0);
i.setAttendancePerfect(true);
i.setPerformanceScore(85);
PayrollDecision d = service.calculate(i);
Assert.assertEquals(240.0, d.getSeniorityAllowance(), 0.0001);
Assert.assertEquals(200.0, d.getNightShiftAllowance(), 0.0001);
Assert.assertEquals(1200.0, d.getOvertimePay(), 0.0001);
Assert.assertEquals(300.0, d.getPerfectAttendanceBonus(), 0.0001);
Assert.assertEquals(1600.0, d.getPerformanceBonus(), 0.0001);
Assert.assertEquals(3540.0, d.getTotalAllowance(), 0.0001);
}
/**
* 10 月申报 10 天户外高温 → 非发放期防错发清零
*/
@Test
public void shouldRejectHighTempAllowanceOutsideSummer() {
PayrollInput i = base("E004", 7000, 10);
i.setYearsOfService(2);
i.setHighTempDays(10);
i.setHighTempOutdoor(true);
i.setMealAllowanceDays(10);
i.setPerformanceScore(60);
PayrollDecision d = service.calculate(i);
Assert.assertEquals(0.0, d.getHighTempAllowance(), 0.0001);
Assert.assertEquals(70.0, d.getSeniorityAllowance(), 0.0001);
Assert.assertEquals(300.0, d.getMealAllowance(), 0.0001);
Assert.assertEquals(350.0, d.getPerformanceBonus(), 0.0001);
Assert.assertEquals(720.0, d.getTotalAllowance(), 0.0001);
Assert.assertTrue(d.getNotes().get(0).contains("不在高温津贴发放期(6-9 月)"));
}
/**
* 申报 40 小时工作日加班 → 仅核发 36 小时
*/
@Test
public void shouldCapOvertimeAtLegalLimit() {
PayrollInput i = base("E005", 6000, 8);
i.setYearsOfService(0);
i.setOvertimeHours(40);
i.setOvertimeHourlyBase(50);
i.setOvertimeType("WORKDAY");
i.setMealAllowanceDays(0);
i.setPerformanceScore(0);
PayrollDecision d = service.calculate(i);
Assert.assertEquals(2700.0, d.getOvertimePay(), 0.0001);
Assert.assertEquals(2700.0, d.getTotalAllowance(), 0.0001);
Assert.assertTrue(d.getNotes().get(0).contains("超出法定上限 36 小时,超出部分按 4.0 小时核减"));
}
/**
* 绩效 59 分无奖金;同条件 60 分临界拿 5%
*/
@Test
public void shouldHandlePerformanceThresholdAtSixty() {
PayrollInput low = base("E006", 9000, 9);
low.setYearsOfService(10);
low.setNightShifts(10);
low.setMealAllowanceDays(21);
low.setPerformanceScore(59);
PayrollDecision d1 = service.calculate(low);
Assert.assertEquals(450.0, d1.getSeniorityAllowance(), 0.0001);
Assert.assertEquals(500.0, d1.getNightShiftAllowance(), 0.0001);
Assert.assertEquals(630.0, d1.getMealAllowance(), 0.0001);
Assert.assertEquals(0.0, d1.getPerformanceBonus(), 0.0001);
Assert.assertEquals(1580.0, d1.getTotalAllowance(), 0.0001);
PayrollInput edge = base("E007", 9000, 9);
edge.setYearsOfService(10);
edge.setNightShifts(10);
edge.setMealAllowanceDays(21);
edge.setPerformanceScore(60);
PayrollDecision d2 = service.calculate(edge);
Assert.assertEquals(450.0, d2.getPerformanceBonus(), 0.0001);
Assert.assertEquals(2030.0, d2.getTotalAllowance(), 0.0001);
}
private PayrollInput base(String id, double baseSalary, int month) {
PayrollInput i = new PayrollInput();
i.setEmployeeId(id);
i.setEmployeeName("测试员工");
i.setBaseSalary(baseSalary);
i.setYearsOfService(0);
i.setNightShifts(0);
i.setPayrollMonth(month);
i.setHighTempDays(0);
i.setHighTempOutdoor(false);
i.setOvertimeHours(0);
i.setOvertimeHourlyBase(0);
i.setOvertimeType("WORKDAY");
i.setMealAllowanceDays(0);
i.setRemoteArea(false);
i.setAttendancePerfect(false);
i.setPerformanceScore(0);
return i;
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=SalaryAllowanceServiceTest

测试覆盖形态(6 个 @Test 方法,覆盖全分项标杆、新人、三倍加班、防错发、法定核减、绩效临界):

测试方法 业务形态 关键断言/期望决策
shouldCalculateAllAllowanceItems E001 基本工资 10000、7 月:12 年工龄、8 夜班、12 天户外高温、10 小时工作日加班(基数 60)、22 天餐补、边远、全勤、绩效 90 八项依次 500/400/360/900/660/1000/300/2000,补贴合计 6120.0,应发 16120.0
shouldCalculateFreshmanBasicAllowance E002 基本工资 6000、6 月:0 年工龄、无夜班加班、20 天餐补、绩效 72 工龄津贴 0、餐补 600、绩效 600(10% 档),合计 1200.0,应发 7200.0
shouldPayTripleOvertimeOnHoliday E003 基本工资 8000、10 月:6 年工龄、4 夜班、8 小时法定节假日加班(基数 50)、全勤、绩效 85 工龄 240、夜班 200、加班 1200(50×8×3)、全勤 300、绩效 1600,合计 3540.0
shouldRejectHighTempAllowanceOutsideSummer E004 基本工资 7000、10 月:2 年工龄、申报 10 天户外高温、10 天餐补、绩效 60 高温津贴 0.0,工龄 70、餐补 300、绩效 350(5% 档),合计 720.0,首条备注含“不在高温津贴发放期(6-9 月)”
shouldCapOvertimeAtLegalLimit E005 基本工资 6000、8 月:申报 40 小时工作日加班(基数 50),其余为 0 仅按 36 小时计发:50×36×1.5=2700.0,合计 2700.0,备注含“超出法定上限 36 小时,超出部分按 4.0 小时核减”
shouldHandlePerformanceThresholdAtSixty E006/E007 基本工资 9000、9 月:10 年工龄、10 夜班、21 天餐补,绩效分别 59 与 60 59 分绩效奖金 0,合计 1580.0;60 分临界拿 5%(450.0),合计 2030.0;两单工龄 450、夜班 500、餐补 630 一致
  • 八项口径 XML 外置、政策调标免发版:工龄 8%/5%/3%/1%、夜班 50 元、高温 30/15 元、加班 1.5/2/3 倍、餐补 30 元、绩效 30%/20%/10%/5% 全部写在 salary-allowance.xml,地区或年度调标时改 XML 重建代理即可,月末封账不再依赖发版窗口。
  • 一方法一补贴,独立调参与审计:每个补贴是一个独立规则方法,工资条上八个分项金额可逐一回溯到对应方法与档位,HR 复核与员工答疑都有明确口径。
  • 合规闸在 Java 侧前置:高温津贴 6-9 月发放期校验直接清零并写防错发备注,月加班 36 小时法定上限在调用脚本前完成核减,申报数据再异常也绕不过硬性合规要求。
  • BigDecimal 统一两位四舍五入:脚本只做纯数值计算,Service 用 BigDecimal HALF_UP 对每个分项、合计、应发分别归整,规避 double 连加的分位尾差。
  • 枚举型加班类型按名注入:@Param("type") 把 WORKDAY/WEEKEND/HOLIDAY 按名传入脚本,倍率分支正向书写、默认回落工作日 1.5 倍,无需在脚本中处理取反逻辑。
  • ASM 动态代理、Java 8 无重型依赖:代理单例复用、无独立服务与额外中间件,可直接嵌入运行在 JDK 8 与信创 JDK 上的老旧 e-HR/薪酬系统。