数式評価エンジンの選定基準と適用方針
人事管理における給与算出ロジックは、複雑な業務ルールを柔軟に反映させる必要がある。これを実現するには、ランタイムでの条件分岐や算术演算を安全に実行できる数式評価エンジンが不可欠である。
主要エンジン比較表
| エンジン名 | 実行速度 | 型安全性 | カスタム関数拡張 | 推奨ユースケース |
|---|---|---|---|---|
| Aviator | ★★★★★ | 高 | 容易 | 大量レコードのバッチ演算 |
| SpEL | ★★★★☆ | 中 | 標準提供 | Spring Framework統合環境 |
| JEXL | ★★★☆☆ | 低 | 一部制限 | 軽量のテンプレート置換 |
| QLExpress | ★★★★☆ | 中 | 豊富 | 複雑なビジネスルールエンジン |
月次給与計算では数百〜数千名の员工データに対して同時に数式が評価されるため、オーバーヘッドが最小限に抑えられ、かつ独自関数を登録可能なAviatorを採用する。
基本ラッパークラスの実装
public class PayrollFormulaEngine {
private static final AviatorEvaluatorInstance EVALUATOR;
static {
EVALUATOR = AviatorEvaluator.newInstance();
// コンパイル時の最適化レベルを設定
EVALUATOR.setOption(Option.OPTIMIZE_LEVEL, AviatorEvaluator.INTERP);
// セキュリティ制約:代入操作と外部クラス呼び出しを禁止
EVALUATOR.setOption(Feature.getForbidFeature(
Feature.assignment, Feature.importClass
));
// ドメイン固有関数の登録
registerDomainFunctions();
}
private static void registerDomainFunctions() {
EVALUATOR.addFunction(new PrecisionRoundFunction());
EVALUATOR.addFunction(new ArraySumFunction());
EVALUATOR.addFunction(new ConditionalValueFunction());
}
public static Object execute(String script, Map<String, Object> variables) {
try {
CompilationExpression compiled = EVALUATOR.compile(script);
return compiled.execute(variables);
} catch (RuntimeException e) {
throw new ArithmeticExecutionException(
String.format("数式[%s]の評価失敗: %s", script, e.getMessage())
);
}
}
public static BigDecimal executeAsBigDecimal(String script, Map<String, Object> variables) {
Object res = execute(script, variables);
if (!(res instanceof Number)) {
throw new ArithmeticExecutionException("期待される型は数値のみです");
}
return new BigDecimal(res.toString()).setScale(2, RoundingMode.HALF_UP);
}
}
ドメイン用カスタム関数の定義
// 小数点以下指定桁数で四捨五入
public class PrecisionRoundFunction extends AbstractFunction {
@Override public String getName() { return "round_to"; }
@Override
public AviatorObject call(Map<String, Object> env, AviatorObject arg1, AviatorObject arg2) {
BigDecimal val = new BigDecimal(arg1.numberValue(env).toString());
int scale = arg2.numberValue(env).intValue();
BigDecimal rounded = val.setScale(scale, RoundingMode.HALF_UP);
return new AviatorDouble(rounded.doubleValue());
}
}
// 三項演算子の拡張(読みやすさ向上)
public class ConditionalValueFunction extends AbstractFunction {
@Override public String getName() { return "cond_if"; }
@Override
public AviatorObject call(Map<String, Object> env, AviatorObject cond, AviatorObject onTrue, AviatorObject onFalse) {
boolean flag = Boolean.TRUE.equals(cond.booleanValue(env));
return flag ? onTrue : onFalse;
}
}
給与計算ルール定義例(コメント形式)
# 固定給加算
base_pay := 45000
# 職級に応じた手当(P3未満なら基本のみ)
level_allowance := cond_if(position_level == 'P3', 8000, 5000)
# 成績割増率適用
bonus_pay := base_bonus_rate * performance_factor
# 通勤手当(出勤日数 × 単価、上限あり)
commute_allowance := min_to(max_days_limit, actual_attendance_days * daily_commute_fee)
# 残業代計算(時間給 × 時間数 × 割増率)
overtime_pay := overtime_hours * (monthly_base / 21.75 / 8) * overtime_multiplier
パラメータ解決のためのストラテジーパターン適用
給与構成要素のうち、勤続年数や法定最低賃金などは動的に取得する必要があります。これらの算出ロジックをインターフェース経由で分離し、新しいルール追加時に既存コードを変更せずに済むように設計します。
解決インタフェースとコンテナ登録
@FunctionalInterface
public interface PayrollParamResolver {
/**
* 特定のパラメータキーに対応する計算結果を取得
*/
Optional<String> resolve(ParameterConfig config, LocalDate targetMonth, EmployeeDTO emp);
/**
* 本リゾルバーが担当するパラメータコード一覧
*/
default Set<String> getTargetCodes() { return Collections.emptySet(); }
}
@Component
@Slf4j
public class ResolverContainer implements ApplicationListener<ContextRefreshedEvent> {
private final Map<String, PayrollParamResolver> resolverMap = new ConcurrentHashMap<>();
@Autowired
private List<PayrollParamResolver> resolverBeans;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
resolverBeans.forEach(this::register);
log.info("登録された給与パラメータリゾルバー数: {}", resolverMap.size());
}
private void register(PayrollParamResolver bean) {
bean.getTargetCodes().forEach(code ->
resolverMap.put(code, bean)
);
}
public PayrollParamResolver getByCode(String code) {
return resolverMap.getOrDefault(code, NO_OP_RESOLVER);
}
private static final PayrollParamResolver NO_OP_RESOLVER = (c, m, e) -> Optional.empty();
}
代表的な解決コンポーネントの実装
// 勤続年数算出
@Component
public class TenureYearResolver implements PayrollParamResolver {
@Override
public Optional<String> resolve(ParameterConfig config, LocalDate month, EmployeeDTO emp) {
if (emp.getHireDate() == null) return Optional.empty();
long years = ChronologyUnit.YEARS.between(emp.getHireDate(), month);
return Optional.of(Long.toString(years));
}
@Override public Set<String> getTargetCodes() { return Set.of("service_years", "tenure"); }
}
// 法定最低賃金参照
@Component
public class MinWageResolver implements PayrollParamResolver {
@Autowired private PolicyRepository policyRepo;
@Override
public Optional<String> resolve(ParameterConfig config, LocalDate month, EmployeeDTO emp) {
BigDecimal wage = policyRepo.findMinimumWageByCityAndMonth(emp.getWorkCity(), month);
return wage != null ? Optional.of(wage.toString()) : Optional.empty();
}
@Override public Set<String> getTargetCodes() { return Set.of("local_min_wage", "floor_wage"); }
}
// 所定労働日数計算
@Component
public class ScheduledDaysResolver implements PayrollParamResolver {
@Override
public Optional<String> resolve(ParameterConfig config, LocalDate month, EmployeeDTO emp) {
YearPeriod ym = YearPeriod.from(month);
int total = ym.lengthOfMonth();
long weekends = IntStream.rangeClosed(1, total)
.mapToObj(d -> month.withDayOfMonth(d))
.filter(d -> d.getDayOfWeek().getValue() >= 6)
.count();
int holidays = HolidayConfigLoader.countOfficialHolidays(month);
int available = total - Math.toIntExact(weekends) - holidays;
return Optional.of(Integer.toString(Math.max(available, 0)));
}
@Override public Set<String> getTargetCodes() { return Set.of("scheduled_days", "standard_workdays"); }
}
計算コンテキスト構築フロー
public class PayrollContextAssembler {
@Autowired private ResolverContainer resolverContainer;
@Autowired private SystemParamLoader paramLoader;
public Map<String, Object> build(EmployeeDTO employee, LocalDate calcMonth) {
Map<String, Object> ctx = new HashMap<>();
ctx.put("employee_id", employee.getId());
ctx.put("calc_month", calcMonth.toString());
ctx.put("target_date", calcMonth);
List<ParameterConfig> configs = paramLoader.loadActiveParameters();
for (ParameterConfig cfg : configs) {
PayrollParamResolver resolver = resolverContainer.getByCode(cfg.getCode());
resolver.resolve(cfg, calcMonth, employee)
.ifPresentOrElse(
val -> ctx.put(cfg.getCode(), parseValue(cfg.getType(), val)),
() -> ctx.put(cfg.getCode(), cfg.getDefaultValue())
);
}
return ctx;
}
private Object parseValue(String type, String raw) {
if ("DECIMAL".equals(type)) return new BigDecimal(raw);
if ("INT".equals(type)) return Integer.parseInt(raw);
return raw;
}
}
法定控除項目(所得税・社会保険)の計算モデル
データエンティティ設計
@Table("income_tax_rule")
public class IncomeTaxRuleEntity {
@Id private Long id;
private String regionCode;
private BigDecimal threshold;
private LocalDateTime effectiveFrom;
}
@Table("tax_bracket_config")
public class TaxBracketConfig {
@Id private Long id;
private Long ruleId;
private Integer level;
private BigDecimal lowerBound;
private BigDecimal upperBound;
private BigDecimal rate;
private BigDecimal quickDeduction;
}
所得税算出プロセッサ
public class IncomeTaxProcessor {
public BigDecimal computeNetTax(BigDecimal taxableBase, List<TaxBracketConfig> brackets) {
if (taxableBase.compareTo(BigDecimal.ZERO) <= 0) return BigDecimal.ZERO;
TaxBracketConfig applicable = brackets.stream()
.filter(b -> isInBounds(taxableBase, b))
.findFirst()
.orElse(brackets.getLast());
BigDecimal tax = taxableBase.multiply(applicable.getRate())
.subtract(applicable.getQuickDeduction());
return tax.abs().abs().setScale(0, RoundingMode.DOWN);
}
private boolean isInBounds(BigDecimal value, TaxBracketConfig bracket) {
boolean geLower = value.compareTo(bracket.getLowerBound()) >= 0;
BigDecimal up = bracket.getUpperBound();
boolean ltUpper = up == null || value.compareTo(up) < 0;
return geLower && ltUpper;
}
}
税率区間テーブル初期値投入例
INSERT INTO tax_bracket_config (rule_id, level, lower_bound, upper_bound, rate, quick_deduction) VALUES
('JP_2024_MONTHLY', 1, 0, 195000, 0.05, 0),
('JP_2024_MONTHLY', 2, 195000, 330000, 0.10, 10000),
('JP_2024_MONTHLY', 3, 330000, 695000, 0.20, 42500),
('JP_2024_MONTHLY', 4, 695000, 900000, 0.23, 61000),
('JP_2024_MONTHLY', 5, 900000, 1800000, 0.30, 152000),
('JP_2024_MONTHLY', 6, 1800000, 4000000, 0.37, 315000),
('JP_2024_MONTHLY', 7, 4000000, NULL, 0.45, 800000);
社会保険料算出手法
public class SocialInsuranceProcessor {
public InsuranceResult calculate(EmployeeDTO emp, InsuranceTemplate template, BigDecimal grossMonthly) {
BigDecimal contributableBase = determineBase(emp, template, grossMonthly);
BigDecimal employeeTotal = BigDecimal.ZERO;
BigDecimal employerTotal = BigDecimal.ZERO;
for (InsuranceRateItem item : template.getItems()) {
BigDecimal amount = contributableBase.multiply(item.getRate());
if ("EMPLOYEE".equals(item.getPayerSide())) {
employeeTotal = employeeTotal.add(amount);
} else {
employerTotal = employerTotal.add(amount);
}
}
return new InsuranceResult(contributableBase, employeeTotal, employerTotal);
}
private BigDecimal determineBase(EmployeeDTO emp, InsuranceTemplate tmpl, BigDecimal salary) {
BigDecimal fixed = emp.getRegisteredBase();
BigDecimal floor = tmpl.getMinLimit();
BigDecimal ceiling = tmpl.getMaxLimit();
BigDecimal base = (fixed != null && !fixed.signum() == 0) ? fixed : salary;
// 規定範囲内に収めるクリッピング処理
if (base.compareTo(floor) < 0) base = floor;
if (base.compareTo(ceiling) > 0) base = ceiling;
return base;
}
}
大量データ処理におけるパフォーマンス最適化
パーティショニングベースのバッチ実行
public class BulkPayrollScheduler {
private static final int BATCH_LIMIT = 200;
public CompletableFuture<Void> runMonthlyCycle(LocalDate targetMonth) {
PageIterator<EmployeeDTO> iterator = repository.findAllPaginated(BATCH_LIMIT);
List<CompletableFuture<List<PayrollSnapshot>>> futures = new ArrayList<>();
iterator.forEach(batch -> {
Future<List<PayrollSnapshot>> f = executor.submit(() -> {
return batch.parallelStream()
.map(emp -> evaluateSingleEmployee(emp, targetMonth))
.collect(Collectors.toList());
});
futures.add(f);
});
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenAccept(v -> persistAllResults());
}
}
式コンパイルプールの再利用
public class ExpressionCompilationPool {
private final LoadingCache<String, CompiledScript> pool = CacheBuilder.newBuilder()
.maximumSize(512)
.expireAfterWrite(24, TimeUnit.HOURS)
.build(key -> new CompiledScript(AviatorEvaluator.INSTANCE.compile(key)));
public BigDecimal run(CompiledScript script, Map<String, Object> vars) {
Object result = script.getUnderlying().execute(vars);
return result instanceof Number ? ((Number)result).doubleValue() : 0.0;
}
}
集約クエリの効率的な実行
public class DataFetchOptimizer {
public Map<Long, AttendanceRecord> fetchBatchRecords(List<Long> employeeIds, String month) {
List<AttendanceRecord> rows = attendanceDao.findByMonthAndIds(month, employeeIds);
return rows.stream()
.collect(Collectors.toMap(
AttendanceRecord::getEmployeeId,
r -> r,
(old, newer) -> newer // 最新レコード優先
));
}
public Map<Long, SalaryTemplate> fetchTemplates(List<Long> employeeIds) {
// 外部キー結合ではなくIN句による一括取得+ローカルマージ
List<EmployeeTemplateLink> links = linkDao.findByEmployees(employeeIds);
List<Long> templateIds = links.stream().map(l -> l.getTemplateId()).distinct().toList();
List<SalaryTemplate> templates = templateDao.findByIds(templateIds);
return links.stream()
.collect(Collectors.groupingBy(
EmployeeTemplateLink::getEmployeeId,
Collectors.collectingAndThen(
Collectors.reducing((a,b) -> a), // 最新の有効期間を選択
opt -> templates.stream()
.filter(t -> t.getId().equals(opt.get().getTemplateId()))
.findFirst()
.orElse(null)
)
));
}
}