yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -5,6 +5,9 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.salary.vo.HrmSalaryCalculationPageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmOvertimeApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmEmployeeScheduleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmShiftDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmEmployeeSalaryDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmEmployeeSocialSecurityDO;
@@ -13,7 +16,11 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryPaymentDetailDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryPaymentDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSocialSecuritySchemeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.salary.HrmSalaryStructureDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmEmployeeScheduleMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmShiftMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmOvertimeApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSalaryMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmEmployeeSocialSecurityMapper;
@@ -22,8 +29,15 @@
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryPaymentDetailMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSalaryPaymentMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.salary.HrmSocialSecuritySchemeMapper;
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;
import org.springframework.transaction.annotation.Transactional;
@@ -34,8 +48,11 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -44,7 +61,7 @@
/**
 * 酬核算 Service 实现类
 *
 * @author 芋道源码
 * @author 超级管理员
 */
@Service
@Validated
@@ -82,11 +99,21 @@
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
    @Resource
    private HrmOvertimeApplicationMapper overtimeApplicationMapper;
    @Resource
    private HrmSalaryStructureMapper salaryStructureMapper;
    @Resource
    private HrmOvertimeWageConfigMapper overtimeWageConfigMapper;
    @Resource
    private HrmLeaveTypeConfigMapper leaveTypeConfigMapper;
    @Resource
    private HrmSalaryPaymentMapper salaryPaymentMapper;
    @Resource
    private HrmSalaryPaymentDetailMapper salaryPaymentDetailMapper;
    @Resource
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
@@ -130,7 +157,10 @@
                if (existCalculations.size() > 1) {
                    throw exception(SALARY_CALCULATION_DUPLICATE, employee.getName(), period);
                }
                continue; // 已存在则跳过
                // 已确认或已发放的记录不可自动改写;待确认记录允许重算,以吸收后来审批通过的加班。
                if (existCalculations.get(0).getStatus() != 0) {
                    continue;
                }
            }
            // 2.2 获取员工薪酬档案(按员工ID查询)
@@ -151,15 +181,20 @@
            // 2.5 构建核算记录(包含扣除计算,通过方案获取比例)
            HrmSalaryCalculationDO calculation = buildSalaryCalculation(period, employeeId, employee, employeeSalary, socialSecurity, scheme, effectiveDate);
            // 2.6 生成核算单号
            String no = noRedisDAO.generateSalaryCalculationNo();
            while (salaryCalculationMapper.selectByNo(no) != null) {
                no = noRedisDAO.generateSalaryCalculationNo();
            // 2.6 保存:首次生成单号;已有待确认记录则保留单号并覆盖重算结果
            if (CollUtil.isNotEmpty(existCalculations)) {
                HrmSalaryCalculationDO existing = existCalculations.get(0);
                calculation.setId(existing.getId());
                calculation.setNo(existing.getNo());
                salaryCalculationMapper.updateById(calculation);
            } else {
                String no = noRedisDAO.generateSalaryCalculationNo();
                while (salaryCalculationMapper.selectByNo(no) != null) {
                    no = noRedisDAO.generateSalaryCalculationNo();
                }
                calculation.setNo(no);
                salaryCalculationMapper.insert(calculation);
            }
            calculation.setNo(no);
            // 2.7 保存核算记录
            salaryCalculationMapper.insert(calculation);
        }
    }
@@ -174,13 +209,14 @@
    }
    /**
     * 构建默认的员工薪酬档案(基于员工表的基本工资)
     * 构建默认的员工薪酬档案。基本工资统一取员工档案,绩效工资默认不自动生成。
     */
    private HrmEmployeeSalaryDO buildDefaultEmployeeSalary(Long employeeId, HrmEmployeeDO employee) {
        BigDecimal baseSalary = employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO;
        return HrmEmployeeSalaryDO.builder()
                .userId(employeeId) // 使用员工ID
                .salaryStructureId(employee.getSalaryStructureId())
                .baseSalary(employee.getBaseSalary() != null ? employee.getBaseSalary() : BigDecimal.ZERO)
                .baseSalary(baseSalary)
                .performanceSalary(BigDecimal.ZERO)
                .mealAllowance(BigDecimal.ZERO)
                .transportAllowance(BigDecimal.ZERO)
@@ -204,17 +240,23 @@
        BigDecimal mealAllowance = employeeSalary.getMealAllowance() != null ? employeeSalary.getMealAllowance() : BigDecimal.ZERO;
        BigDecimal transportAllowance = employeeSalary.getTransportAllowance() != null ? employeeSalary.getTransportAllowance() : BigDecimal.ZERO;
        BigDecimal otherAllowance = employeeSalary.getOtherAllowance() != null ? employeeSalary.getOtherAllowance() : BigDecimal.ZERO;
        BigDecimal overtimePay = BigDecimal.ZERO;
        OvertimeSalary overtimeSalary = calculateOvertimeSalary(employee, employeeSalary, period, baseSalary);
        BigDecimal overtimePay = overtimeSalary.pay();
        // 按排班累计出勤天数与排班总工时(无排班时出勤天数回退默认 22)
        ScheduleStat scheduleStat = calculateScheduleStat(employeeId, period);
        Integer workDays = scheduleStat.scheduledDays() > 0 ? scheduleStat.scheduledDays() : 22;
        BigDecimal totalWorkHours = scheduleStat.totalWorkHours();
        // 应发工资总额
        BigDecimal totalIncome = baseSalary.add(performanceSalary).add(mealAllowance)
                .add(transportAllowance).add(otherAllowance).add(overtimePay);
        // 获取社保基数(员工档案中的个性化基数)
        // 获取社保基数:员工社保档案 > 员工基本工资
        BigDecimal socialSecurityBase = (socialSecurity != null && socialSecurity.getSocialSecurityBase() != null)
                ? socialSecurity.getSocialSecurityBase() : baseSalary;
        // 获取公积金基数(员工档案中的个性化基数)
        // 获取公积金基数:员工社保档案 > 员工基本工资
        BigDecimal housingFundBase = (socialSecurity != null && socialSecurity.getHousingFundBase() != null)
                ? socialSecurity.getHousingFundBase() : baseSalary;
@@ -276,8 +318,9 @@
                .otherDeduction(otherDeduction)
                .leaveDeduction(leaveDeduction)
                .actualSalary(actualSalary)
                .workDays(22) // 默认工作日
                .overtimeHours(BigDecimal.ZERO)
                .workDays(workDays) // 按排班累计应出勤天数,无排班时回退 22
                .overtimeHours(overtimeSalary.hours())
                .totalWorkHours(totalWorkHours)
                .status(0) // 待确认
                .build();
    }
