gaoluyang
6 小时以前 5db13c2ad6678cf4614b81c95df035c6f47d313d
feat(hrm): 考勤规则放开部门唯一限制,支持同一部门多班次

- 新增/编辑不再校验「该部门已配置考勤规则」,同一部门可建多条规则用于多班次
- 打卡/导入按打卡时间匹配班次:优先落在班次时间窗口内的规则,
都不落在窗口内时取窗口最近的一条(跨天班次也算得对,结果稳定不随查询顺序漂移)
- 规则名称仍全局唯一;未配部门规则时仍回退全局规则
- 补充 matchRuleByClockTime 单元测试 7 例;输出联调文档 docs/attendance_multi_shift_dept_rules.md
已添加2个文件
已修改4个文件
335 ■■■■■ 文件已修改
docs/attendance_multi_shift_dept_rules.md 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImplTest.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/attendance_multi_shift_dept_rules.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
# è€ƒå‹¤è§„则支持「同一个部门多条规则(多班次)」
> å˜æ›´æ—¥æœŸï¼š2026-10-16 模块:HRM â†’ è€ƒå‹¤ç®¡ç† â†’ è€ƒå‹¤è§„则配置
## æ¶‰åŠé¡µé¢
- HRM â†’ è€ƒå‹¤ç®¡ç† â†’ è€ƒå‹¤è§„则(新增 / ç¼–辑)
- HRM â†’ è€ƒå‹¤ç®¡ç† â†’ è€ƒå‹¤è®°å½•(打卡、导入、月度汇总依赖的规则匹配)
- ç§»åŠ¨ç«¯/大屏的打卡入口(走同一套 clock-in / clock-out æŽ¥å£ï¼‰
## å˜æ›´ç‚¹
| é¡¹ | å˜æ›´å‰ | å˜æ›´åŽ |
|----|--------|--------|
| éƒ¨é—¨ä¸Žè§„则的关系 | ä¸€ä¸ªéƒ¨é—¨åªèƒ½é…ç½®ä¸€æ¡è§„则,新增/编辑时若该部门已有规则,报错「该部门已配置考勤规则」 | ä¸€ä¸ªéƒ¨é—¨å¯ä»¥é…ç½®å¤šæ¡è§„则,用于多班次;新增/编辑不再报该错 |
| è§„则名称 | å…¨å±€å”¯ä¸€ï¼ˆ`hrm_attendance_rule.name` ä¸å…è®¸é‡å¤ï¼‰ | **不变**,仍然全局唯一,请用「早班规则 / å¤œç­è§„则」这类可区分的名称 |
| æ‰“卡/导入时用哪条规则 | è¯¥éƒ¨é—¨å¯ç”¨è§„则中的任意一条(按数据库返回顺序) | æŒ‰æ‰“卡时间匹配班次,见下表 |
| æœªé…ç½®éƒ¨é—¨è§„则时 | å›žé€€åˆ°å…¨å±€è§„则(`dept_id` ä¸ºç©ºï¼‰ | **不变** |
## ç­æ¬¡åŒ¹é…è§„则(同一个部门有多条启用规则时)
| åœºæ™¯ | å–哪条规则 |
|------|------------|
| æ‰“卡时间落在某条规则的 [上班时间, ä¸‹ç­æ—¶é—´] å†… | å–这条 |
| æ‰“卡时间不在任何班次窗口内(如提前 2 å°æ—¶åˆ°å²—) | å–班次窗口离打卡时间最近的那条 |
| æœ‰å¤šæ¡éƒ½ç¬¦åˆï¼ˆå¹¶åˆ—) | æŒ‰ä¸Šç­æ—¶é—´æ—©çš„优先,再按规则编号,结果稳定不漂移 |
| è·¨å¤©ç­æ¬¡ï¼ˆå¦‚ 22:00-06:00,即下班时间 â‰¤ ä¸Šç­æ—¶é—´ï¼‰ | å‡Œæ™¨çš„æ‰“卡(00:00-06:00)判给该跨天班次 |
| ä¸å…³å¿ƒç­æ¬¡ï¼ˆå†…部调用未传打卡时间) | å–上班时间最早的一条 |
匹配只在该部门启用的规则中进行;若该部门一条启用规则都没有,仍回退到全局规则(同样按上述规则匹配)。
## API
接口路径、请求参数、响应结构**均无变化**:
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /hrm/attendance-rule/create | æ–°å¢žè€ƒå‹¤è§„则(不再因部门重复而失败) |
| PUT | /hrm/attendance-rule/update | ä¿®æ”¹è€ƒå‹¤è§„则(不再因部门重复而失败) |
| POST | /hrm/attendance-clock/clock-in | ä¸Šç­æ‰“卡(按打卡时间匹配班次判迟到) |
| POST | /hrm/attendance-clock/clock-out | ä¸‹ç­æ‰“卡(按打卡时间匹配班次判早退、算加班) |
错误码 `1_030_004_007「该部门已配置考勤规则」` å·²ä¸å†æœ‰è§¦å‘点,前端无需再针对它做特殊处理(若有按错误码写死的文案,可删除)。
## å‰ç«¯ä¿®æ”¹ç‚¹
无。前端的部门下拉本来就是拉取全量部门、不做「已配置则禁用」的过滤;该提示来自后端异常,后端不再返回即可。
## æ³¨æ„äº‹é¡¹
- åŒä¸€ä¸ªéƒ¨é—¨é…å¤šæ¡è§„则时,**班次时间必须能区分开**(例如早班 08:00-16:00、晚班 16:00-24:00)。若两条规则的班次窗口完全重叠,打卡只会命中其中一条(上班时间早的那条),请避免配置重叠班次。
- è§„则名称仍全局唯一,两个部门各配「白班」会提示「考勤规则名称已存在」,请改名区分。
- å·²æœ‰çš„考勤记录不回溯重算:本次变更只影响变更之后新打卡、新导入/编辑的记录。历史记录如需按新班次重算,需要单独处理(重新导入或提供重算脚本)。
- æ— è¡¨ç»“构变更,无需执行 SQL;`hrm_attendance_rule.dept_id` æœ¬æ¥å°±æ˜¯æ™®é€šç´¢å¼•(`idx_dept_id`),不存在唯一索引。
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -83,6 +83,7 @@
    ErrorCode ATTENDANCE_EXCEPTION_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_030_004_004, "考勤异常申请提交失败,原因:不是草稿状态");
    ErrorCode ATTENDANCE_RULE_NOT_EXISTS = new ErrorCode(1_030_004_005, "考勤规则不存在");
    ErrorCode ATTENDANCE_RULE_NAME_EXISTS = new ErrorCode(1_030_004_006, "考勤规则名称已存在");
    // å·²åºŸå¼ƒï¼ˆ2026-10-16):同一个部门允许配置多条考勤规则(多班次),不再校验部门唯一,保留该错误码仅为兼容历史引用
    ErrorCode ATTENDANCE_RULE_DEPT_EXISTS = new ErrorCode(1_030_004_007, "该部门已配置考勤规则");
    ErrorCode ATTENDANCE_HOLIDAY_NOT_EXISTS = new ErrorCode(1_030_004_008, "节假日配置不存在(ID={}),可能已被他人删除。请刷新列表后重试");
    ErrorCode ATTENDANCE_HOLIDAY_RANGE_OVERLAP = new ErrorCode(1_030_004_009, "日期区间【{} ~ {}】与已有配置「{}」({} ~ {})重叠,无法保存。请调整本次日期区间避开该配置,或直接编辑/删除已有配置后再保存");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -88,27 +88,31 @@
        if (record.getDeptId() == null) {
            return;
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId());
        if (rule == null) {
            return;
        // ä¸Šç­æ‰“卡类型:晚于 ä¸Šç­æ—¶é—´+迟到允许 è®°ä¸ºè¿Ÿåˆ°ï¼ˆæŒ‰ä¸Šç­æ‰“卡时间匹配班次)
        if (record.getClockInTime() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockInTime());
            if (rule != null && rule.getWorkStartTime() != null) {
                int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
                LocalTime lateThreshold = rule.getWorkStartTime().plusMinutes(lateAllowMinutes);
                LocalTime clockInOnly = record.getClockInTime().toLocalTime();
                record.setClockInType(clockInOnly.isAfter(lateThreshold)
                        ? HrmAttendanceClockTypeEnum.LATE.getType()
                        : HrmAttendanceClockTypeEnum.NORMAL.getType());
            }
        }
        // ä¸Šç­æ‰“卡类型:晚于 ä¸Šç­æ—¶é—´+迟到允许 è®°ä¸ºè¿Ÿåˆ°
        if (record.getClockInTime() != null && rule.getWorkStartTime() != null) {
            int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
            LocalTime lateThreshold = rule.getWorkStartTime().plusMinutes(lateAllowMinutes);
            LocalTime clockInOnly = record.getClockInTime().toLocalTime();
            record.setClockInType(clockInOnly.isAfter(lateThreshold)
                    ? HrmAttendanceClockTypeEnum.LATE.getType()
                    : HrmAttendanceClockTypeEnum.NORMAL.getType());
        }
        // ä¸‹ç­æ‰“卡类型:早于 ä¸‹ç­æ—¶é—´-早退允许 è®°ä¸ºæ—©é€€
        if (record.getClockOutTime() != null && rule.getWorkEndTime() != null) {
            int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
            LocalTime earlyThreshold = rule.getWorkEndTime().minusMinutes(earlyAllowMinutes);
            LocalTime clockOutOnly = record.getClockOutTime().toLocalTime();
            record.setClockOutType(clockOutOnly.isBefore(earlyThreshold)
                    ? HrmAttendanceClockTypeEnum.EARLY.getType()
                    : HrmAttendanceClockTypeEnum.NORMAL.getType());
        // ä¸‹ç­æ‰“卡类型:早于 ä¸‹ç­æ—¶é—´-早退允许 è®°ä¸ºæ—©é€€ï¼ˆæŒ‰ä¸‹ç­æ‰“卡时间匹配班次)
        if (record.getClockOutTime() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockOutTime());
            if (rule != null && rule.getWorkEndTime() != null) {
                int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
                LocalTime earlyThreshold = rule.getWorkEndTime().minusMinutes(earlyAllowMinutes);
                LocalTime clockOutOnly = record.getClockOutTime().toLocalTime();
                record.setClockOutType(clockOutOnly.isBefore(earlyThreshold)
                        ? HrmAttendanceClockTypeEnum.EARLY.getType()
                        : HrmAttendanceClockTypeEnum.NORMAL.getType());
            }
        }
    }
