yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -32,6 +32,11 @@
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryStructureMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmOvertimeWageConfigDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmOvertimeWageConfigMapper;
import cn.iocoder.yudao.module.hrm.enums.HrmEmployeeCategoryEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmOvertimeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -97,6 +102,8 @@
    private HrmOvertimeApplicationMapper overtimeApplicationMapper;
    @Resource
    private HrmSalaryStructureMapper salaryStructureMapper;
    @Resource
    private HrmOvertimeWageConfigMapper overtimeWageConfigMapper;
    @Resource
    private HrmLeaveTypeConfigMapper leaveTypeConfigMapper;
    @Resource
@@ -371,8 +378,16 @@
    }
    /**
     * 汇总当月审批通过的加班申请。小时工资按基本工资 / 21.75 / 8 计算;
     * 工作日默认 1.5 倍、休息日默认 2 倍、法定节假日默认 3 倍,均可由薪酬结构覆盖。
     * 汇总当月审批通过的加班申请,计算加班工资与加班时长。
     *
     * <p>按「员工类别」差异化核算:
     * <ul>
     *     <li>技工:加班薪资按 1:1 核算,与平时时薪一致(基本工资 / 21.75 / 8);</li>
     *     <li>普通员工:按岗位配置的固定加班时薪(默认 20 元/小时)核算;</li>
     *     <li>管理:不计加班工资;</li>
     * </ul>
     * 命中岗位配置时,按配置的最大加班时长上限对加班时长封顶;
     * 未命中任何岗位配置且非技工/管理时,回退旧逻辑(薪酬结构倍率 / 加班类型默认倍率)。
     */
    private OvertimeSalary calculateOvertimeSalary(HrmEmployeeDO employee, HrmEmployeeSalaryDO employeeSalary,
                                                    String period, BigDecimal baseSalary) {
@@ -385,6 +400,38 @@
        if (CollUtil.isEmpty(applications)) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        // 管理类别不计加班工资
        HrmEmployeeCategoryEnum category = HrmEmployeeCategoryEnum.valueOfCategory(employee.getEmployeeCategory());
        if (category == HrmEmployeeCategoryEnum.MANAGER) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        // 员工岗位命中的加班工资配置(取时薪最高、上限最宽的一条)
        OvertimeWageRule wageRule = resolveOvertimeWageRule(employee);
        // 平时时薪 = 基本工资 / 21.75 / 8
        BigDecimal baseHourlySalary = baseSalary.divide(new BigDecimal("21.75"), 8, RoundingMode.HALF_UP)
                .divide(new BigDecimal("8"), 8, RoundingMode.HALF_UP);
        BigDecimal totalHours = BigDecimal.ZERO;
        BigDecimal totalPay = BigDecimal.ZERO;
        if (category == HrmEmployeeCategoryEnum.TECHNICIAN || wageRule != null) {
            // 技工:1:1(与平时时薪一致);普通员工命中配置:按配置固定时薪,均不区分加班类型倍率
            BigDecimal hourlySalary = category == HrmEmployeeCategoryEnum.TECHNICIAN
                    ? baseHourlySalary : wageRule.hourlyRate();
            for (HrmOvertimeApplicationDO application : applications) {
                BigDecimal hours = application.getDuration() == null ? BigDecimal.ZERO : application.getDuration();
                HrmOvertimeTypeEnum type = HrmOvertimeTypeEnum.valueOfType(application.getOvertimeType());
                if (type == null || hours.signum() <= 0) {
                    continue;
                }
                totalHours = totalHours.add(hours);
            }
            // 按岗位配置封顶加班时长
            BigDecimal paidHours = wageRule != null && wageRule.maxOvertimeHours() != null
                    && totalHours.compareTo(wageRule.maxOvertimeHours()) > 0
                    ? wageRule.maxOvertimeHours() : totalHours;
            totalPay = hourlySalary.multiply(paidHours);
        } else {
            // 兼容旧逻辑:无岗位配置、且非技工/管理
        Long structureId = employeeSalary.getSalaryStructureId() != null
                ? employeeSalary.getSalaryStructureId() : employee.getSalaryStructureId();
        HrmSalaryStructureDO structure = structureId == null ? null : salaryStructureMapper.selectById(structureId);
@@ -394,10 +441,6 @@
                ? structure.getRestDayOvertimePayRatio() : HrmOvertimeTypeEnum.REST_DAY.getSalaryMultiplier();
        BigDecimal legalHolidayMultiplier = structure != null && structure.getLegalHolidayOvertimePayRatio() != null
                ? structure.getLegalHolidayOvertimePayRatio() : HrmOvertimeTypeEnum.LEGAL_HOLIDAY.getSalaryMultiplier();
        BigDecimal hourlySalary = baseSalary.divide(new BigDecimal("21.75"), 8, RoundingMode.HALF_UP)
                .divide(new BigDecimal("8"), 8, RoundingMode.HALF_UP);
        BigDecimal totalHours = BigDecimal.ZERO;
        BigDecimal totalPay = BigDecimal.ZERO;
        for (HrmOvertimeApplicationDO application : applications) {
            BigDecimal hours = application.getDuration() == null ? BigDecimal.ZERO : application.getDuration();
            HrmOvertimeTypeEnum type = HrmOvertimeTypeEnum.valueOfType(application.getOvertimeType());
@@ -410,12 +453,56 @@
                case LEGAL_HOLIDAY -> legalHolidayMultiplier;
            };
            totalHours = totalHours.add(hours);
            totalPay = totalPay.add(hourlySalary.multiply(hours).multiply(multiplier));
                totalPay = totalPay.add(baseHourlySalary.multiply(hours).multiply(multiplier));
            }
        }
        return new OvertimeSalary(totalHours.setScale(1, RoundingMode.HALF_UP),
                totalPay.setScale(2, RoundingMode.HALF_UP));
    }
    /**
     * 解析员工岗位对应的加班工资规则:时薪取命中配置中的最大值;
     * 上限取“最宽”(存在未设置上限的配置则视为不限,否则取最大值)。
     */
    private OvertimeWageRule resolveOvertimeWageRule(HrmEmployeeDO employee) {
        if (StrUtil.isEmpty(employee.getPostIds())) {
            return null;
        }
        List<Long> postIds;
        try {
            postIds = JSONUtil.toList(employee.getPostIds(), Long.class);
        } catch (Exception ex) {
            return null;
        }
        if (CollUtil.isEmpty(postIds)) {
            return null;
        }
        List<HrmOvertimeWageConfigDO> configs = overtimeWageConfigMapper.selectEnabledListByPostIds(postIds);
        if (CollUtil.isEmpty(configs)) {
            return null;
        }
        BigDecimal hourlyRate = null;
        BigDecimal maxHours = null;
        boolean unlimited = false;
        for (HrmOvertimeWageConfigDO config : configs) {
            if (config.getHourlyRate() != null
                    && (hourlyRate == null || config.getHourlyRate().compareTo(hourlyRate) > 0)) {
                hourlyRate = config.getHourlyRate();
            }
            if (config.getMaxOvertimeHours() == null) {
                unlimited = true;
            } else if (!unlimited && (maxHours == null || config.getMaxOvertimeHours().compareTo(maxHours) > 0)) {
                maxHours = config.getMaxOvertimeHours();
            }
        }
        if (hourlyRate == null) {
            return null;
        }
        return new OvertimeWageRule(hourlyRate, unlimited ? null : maxHours);
    }
    private record OvertimeWageRule(BigDecimal hourlyRate, BigDecimal maxOvertimeHours) {}
    private record OvertimeSalary(BigDecimal hours, BigDecimal pay) {}
    private record ScheduleStat(int scheduledDays, BigDecimal totalWorkHours) {}