tuqin
6 天以前 a063b6faf44f5810b325ccffbdc503188d721ca5
feat(hrm): 考勤规则支持同一部门配置多条(按生效日期区间生效)

- 去掉「该部门已配置考勤规则」的部门唯一校验,同一部门可配多条规则
- 新增 effective_start_date / effective_end_date 字段(空=不限)
- 规则匹配改为按考勤日期区间命中:优先部门规则,其次全局规则;多条命中取生效开始日期最晚的那条(NULL 优先级最低)
- 打卡/导入/重算 4 处调用点传入考勤日期
- 新增校验:生效开始日期不能晚于结束日期
- SQL:sql/mysql/hrm_attendance_rule_add_effective_date.sql(幂等)
已修改7个文件
已添加1个文件
163 ■■■■■ 文件已修改
sql/mysql/hrm_attendance_rule_add_effective_date.sql 41 ●●●●● 补丁 | 查看 | 原始文档 | 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/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_attendance_rule_add_effective_date.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
-- =============================================
-- è€ƒå‹¤è§„则:支持同一部门配置多条规则(按生效日期区间区分)
--
-- å˜æ›´ï¼šhrm_attendance_rule å¢žåŠ  2 åˆ—
--   effective_start_date ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸï¼ˆNULL = ä¸é™ï¼‰
--   effective_end_date   ç”Ÿæ•ˆç»“束日期(NULL = ä¸é™ï¼‰
--
-- åŒ¹é…è§„则(后端 HrmAttendanceRuleServiceImpl.getAttendanceRuleByDeptId):
--   1) ä¼˜å…ˆå–部门专属规则,没有则取全局规则(dept_id IS NULL)
--   2) åªå– status=0 ä¸” ç”Ÿæ•ˆåŒºé—´è¦†ç›–考勤日期 çš„规则
--   3) å¤šæ¡å‘½ä¸­æ—¶ï¼Œå– effective_start_date æœ€æ™šçš„那条(NULL è§†ä¸ºé•¿æœŸæœ‰æ•ˆã€ä¼˜å…ˆçº§æœ€ä½Žï¼‰
--
-- è„šæœ¬å¯é‡å¤æ‰§è¡Œï¼ˆå¹‚ç­‰ï¼‰
-- =============================================
SET NAMES utf8mb4;
SET @sch := DATABASE();
-- 1. å¢žåŠ  effective_start_date
SET @exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = @sch AND TABLE_NAME = 'hrm_attendance_rule' AND COLUMN_NAME = 'effective_start_date');
SET @sql := IF(@exists = 0,
    'ALTER TABLE `hrm_attendance_rule` ADD COLUMN `effective_start_date` date DEFAULT NULL COMMENT ''生效开始日期(空表示不限)'' AFTER `overtime_threshold`',
    'SELECT ''effective_start_date å·²å­˜åœ¨ï¼Œè·³è¿‡'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. å¢žåŠ  effective_end_date
SET @exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = @sch AND TABLE_NAME = 'hrm_attendance_rule' AND COLUMN_NAME = 'effective_end_date');
SET @sql := IF(@exists = 0,
    'ALTER TABLE `hrm_attendance_rule` ADD COLUMN `effective_end_date` date DEFAULT NULL COMMENT ''生效结束日期(空表示不限)'' AFTER `effective_start_date`',
    'SELECT ''effective_end_date å·²å­˜åœ¨ï¼Œè·³è¿‡'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. æ ¸å¯¹
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @sch AND TABLE_NAME = 'hrm_attendance_rule'
  AND COLUMN_NAME IN ('effective_start_date', 'effective_end_date');
SELECT '考勤规则生效日期字段初始化完成' AS result;
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -93,6 +93,7 @@
    ErrorCode ATTENDANCE_EXCEPTION_UPDATE_AUDIT_STATUS_FAIL = new ErrorCode(1_030_004_014, "更新考勤异常审批状态失败,原因:不是审批中状态");
    ErrorCode ATTENDANCE_EXCEPTION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_004_015, "考勤异常审批流程不存在");
    ErrorCode ATTENDANCE_CLOCK_ALREADY_CLOCKED = new ErrorCode(1_030_004_016, "已打过卡,请勿重复打卡");
    ErrorCode ATTENDANCE_RULE_EFFECTIVE_DATE_INVALID = new ErrorCode(1_030_004_017, "生效开始日期不能晚于生效结束日期");
    // ========== è–ªé…¬ç®¡ç† 1-030-005-000 ==========
    ErrorCode SALARY_STRUCTURE_NOT_EXISTS = new ErrorCode(1_030_005_000, "薪酬结构不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java
@@ -8,6 +8,7 @@
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -51,6 +52,14 @@
    @ExcelProperty("加班起算时长")
    private BigDecimal overtimeThreshold;
    @Schema(description = "生效开始日期(空表示不限)")
    @ExcelProperty("生效开始日期")
    private LocalDate effectiveStartDate;
    @Schema(description = "生效结束日期(空表示不限)")
    @ExcelProperty("生效结束日期")
    private LocalDate effectiveEndDate;
    @Schema(description = "状态:0-启用,1-禁用")
    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat("hrm_rule_status")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java
@@ -6,6 +6,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
@Schema(description = "管理后台 - è€ƒå‹¤è§„则新增/修改 Request VO")
@@ -39,6 +40,12 @@
    @Schema(description = "加班起算时长(小时)", example = "1")
    private BigDecimal overtimeThreshold;
    @Schema(description = "生效开始日期(空表示不限)", example = "2026-01-01")
    private LocalDate effectiveStartDate;
    @Schema(description = "生效结束日期(空表示不限)", example = "2026-12-31")
    private LocalDate effectiveEndDate;
    @Schema(description = "状态:0-启用,1-禁用", example = "0")
    private Integer status;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java
@@ -68,6 +68,16 @@
    private BigDecimal overtimeThreshold;
    /**
     * ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸï¼ˆç©ºè¡¨ç¤ºä¸é™ï¼‰
     */
    private LocalDate effectiveStartDate;
    /**
     * ç”Ÿæ•ˆç»“束日期(空表示不限)
     */
    private LocalDate effectiveEndDate;
    /**
     * çŠ¶æ€ï¼š0-启用,1-禁用
     */
    private Integer status;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -88,7 +88,7 @@
        if (record.getDeptId() == null) {
            return;
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId());
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId(), record.getDate());
        if (rule == null) {
            return;
        }