@@ -123,9 +127,10 @@
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        // åŠ ç­æ—¶é•¿ï¼šæŒ‰è€ƒå‹¤è§„åˆ™çš„æ ‡å‡†ä¸‹ç­æ—¶é—´è®¡ç®—è¶…é¢éƒ¨åˆ†
        // åŠ ç­æ—¶é•¿ï¼šæŒ‰è€ƒå‹¤è§„åˆ™çš„æ ‡å‡†ä¸‹ç­æ—¶é—´è®¡ç®—è¶…é¢éƒ¨åˆ†ï¼ˆæŒ‰ä¸‹ç­æ‰“å¡æ—¶é—´åŒ¹é…ç­æ¬¡ï¼‰
        if (record.getClockOutTime() != null && record.getDeptId() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId());
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockOutTime());
            if (rule != null && rule.getWorkEndTime() != null) {
                LocalTime workEnd = rule.getWorkEndTime();
                LocalDate outDate = record.getClockOutTime().toLocalDate();
@@ -159,7 +164,8 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        // æŒ‰æœ¬æ¬¡æ‰“卡时间匹配班次(同一个部门可能配置了多条规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockTime);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
@@ -212,7 +218,8 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        // æŒ‰æœ¬æ¬¡æ‰“卡时间匹配班次(同一个部门可能配置了多条规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockTime);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleService.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -69,4 +70,16 @@
     */
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId);
    /**
     * æ ¹æ®éƒ¨é—¨ID + æ‰“卡时间获取考勤规则
     *
     * åŒä¸€ä¸ªéƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è€ƒå‹¤è§„则(多班次),此时按打卡时间匹配班次:
     * ä¼˜å…ˆå–班次时间窗口包含该打卡时间的规则,都不包含时取时间窗口最接近的一条。
     *
     * @param deptId    éƒ¨é—¨ID
     * @param clockTime æ‰“卡时间;为空时取该部门上班时间最早的一条规则
     * @return è€ƒå‹¤è§„则
     */
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDateTime clockTime);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java
@@ -2,6 +2,8 @@
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;
@@ -11,6 +13,10 @@
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.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -25,6 +31,9 @@
@Validated
public class HrmAttendanceRuleServiceImpl implements HrmAttendanceRuleService {
    /** è§„则状态:0-启用 */
    private static final Integer ENABLED_STATUS = 0;
    @Resource
    private HrmAttendanceRuleMapper attendanceRuleMapper;
@@ -32,10 +41,7 @@
    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 +65,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 +95,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 +112,80 @@
    @Override
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId) {
        // 1. å…ˆæŸ¥æ‰¾éƒ¨é—¨ä¸“属规则
        List<HrmAttendanceRuleDO> deptRules = attendanceRuleMapper.selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<HrmAttendanceRuleDO>()
        return getAttendanceRuleByDeptId(deptId, null);
    }
    @Override
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDateTime clockTime) {
        // 1. å…ˆæŸ¥æ‰¾éƒ¨é—¨ä¸“属规则(同一个部门可以配置多条,用于多班次)
        List<HrmAttendanceRuleDO> deptRules = attendanceRuleMapper.selectList(new LambdaQueryWrapperX<HrmAttendanceRuleDO>()
                .eq(HrmAttendanceRuleDO::getDeptId, deptId)
                .eq(HrmAttendanceRuleDO::getStatus, 0));
                .eq(HrmAttendanceRuleDO::getStatus, ENABLED_STATUS));
        if (!deptRules.isEmpty()) {
            return deptRules.get(0);
            return matchRuleByClockTime(deptRules, clockTime);
        }
        // 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));
        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);
    }
}
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImplTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,108 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
 * {@link HrmAttendanceRuleServiceImpl} çš„单元测试
 *
 * åŒä¸€ä¸ªéƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è€ƒå‹¤è§„则(多班次),这里覆盖「按打卡时间匹配班次」的选规则逻辑。
 * æ³¨æ„ï¼šæ–­è¨€åŒ¹é…åˆ°çš„规则编号,而不是规则对象本身 â€”— è¿”回的是传入的同一个对象,比较编号更能说明选的是哪一条。
 */
