gaoluyang
2026-11-02 9e9f0e2d9f6fcce7dcea88b181b3c7e6069e8a1b
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/salary/HrmSalaryCalculationServiceImpl.java
@@ -6,6 +6,8 @@
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;
@@ -16,6 +18,8 @@
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;
@@ -39,8 +43,12 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.temporal.ChronoUnit;
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;
@@ -96,6 +104,10 @@
    private HrmSalaryPaymentMapper salaryPaymentMapper;
    @Resource
    private HrmSalaryPaymentDetailMapper salaryPaymentDetailMapper;
    @Resource
    private HrmEmployeeScheduleMapper employeeScheduleMapper;
    @Resource
    private HrmShiftMapper shiftMapper;
    @Override
    public PageResult<HrmSalaryCalculationDO> getSalaryCalculationPage(HrmSalaryCalculationPageReqVO pageReqVO) {
@@ -225,6 +237,11 @@
        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);
@@ -295,8 +312,9 @@
                .otherDeduction(otherDeduction)
                .leaveDeduction(leaveDeduction)
                .actualSalary(actualSalary)
                .workDays(22) // 默认工作日
                .workDays(workDays) // 按排班累计应出勤天数,无排班时回退 22
                .overtimeHours(overtimeSalary.hours())
                .totalWorkHours(totalWorkHours)
                .status(0) // 待确认
                .build();
    }
@@ -341,7 +359,8 @@
        BigDecimal totalLeaveDeduction = BigDecimal.ZERO;
        for (HrmLeaveApplicationDO application : leaveApplications) {
            Integer leaveType = application.getLeaveType();
            BigDecimal duration = application.getDuration() != null ? application.getDuration() : BigDecimal.ZERO;
            // 跨月请假按落在本核算月内的天数折算,避免整条请假被跳过或被重复计入
            BigDecimal duration = resolveDurationInMonth(application, monthStart, monthEnd);
            BigDecimal deductionRatio = configMap.getOrDefault(leaveType, BigDecimal.ZERO);
            // 请假扣款 = 日工资 × 请假天数 × 扣款比例
@@ -351,6 +370,39 @@
        }
        return totalLeaveDeduction;
    }
    /**
     * 计算一笔请假落在本核算月内的请假时长
     *
     * 1)请假整段都在本月内:直接取请假单上的时长;
     * 2)请假跨月:按「本月内占用的自然日 / 请假总自然日」折算,例如 10-28 ~ 11-03 共 7 天,
     *    其中 10 月占 4 天,则 10 月核算按 时长 × 4 / 7 计,11 月按 时长 × 3 / 7 计,避免重复或漏算。
     */
    private BigDecimal resolveDurationInMonth(HrmLeaveApplicationDO application,
                                             LocalDateTime monthStart, LocalDateTime monthEnd) {
        BigDecimal duration = application.getDuration() != null ? application.getDuration() : BigDecimal.ZERO;
        LocalDateTime start = application.getStartTime();
        LocalDateTime end = application.getEndTime();
        if (start == null || end == null) {
            return duration;
        }
        // 整段都在本月内
        if (!start.isBefore(monthStart) && !end.isAfter(monthEnd)) {
            return duration;
        }
        long totalDays = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate()) + 1;
        if (totalDays <= 0) {
            return duration;
        }
        LocalDateTime overlapStart = start.isBefore(monthStart) ? monthStart : start;
        LocalDateTime overlapEnd = end.isAfter(monthEnd) ? monthEnd : end;
        if (overlapStart.isAfter(overlapEnd)) {
            return BigDecimal.ZERO;
        }
        long overlapDays = ChronoUnit.DAYS.between(overlapStart.toLocalDate(), overlapEnd.toLocalDate()) + 1;
        return duration.multiply(BigDecimal.valueOf(overlapDays))
                .divide(BigDecimal.valueOf(totalDays), 4, RoundingMode.HALF_UP);
    }
    /**
@@ -401,6 +453,37 @@
    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
    @Transactional(rollbackFor = Exception.class)
    public void confirmSalaryCalculation(List<Long> ids) {