gaoluyang
6 小时以前 d206afe19ea77303a1f3dc50aa80caaf09612693
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java
@@ -2,16 +2,26 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.google.common.annotations.VisibleForTesting;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRulePageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRuleSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRuleMapper;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
@@ -25,17 +35,19 @@
@Validated
public class HrmAttendanceRuleServiceImpl implements HrmAttendanceRuleService {
    /** 规则状态:0-启用 */
    private static final Integer ENABLED_STATUS = 0;
    @Resource
    private HrmAttendanceRuleMapper attendanceRuleMapper;
    @Resource
    private DeptApi deptApi;
    @Override
    public Long createAttendanceRule(HrmAttendanceRuleSaveReqVO createReqVO) {
        // 校验规则名称是否重复
        validateAttendanceRuleNameUnique(null, createReqVO.getName());
        // 校验部门是否已配置规则(如果指定了部门)
        if (createReqVO.getDeptId() != null) {
            validateAttendanceRuleDeptUnique(null, createReqVO.getDeptId());
        }
        // 注:同一个部门允许配置多条规则(多班次),这里不再校验部门唯一
        HrmAttendanceRuleDO attendanceRule = BeanUtils.toBean(createReqVO, HrmAttendanceRuleDO.class);
        // 设置默认值
        if (attendanceRule.getLateAllowMinutes() == null) {
@@ -59,10 +71,7 @@
        validateAttendanceRuleExists(updateReqVO.getId());
        // 校验规则名称是否重复
        validateAttendanceRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
        // 校验部门是否已配置规则(如果指定了部门)
        if (updateReqVO.getDeptId() != null) {
            validateAttendanceRuleDeptUnique(updateReqVO.getId(), updateReqVO.getDeptId());
        }
        // 注:同一个部门允许配置多条规则(多班次),这里不再校验部门唯一
        HrmAttendanceRuleDO updateObj = BeanUtils.toBean(updateReqVO, HrmAttendanceRuleDO.class);
        attendanceRuleMapper.updateById(updateObj);
    }
@@ -92,18 +101,6 @@
        }
    }
    private void validateAttendanceRuleDeptUnique(Long id, Long deptId) {
        List<HrmAttendanceRuleDO> rules = attendanceRuleMapper.selectListByDeptId(deptId);
        if (rules.isEmpty()) {
            return;
        }
        for (HrmAttendanceRuleDO rule : rules) {
            if (id == null || !rule.getId().equals(id)) {
                throw exception(ATTENDANCE_RULE_DEPT_EXISTS);
            }
        }
    }
    @Override
    public HrmAttendanceRuleDO getAttendanceRule(Long id) {
        return attendanceRuleMapper.selectById(id);
@@ -121,21 +118,89 @@
    @Override
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId) {
        // 1. 先查找部门专属规则
        List<HrmAttendanceRuleDO> deptRules = attendanceRuleMapper.selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<HrmAttendanceRuleDO>()
                .eq(HrmAttendanceRuleDO::getDeptId, deptId)
                .eq(HrmAttendanceRuleDO::getStatus, 0));
        if (!deptRules.isEmpty()) {
            return deptRules.get(0);
        return getAttendanceRuleByDeptId(deptId, null);
    }
    @Override
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDateTime clockTime) {
        // 1. 从员工所在部门开始逐级向上查找:配置在上级部门的规则对其子部门生效
        Set<Long> visitedDeptIds = new HashSet<>();
        Long currentDeptId = deptId;
        while (currentDeptId != null && currentDeptId != 0 && visitedDeptIds.add(currentDeptId)) {
            List<HrmAttendanceRuleDO> deptRules = attendanceRuleMapper.selectEnabledListByDeptId(currentDeptId);
            if (!deptRules.isEmpty()) {
                // 同一部门可配置多条规则(多班次),按打卡时间匹配班次
                return matchRuleByClockTime(deptRules, clockTime);
            }
            DeptRespDTO dept = deptApi.getDept(currentDeptId);
            if (dept == null) {
                break;
            }
            currentDeptId = dept.getParentId();
        }
        // 2. 查找全局规则(deptId 为 null)
        List<HrmAttendanceRuleDO> globalRules = attendanceRuleMapper.selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<HrmAttendanceRuleDO>()
        List<HrmAttendanceRuleDO> globalRules = attendanceRuleMapper.selectList(new LambdaQueryWrapperX<HrmAttendanceRuleDO>()
                .isNull(HrmAttendanceRuleDO::getDeptId)
                .eq(HrmAttendanceRuleDO::getStatus, 0));
                .eq(HrmAttendanceRuleDO::getStatus, ENABLED_STATUS)
                .orderByDesc(HrmAttendanceRuleDO::getId));
        if (!globalRules.isEmpty()) {
            return globalRules.get(0);
            return matchRuleByClockTime(globalRules, clockTime);
        }
        return null;
    }
    /**
     * 多条规则(同一个部门多班次)时,按打卡时间选班次:
     * 1. 打卡时间落在某个班次的 [上班时间, 下班时间] 内,就取这一条
     * 2. 都不在窗口内时,取班次时间窗口离打卡时间最近的一条
     * 3. 仍然并列时,按上班时间、编号排序,保证结果稳定(不会随数据库返回顺序漂移)
     * 打卡时间为空(不关心班次)时,取上班时间最早的一条。
     */
    @VisibleForTesting
    static HrmAttendanceRuleDO matchRuleByClockTime(List<HrmAttendanceRuleDO> rules, LocalDateTime clockTime) {
        if (rules.size() == 1) {
            return rules.get(0);
        }
        if (clockTime == null) {
            return rules.stream().min(Comparator
                            .comparing(HrmAttendanceRuleDO::getWorkStartTime,
                                    Comparator.nullsLast(Comparator.<LocalTime>naturalOrder()))
                            .thenComparing(HrmAttendanceRuleDO::getId))
                    .orElse(rules.get(0));
        }
        LocalTime time = clockTime.toLocalTime();
        return rules.stream().min(Comparator
                        .comparingLong((HrmAttendanceRuleDO rule) -> minutesToRuleWindow(rule, time))
                        .thenComparing(HrmAttendanceRuleDO::getWorkStartTime,
                                Comparator.nullsLast(Comparator.<LocalTime>naturalOrder()))
                        .thenComparing(HrmAttendanceRuleDO::getId))
                .orElse(rules.get(0));
    }
    /**
     * 打卡时间到规则班次窗口的距离(分钟):落在窗口内为 0,班次时间未配置时记为最大。
     * 支持跨天班次(如 22:00-06:00,只要 end <= start 就按跨天处理)。
     */
    private static long minutesToRuleWindow(HrmAttendanceRuleDO rule, LocalTime time) {
        LocalTime start = rule.getWorkStartTime();
        LocalTime end = rule.getWorkEndTime();
        if (start == null || end == null) {
            return Long.MAX_VALUE;
        }
        boolean crossDay = !end.isAfter(start);
        boolean inWindow = crossDay
                ? !time.isBefore(start) || !time.isAfter(end)
                : !time.isBefore(start) && !time.isAfter(end);
        if (inWindow) {
            return 0;
        }
        return Math.min(minutesBetween(time, start), minutesBetween(time, end));
    }
    /** 两个时刻的分钟差,按一天 24 小时取最近的环回距离(23:50 与 00:10 只差 20 分钟) */
    private static long minutesBetween(LocalTime one, LocalTime other) {
        long minutes = Math.abs(Duration.between(one, other).toMinutes());
        return Math.min(minutes, 24 * 60 - minutes);
    }
}