public class HrmAttendanceRuleServiceImplTest {
    private static HrmAttendanceRuleDO rule(Long id, String name, LocalTime start, LocalTime end) {
        return HrmAttendanceRuleDO.builder().id(id).name(name)
                .workStartTime(start).workEndTime(end)
                .lateAllowMinutes(0).earlyAllowMinutes(0)
                .overtimeThreshold(BigDecimal.ONE).status(0).build();
    }
    /** æ—©ç­ 08:00-16:00、晚班 16:00-24:00 â€”— ç”¨æ¥è¦†ç›–同一个部门多个班次 */
    private static List<HrmAttendanceRuleDO> dayAndNightRules() {
        return Stream.of(
                rule(1L, "早班", LocalTime.of(8, 0), LocalTime.of(16, 0)),
                rule(2L, "晚班", LocalTime.of(16, 0), LocalTime.of(23, 59)))
                .collect(Collectors.toList());
    }
    @Test
    public void testMatchRuleByClockTime_singleRule() {
        // åªæœ‰ä¸€æ¡è§„则时,无论打卡时间是多少都用它
        List<HrmAttendanceRuleDO> rules = Stream.of(rule(1L, "标准班", LocalTime.of(9, 0), LocalTime.of(18, 0)))
                .collect(Collectors.toList());
        assertEquals(1L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 2, 0)).getId());
    }
    /** æ‰“卡时间落在哪个班次的时间窗口内,就用哪个班次 â€”— ä¸èƒ½å› ä¸ºè¿”回顺序变了就选错 */
    @Test
    public void testMatchRuleByClockTime_clockTimeInWindow() {
        List<HrmAttendanceRuleDO> rules = dayAndNightRules();
        assertEquals(1L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 8, 5)).getId());
        assertEquals(2L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 16, 5)).getId());
        assertEquals(2L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 23, 30)).getId());
    }
    /** ç­æ¬¡é¡ºåºé¢ å€’时结果不变(保证不随数据库返回顺序漂移) */
    @Test
    public void testMatchRuleByClockTime_orderIndependent() {
        List<HrmAttendanceRuleDO> reversed = Stream.of(
                rule(2L, "晚班", LocalTime.of(16, 0), LocalTime.of(23, 59)),
                rule(1L, "早班", LocalTime.of(8, 0), LocalTime.of(16, 0)))
                .collect(Collectors.toList());
        assertEquals(1L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(reversed, LocalDateTime.of(2026, 10, 16, 7, 50)).getId());
    }
    /** æ‰“卡时间不在任何窗口内(例如提前很久到岗),取窗口离得最近的一条 */
    @Test
    public void testMatchRuleByClockTime_outOfWindow() {
        List<HrmAttendanceRuleDO> rules = dayAndNightRules();
        assertEquals(1L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 6, 0)).getId()); // ç¦»æ—©ç­ 2 å°æ—¶
        assertEquals(2L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 17, 1, 0)).getId()); // ç¦»æ™šç­ä¸‹ç­ 1 å°æ—¶
    }
    /** è·¨å¤©å¤œç­ï¼š22:00-06:00,凌晨的打卡属于这个班次 */
    @Test
    public void testMatchRuleByClockTime_crossDay() {
        List<HrmAttendanceRuleDO> rules = Stream.of(
                rule(1L, "白班", LocalTime.of(8, 0), LocalTime.of(16, 0)),
                rule(3L, "夜班", LocalTime.of(22, 0), LocalTime.of(6, 0)))
                .collect(Collectors.toList());
        assertEquals(3L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 22, 10)).getId());
        assertEquals(3L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 17, 5, 40)).getId());
        assertEquals(1L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 9, 0)).getId());
    }
    /** ä¸å…³å¿ƒç­æ¬¡ï¼ˆæ‰“卡时间为空)时,取上班时间最早的一条,结果稳定 */
    @Test
    public void testMatchRuleByClockTime_nullClockTime() {
        List<HrmAttendanceRuleDO> rules = Stream.of(
                rule(2L, "晚班", LocalTime.of(16, 0), LocalTime.of(23, 59)),
                rule(1L, "早班", LocalTime.of(8, 0), LocalTime.of(16, 0)))
                .collect(Collectors.toList());
        assertEquals(1L, HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, null).getId());
    }
    /** ç­æ¬¡æ—¶é—´æ²¡é…å¥½ï¼ˆä¸ºç©ºï¼‰æ—¶ä¸èƒ½æŠ›å¼‚常,仍要能选出一条规则 */
    @Test
    public void testMatchRuleByClockTime_ruleWithoutTime() {
        List<HrmAttendanceRuleDO> rules = Stream.of(
                rule(9L, "未配置班次时间的规则", null, null),
                rule(1L, "早班", LocalTime.of(8, 0), LocalTime.of(16, 0)))
                .collect(Collectors.toList());
        HrmAttendanceRuleDO matched = HrmAttendanceRuleServiceImpl.matchRuleByClockTime(rules, LocalDateTime.of(2026, 10, 16, 9, 0));
        assertNotNull(matched);
        assertEquals(1L, matched.getId());
    }
}