@@ -332,6 +375,165 @@
        }
        return totalLeaveDeduction;
    }
    /**
     * 汇总当月审批通过的加班申请,计算加班工资与加班时长。
     *
     * <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) {
        if (employee.getUserId() == null) {
            return new OvertimeSalary(BigDecimal.ZERO, BigDecimal.ZERO);
        }
        YearMonth month = YearMonth.parse(period);
        List<HrmOvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedByUserAndDateRange(
                employee.getUserId(), month.atDay(1), month.atEndOfMonth());
        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);
            BigDecimal workdayMultiplier = structure != null && structure.getOvertimePayRatio() != null
                    ? structure.getOvertimePayRatio() : HrmOvertimeTypeEnum.WORKDAY.getSalaryMultiplier();
            BigDecimal restDayMultiplier = structure != null && structure.getRestDayOvertimePayRatio() != null
                    ? structure.getRestDayOvertimePayRatio() : HrmOvertimeTypeEnum.REST_DAY.getSalaryMultiplier();
            BigDecimal legalHolidayMultiplier = structure != null && structure.getLegalHolidayOvertimePayRatio() != null
                    ? structure.getLegalHolidayOvertimePayRatio() : HrmOvertimeTypeEnum.LEGAL_HOLIDAY.getSalaryMultiplier();
            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;
                }
                BigDecimal multiplier = switch (type) {
                    case WORKDAY -> workdayMultiplier;
                    case REST_DAY -> restDayMultiplier;
                    case LEGAL_HOLIDAY -> legalHolidayMultiplier;
                };
                totalHours = totalHours.add(hours);
                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) {}
    /**
     * 按排班表累计员工的出勤天数与排班总工时。
     * 仅统计有班次的排班记录,班次标准工时之和即为排班工时。
     */
    private ScheduleStat calculateScheduleStat(Long employeeId, String period) {
        YearMonth yearMonth = YearMonth.parse(period);
        List<HrmEmployeeScheduleDO> schedules = employeeScheduleMapper.selectListByEmployeeIdAndPeriod(
                employeeId, yearMonth.atDay(1), yearMonth.atEndOfMonth());
        if (CollUtil.isEmpty(schedules)) {
            return new ScheduleStat(0, BigDecimal.ZERO);
        }
        Set<Long> shiftIds = schedules.stream().map(HrmEmployeeScheduleDO::getShiftId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, HrmShiftDO> shiftMap = CollUtil.isEmpty(shiftIds) ? Collections.emptyMap()
                : shiftMapper.selectBatchIds(shiftIds).stream()
                        .collect(Collectors.toMap(HrmShiftDO::getId, shift -> shift, (a, b) -> a));
        int scheduledDays = 0;
        BigDecimal totalWorkHours = BigDecimal.ZERO;
        for (HrmEmployeeScheduleDO schedule : schedules) {
            HrmShiftDO shift = schedule.getShiftId() == null ? null : shiftMap.get(schedule.getShiftId());
            if (shift == null) {
                continue;
            }
            scheduledDays++;
            totalWorkHours = totalWorkHours.add(shift.getWorkHours() != null ? shift.getWorkHours() : BigDecimal.ZERO);
        }
        return new ScheduleStat(scheduledDays, totalWorkHours.setScale(2, RoundingMode.HALF_UP));
    }
    @Override
@@ -577,4 +779,4 @@
        salaryCalculationMapper.deleteById(id);
    }
}
}