feat(hrm): 考勤规则放开部门唯一限制,支持同一部门多班次
- 新增/编辑不再校验「该部门已配置考勤规则」,同一部门可建多条规则用于多班次
- 打卡/导入按打卡时间匹配班次:优先落在班次时间窗口内的规则,
都不落在窗口内时取窗口最近的一条(跨天班次也算得对,结果稳定不随查询顺序漂移)
- 规则名称仍全局唯一;未配部门规则时仍回退全局规则
- 补充 matchRuleByClockTime 单元测试 7 例;输出联调文档 docs/attendance_multi_shift_dept_rules.md
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | # èå¤è§åæ¯æãåä¸ä¸ªé¨é¨å¤æ¡è§åï¼å¤ç次ï¼ã |
| | | |
| | | > åæ´æ¥æï¼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`ï¼ï¼ä¸åå¨å¯ä¸ç´¢å¼ã |
| | |
| | | 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, "æ¥æåºé´ã{} ~ {}ãä¸å·²æé
ç½®ã{}ãï¼{} ~ {}ï¼éå ï¼æ æ³ä¿åãè¯·è°æ´æ¬æ¬¡æ¥æåºé´é¿å¼è¯¥é
ç½®ï¼æç´æ¥ç¼è¾/å é¤å·²æé
ç½®ååä¿å"); |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | .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(); |
| | |
| | | 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); |
| | | } |
| | |
| | | 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); |
| | | } |
| | |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO; |
| | | import jakarta.validation.Valid; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | |
| | | /** |
| | |
| | | */ |
| | | HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId); |
| | | |
| | | /** |
| | | * æ ¹æ®é¨é¨ID + æå¡æ¶é´è·åèå¤è§å |
| | | * |
| | | * åä¸ä¸ªé¨é¨å
许é
ç½®å¤æ¡èå¤è§åï¼å¤ç次ï¼ï¼æ¤æ¶ææå¡æ¶é´å¹é
çæ¬¡ï¼ |
| | | * ä¼å
åçæ¬¡æ¶é´çªå£å
å«è¯¥æå¡æ¶é´çè§åï¼é½ä¸å
嫿¶åæ¶é´çªå£ææ¥è¿ç䏿¡ã |
| | | * |
| | | * @param deptId é¨é¨ID |
| | | * @param clockTime æå¡æ¶é´ï¼ä¸ºç©ºæ¶å该é¨é¨ä¸çæ¶é´ææ©ç䏿¡è§å |
| | | * @return èå¤è§å |
| | | */ |
| | | HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDateTime clockTime); |
| | | |
| | | } |
| | |
| | | |
| | | 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 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; |
| | |
| | | @Validated |
| | | public class HrmAttendanceRuleServiceImpl implements HrmAttendanceRuleService { |
| | | |
| | | /** è§åç¶æï¼0-å¯ç¨ */ |
| | | private static final Integer ENABLED_STATUS = 0; |
| | | |
| | | @Resource |
| | | private HrmAttendanceRuleMapper attendanceRuleMapper; |
| | | |
| | |
| | | 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) { |
| | |
| | | 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); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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); |
| | |
| | | |
| | | @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); |
| | | } |
| | | |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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()); |
| | | } |
| | | |
| | | } |