package cn.iocoder.yudao.module.hrm.service.attendance;
|
|
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 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.List;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
|
|
/**
|
* 考勤规则配置 Service 实现类
|
*
|
* @author 超级管理员
|
*/
|
@Service
|
@Validated
|
public class HrmAttendanceRuleServiceImpl implements HrmAttendanceRuleService {
|
|
/** 规则状态:0-启用 */
|
private static final Integer ENABLED_STATUS = 0;
|
|
@Resource
|
private HrmAttendanceRuleMapper attendanceRuleMapper;
|
|
@Override
|
public Long createAttendanceRule(HrmAttendanceRuleSaveReqVO createReqVO) {
|
// 校验规则名称是否重复
|
validateAttendanceRuleNameUnique(null, createReqVO.getName());
|
// 注:同一个部门允许配置多条规则(多班次),这里不再校验部门唯一
|
HrmAttendanceRuleDO attendanceRule = BeanUtils.toBean(createReqVO, HrmAttendanceRuleDO.class);
|
// 设置默认值
|
if (attendanceRule.getLateAllowMinutes() == null) {
|
attendanceRule.setLateAllowMinutes(0);
|
}
|
if (attendanceRule.getEarlyAllowMinutes() == null) {
|
attendanceRule.setEarlyAllowMinutes(0);
|
}
|
if (attendanceRule.getOvertimeThreshold() == null) {
|
attendanceRule.setOvertimeThreshold(BigDecimal.ONE);
|
}
|
if (attendanceRule.getStatus() == null) {
|
attendanceRule.setStatus(0);
|
}
|
attendanceRuleMapper.insert(attendanceRule);
|
return attendanceRule.getId();
|
}
|
|
@Override
|
public void updateAttendanceRule(HrmAttendanceRuleSaveReqVO updateReqVO) {
|
validateAttendanceRuleExists(updateReqVO.getId());
|
// 校验规则名称是否重复
|
validateAttendanceRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
|
// 注:同一个部门允许配置多条规则(多班次),这里不再校验部门唯一
|
HrmAttendanceRuleDO updateObj = BeanUtils.toBean(updateReqVO, HrmAttendanceRuleDO.class);
|
attendanceRuleMapper.updateById(updateObj);
|
}
|
|
@Override
|
public void deleteAttendanceRule(Long id) {
|
validateAttendanceRuleExists(id);
|
attendanceRuleMapper.deleteById(id);
|
}
|
|
private void validateAttendanceRuleExists(Long id) {
|
if (attendanceRuleMapper.selectById(id) == null) {
|
throw exception(ATTENDANCE_RULE_NOT_EXISTS);
|
}
|
}
|
|
private void validateAttendanceRuleNameUnique(Long id, String name) {
|
HrmAttendanceRuleDO rule = attendanceRuleMapper.selectOne(HrmAttendanceRuleDO::getName, name);
|
if (rule == null) {
|
return;
|
}
|
if (id == null) {
|
throw exception(ATTENDANCE_RULE_NAME_EXISTS);
|
}
|
if (!rule.getId().equals(id)) {
|
throw exception(ATTENDANCE_RULE_NAME_EXISTS);
|
}
|
}
|
|
@Override
|
public HrmAttendanceRuleDO getAttendanceRule(Long id) {
|
return attendanceRuleMapper.selectById(id);
|
}
|
|
@Override
|
public PageResult<HrmAttendanceRuleDO> getAttendanceRulePage(HrmAttendanceRulePageReqVO pageReqVO) {
|
return attendanceRuleMapper.selectPage(pageReqVO);
|
}
|
|
@Override
|
public List<HrmAttendanceRuleDO> getAttendanceRuleList() {
|
return attendanceRuleMapper.selectListByStatus(0);
|
}
|
|
@Override
|
public HrmAttendanceRuleDO getAttendanceRuleByDeptId(Long deptId) {
|
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, ENABLED_STATUS));
|
if (!deptRules.isEmpty()) {
|
return matchRuleByClockTime(deptRules, clockTime);
|
}
|
// 2. 查找全局规则(deptId 为 null)
|
List<HrmAttendanceRuleDO> globalRules = attendanceRuleMapper.selectList(new LambdaQueryWrapperX<HrmAttendanceRuleDO>()
|
.isNull(HrmAttendanceRuleDO::getDeptId)
|
.eq(HrmAttendanceRuleDO::getStatus, ENABLED_STATUS));
|
if (!globalRules.isEmpty()) {
|
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);
|
}
|
|
}
|