@@ -125,7 +125,7 @@
        }
        // åŠ ç­æ—¶é•¿ï¼šæŒ‰è€ƒå‹¤è§„åˆ™çš„æ ‡å‡†ä¸‹ç­æ—¶é—´è®¡ç®—è¶…é¢éƒ¨åˆ†
        if (record.getClockOutTime() != null && record.getDeptId() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId());
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId(), record.getDate());
            if (rule != null && rule.getWorkEndTime() != null) {
                LocalTime workEnd = rule.getWorkEndTime();
                LocalDate outDate = record.getClockOutTime().toLocalDate();
@@ -159,7 +159,7 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockDate);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
@@ -212,7 +212,7 @@
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockDate);
        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
@@ -61,12 +61,14 @@
    List<HrmAttendanceRuleDO> getAttendanceRuleList();
    /**
     * æ ¹æ®éƒ¨é—¨ID获取考勤规则
     * ä¼˜å…ˆåŒ¹é…éƒ¨é—¨è§„则,其次查找全局规则(deptId=null)
     * æ ¹æ®éƒ¨é—¨ID + è€ƒå‹¤æ—¥æœŸèŽ·å–ç”Ÿæ•ˆçš„è€ƒå‹¤è§„åˆ™
     * ä¼˜å…ˆåŒ¹é…éƒ¨é—¨è§„则,其次查找全局规则(deptId=null);
     * åŒéƒ¨é—¨å­˜åœ¨å¤šæ¡è§„则时,取「生效开始日期」最晚且覆盖该日期的那条。
     *
     * @param deptId éƒ¨é—¨ID
     * @param date   è€ƒå‹¤æ—¥æœŸï¼ˆä¸ºç©ºæ—¶æŒ‰å½“天)
     * @return è€ƒå‹¤è§„则
     */
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId);
    HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, java.time.LocalDate date);
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java
@@ -2,6 +2,7 @@
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 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 +12,7 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -18,6 +20,9 @@
/**
 * è€ƒå‹¤è§„则配置 Service å®žçŽ°ç±»
 *
 * è¯´æ˜Žï¼šåŒä¸€éƒ¨é—¨å…è®¸é…ç½®å¤šæ¡è§„则,按「生效开始日期 / ç”Ÿæ•ˆç»“束日期」区间区分;
 * åŒ¹é…æ—¶å–覆盖目标日期、且生效开始日期最晚的那条(无日期的视为长期有效,优先级最低)。
 *
 * @author è¶…级管理员
 */
@@ -30,12 +35,9 @@
    @Override
    public Long createAttendanceRule(HrmAttendanceRuleSaveReqVO createReqVO) {
        // æ ¡éªŒè§„则名称是否重复
        // æ ¡éªŒè§„则名称是否重复(同一部门允许多条规则,不再限制部门唯一)
        validateAttendanceRuleNameUnique(null, createReqVO.getName());
        // æ ¡éªŒéƒ¨é—¨æ˜¯å¦å·²é…ç½®è§„则(如果指定了部门)
        if (createReqVO.getDeptId() != null) {
            validateAttendanceRuleDeptUnique(null, createReqVO.getDeptId());
        }
        validateEffectiveDateRange(createReqVO.getEffectiveStartDate(), createReqVO.getEffectiveEndDate());
        HrmAttendanceRuleDO attendanceRule = BeanUtils.toBean(createReqVO, HrmAttendanceRuleDO.class);
        // è®¾ç½®é»˜è®¤å€¼
        if (attendanceRule.getLateAllowMinutes() == null) {
@@ -59,10 +61,7 @@
        validateAttendanceRuleExists(updateReqVO.getId());
        // æ ¡éªŒè§„则名称是否重复
        validateAttendanceRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
        // æ ¡éªŒéƒ¨é—¨æ˜¯å¦å·²é…ç½®è§„则(如果指定了部门)
        if (updateReqVO.getDeptId() != null) {
            validateAttendanceRuleDeptUnique(updateReqVO.getId(), updateReqVO.getDeptId());
        }
        validateEffectiveDateRange(updateReqVO.getEffectiveStartDate(), updateReqVO.getEffectiveEndDate());
        HrmAttendanceRuleDO updateObj = BeanUtils.toBean(updateReqVO, HrmAttendanceRuleDO.class);
        attendanceRuleMapper.updateById(updateObj);
    }
@@ -92,15 +91,12 @@
        }
    }
    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);
            }
    /**
     * æ ¡éªŒç”Ÿæ•ˆæ—¥æœŸåŒºé—´ï¼šå¼€å§‹æ—¥æœŸä¸èƒ½æ™šäºŽç»“束日期
     */
    private void validateEffectiveDateRange(LocalDate startDate, LocalDate endDate) {
        if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
            throw exception(ATTENDANCE_RULE_EFFECTIVE_DATE_INVALID);
        }
    }
@@ -120,22 +116,47 @@
    }
    @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);
    public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId, LocalDate date) {
        LocalDate targetDate = date != null ? date : LocalDate.now();
        // 1. å…ˆæŸ¥æ‰¾éƒ¨é—¨ä¸“属规则(覆盖目标日期的,生效开始日期最晚的优先)
        if (deptId != null) {
            List<HrmAttendanceRuleDO> deptRules = selectEffectiveRules(deptId, targetDate);
            if (!deptRules.isEmpty()) {
                return deptRules.get(0);
            }
        }
        // 2. æŸ¥æ‰¾å…¨å±€è§„则(deptId ä¸º null)
        List<HrmAttendanceRuleDO> globalRules = attendanceRuleMapper.selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<HrmAttendanceRuleDO>()
                .isNull(HrmAttendanceRuleDO::getDeptId)
                .eq(HrmAttendanceRuleDO::getStatus, 0));
        List<HrmAttendanceRuleDO> globalRules = selectEffectiveRules(null, targetDate);
        if (!globalRules.isEmpty()) {
            return globalRules.get(0);
        }
        return null;
    }
}
    /**
     * æŸ¥è¯¢åœ¨æŒ‡å®šæ—¥æœŸç”Ÿæ•ˆçš„规则
     *
     * @param deptId éƒ¨é—¨ID,null è¡¨ç¤ºå…¨å±€è§„则
     * @param date   ç›®æ ‡æ—¥æœŸ
     */
    private List<HrmAttendanceRuleDO> selectEffectiveRules(Long deptId, LocalDate date) {
        LambdaQueryWrapperX<HrmAttendanceRuleDO> wrapper = new LambdaQueryWrapperX<>();
        if (deptId == null) {
            wrapper.isNull(HrmAttendanceRuleDO::getDeptId);
        } else {
            wrapper.eq(HrmAttendanceRuleDO::getDeptId, deptId);
        }
        wrapper.eq(HrmAttendanceRuleDO::getStatus, 0);
        // ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸä¸ºç©ºï¼ˆä¸é™ï¼‰æˆ– <= ç›®æ ‡æ—¥æœŸ
        wrapper.and(w -> w.isNull(HrmAttendanceRuleDO::getEffectiveStartDate)
                .or().le(HrmAttendanceRuleDO::getEffectiveStartDate, date));
        // ç”Ÿæ•ˆç»“束日期为空(不限)或 >= ç›®æ ‡æ—¥æœŸ
        wrapper.and(w -> w.isNull(HrmAttendanceRuleDO::getEffectiveEndDate)
                .or().ge(HrmAttendanceRuleDO::getEffectiveEndDate, date));
        // ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸè¶Šæ™šä¼˜å…ˆçº§è¶Šé«˜ï¼›åŒå¼€å§‹æ—¥æœŸæ—¶å–先创建的
        wrapper.orderByDesc(HrmAttendanceRuleDO::getEffectiveStartDate);
        wrapper.orderByAsc(HrmAttendanceRuleDO::getId);
        return attendanceRuleMapper.selectList(wrapper);
    }
}