liu
12 小时以前 690b713454828f34f901bc1e3a5f7bcc35a31b30
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -1,12 +1,18 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRuleRespVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceHolidayDO;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceHolidayTypeEnum;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceExceptionDO;
@@ -15,10 +21,7 @@
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceDataSourceEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceExceptionTypeEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceHolidayTypeEnum;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
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.transaction.annotation.Transactional;
@@ -77,12 +80,335 @@
    }
    @Override
    public void createImportedRecord(HrmAttendanceRecordDO record) {
        // 按考勤规则重算打卡类型与时长,不信任 Excel 填写的值
        fillClockTypes(record);
        fillCalculatedHours(record);
        attendanceRecordMapper.insert(record);
    }
    @Override
    public void updateImportedRecord(HrmAttendanceRecordDO record) {
        fillClockTypes(record);
        fillCalculatedHours(record);
        attendanceRecordMapper.updateById(record);
    }
    /**
     * 按考勤规则重算上下班打卡类型(正常/迟到/早退),与打卡逻辑保持一致
     */
    private void fillClockTypes(HrmAttendanceRecordDO record) {
        if (record.getDeptId() == null) {
            return;
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId(), record.getDate());
        if (rule == null) {
            return;
        }
        // 上班打卡类型:晚于 上班时间+迟到允许 记为迟到
        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());
        }
    }
    /**
     * 根据上下班打卡时间和考勤规则,计算工作时长与加班时长
     */
    private void fillCalculatedHours(HrmAttendanceRecordDO record) {
        // 工作时长 = 下班打卡时间 - 上班打卡时间(与打卡逻辑一致)
        if (record.getClockInTime() != null && record.getClockOutTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), record.getClockOutTime());
            BigDecimal workHours = BigDecimal.valueOf(duration.toMinutes())
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        // 加班时长:按考勤规则的标准下班时间计算超额部分
        if (record.getClockOutTime() != null && record.getDeptId() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(record.getDeptId(), record.getDate());
            if (rule != null && rule.getWorkEndTime() != null) {
                LocalTime workEnd = rule.getWorkEndTime();
                LocalDate outDate = record.getClockOutTime().toLocalDate();
                LocalDateTime workEndDateTime = LocalDateTime.of(outDate, workEnd);
                if (record.getClockOutTime().isAfter(workEndDateTime)) {
                    Duration overtimeDuration = Duration.between(workEndDateTime, record.getClockOutTime());
                    BigDecimal overtimeThreshold = rule.getOvertimeThreshold() != null
                            ? rule.getOvertimeThreshold() : BigDecimal.ZERO;
                    BigDecimal overtimeHours = BigDecimal.valueOf(overtimeDuration.toMinutes())
                            .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
                    record.setOvertimeHours(overtimeHours.compareTo(overtimeThreshold) > 0 ? overtimeHours : BigDecimal.ZERO);
                } else {
                    record.setOvertimeHours(BigDecimal.ZERO);
                }
            } else {
                record.setOvertimeHours(BigDecimal.ZERO);
            }
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceClockResult clockIn(Long userId, LocalDateTime clockTime,
                                            HrmAttendanceClockReqVO reqVO, String legacyLocation) {
        LocalDate clockDate = clockTime.toLocalDate();
        // 1. 校验是否工作日
        if (!attendanceHolidayService.isWorkDay(clockDate)) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY);
        }
        // 2. 获取员工信息和考勤规则
        HrmEmployeeDO employee = employeeService.getEmployee(userId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockDate);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
        // 3. 查询当日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        if (record != null && record.getClockInTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        // 在任何写库操作前完成定位校验,旧版地点字符串不能绕过范围限制。
        ClockLocation clockLocation = validateClockLocation(rule, reqVO);
        // 4. 判断打卡类型
        LocalTime workStartTime = rule.getWorkStartTime();
        int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
        LocalTime lateThreshold = workStartTime.plusMinutes(lateAllowMinutes);
        LocalTime clockTimeOnly = clockTime.toLocalTime();
        Integer clockInType = clockTimeOnly.isBefore(lateThreshold) || clockTimeOnly.equals(lateThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.LATE.getType();
        // 5. 创建或更新考勤记录
        if (record == null) {
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee.getDeptId())
                    .date(clockDate)
                    .clockInTime(clockTime)
                    .clockInType(clockInType)
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
            applyClockLocation(record, rule, clockLocation, legacyLocation, true);
            attendanceRecordMapper.insert(record);
        } else {
            record.setClockInTime(clockTime);
            record.setClockInType(clockInType);
            applyClockLocation(record, rule, clockLocation, legacyLocation, true);
            attendanceRecordMapper.updateById(record);
        }
        return new HrmAttendanceClockResult(record, rule,
                clockLocation != null ? clockLocation.distanceMeters() : null);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceClockResult clockOut(Long userId, LocalDateTime clockTime,
                                             HrmAttendanceClockReqVO reqVO, String legacyLocation) {
        LocalDate clockDate = clockTime.toLocalDate();
        // 1. 校验是否工作日
        if (!attendanceHolidayService.isWorkDay(clockDate)) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY);
        }
        // 2. 获取员工信息和考勤规则
        HrmEmployeeDO employee = employeeService.getEmployee(userId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockDate);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
        // 3. 查询当日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        if (record != null && record.getClockOutTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        ClockLocation clockLocation = validateClockLocation(rule, reqVO);
        if (record == null) {
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee.getDeptId())
                    .date(clockDate)
                    .clockInType(HrmAttendanceClockTypeEnum.MISSING.getType())
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
        }
        // 4. 判断打卡类型
        LocalTime workEndTime = rule.getWorkEndTime();
        int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
        LocalTime earlyThreshold = workEndTime.minusMinutes(earlyAllowMinutes);
        LocalTime clockTimeOnly = clockTime.toLocalTime();
        Integer clockOutType = clockTimeOnly.isAfter(earlyThreshold) || clockTimeOnly.equals(earlyThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.EARLY.getType();
        // 5. 更新考勤记录
        record.setClockOutTime(clockTime);
        record.setClockOutType(clockOutType);
        applyClockLocation(record, rule, clockLocation, legacyLocation, false);
        // 6. 计算工作时长
        if (record.getClockInTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), clockTime);
            BigDecimal workHours = BigDecimal.valueOf(duration.toMinutes())
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        if (record.getId() == null) {
            attendanceRecordMapper.insert(record);
        } else {
            attendanceRecordMapper.updateById(record);
        }
        return new HrmAttendanceClockResult(record, rule,
                clockLocation != null ? clockLocation.distanceMeters() : null);
    }
    record ClockLocation(BigDecimal longitude, BigDecimal latitude, BigDecimal accuracy,
                         BigDecimal distanceMeters) { }
    ClockLocation validateClockLocation(HrmAttendanceRuleDO rule, HrmAttendanceClockReqVO reqVO) {
        boolean required = Boolean.TRUE.equals(rule.getLocationRequired());
        if (required && (StrUtil.isBlank(rule.getLocationName()) || rule.getLongitude() == null
                || rule.getLatitude() == null || rule.getRadiusMeters() == null
                || rule.getRadiusMeters() < 10 || rule.getRadiusMeters() > 5000
                || !"WGS84".equalsIgnoreCase(rule.getCoordinateSystem()))) {
            throw exception(ATTENDANCE_LOCATION_RULE_INVALID);
        }
        if (reqVO == null || reqVO.getLongitude() == null || reqVO.getLatitude() == null
                || reqVO.getAccuracy() == null || StrUtil.isBlank(reqVO.getCoordinateSystem())) {
            if (required) throw exception(ATTENDANCE_LOCATION_REQUIRED);
            return null;
        }
        boolean coordinatesValid = reqVO.getLongitude().compareTo(BigDecimal.valueOf(-180)) >= 0
                && reqVO.getLongitude().compareTo(BigDecimal.valueOf(180)) <= 0
                && reqVO.getLatitude().compareTo(BigDecimal.valueOf(-90)) >= 0
                && reqVO.getLatitude().compareTo(BigDecimal.valueOf(90)) <= 0;
        if (!coordinatesValid) {
            if (required) throw exception(ATTENDANCE_LOCATION_COORDINATE_INVALID);
            return null;
        }
        if (reqVO.getAccuracy().signum() <= 0
                || reqVO.getAccuracy().compareTo(BigDecimal.valueOf(100)) > 0) {
            if (required) throw exception(ATTENDANCE_LOCATION_ACCURACY_INVALID);
            return null;
        }
        if (!"WGS84".equalsIgnoreCase(reqVO.getCoordinateSystem().trim())) {
            if (required) throw exception(ATTENDANCE_LOCATION_SYSTEM_INVALID);
            return null;
        }
        if (!required) {
            return new ClockLocation(reqVO.getLongitude(), reqVO.getLatitude(), reqVO.getAccuracy(), null);
        }
        double rawDistance = haversineMeters(rule.getLongitude().doubleValue(), rule.getLatitude().doubleValue(),
                reqVO.getLongitude().doubleValue(), reqVO.getLatitude().doubleValue());
        BigDecimal roundedDistance = BigDecimal.valueOf(rawDistance).setScale(2, RoundingMode.HALF_UP);
        if (rawDistance > rule.getRadiusMeters()) {
            throw exception(ATTENDANCE_LOCATION_OUT_OF_RANGE, rule.getLocationName(),
                    roundedDistance.toPlainString(), rule.getRadiusMeters());
        }
        return new ClockLocation(reqVO.getLongitude(), reqVO.getLatitude(), reqVO.getAccuracy(), roundedDistance);
    }
    static double haversineMeters(double lon1, double lat1, double lon2, double lat2) {
        double latDelta = Math.toRadians(lat2 - lat1);
        double lonDelta = Math.toRadians(lon2 - lon1);
        double a = Math.pow(Math.sin(latDelta / 2), 2)
                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                * Math.pow(Math.sin(lonDelta / 2), 2);
        double bounded = Math.max(0D, Math.min(1D, a));
        return 6_371_000D * 2D * Math.atan2(Math.sqrt(bounded), Math.sqrt(1D - bounded));
    }
    private void applyClockLocation(HrmAttendanceRecordDO record, HrmAttendanceRuleDO rule,
                                    ClockLocation location, String legacyLocation, boolean clockIn) {
        if (StrUtil.isNotBlank(rule.getLocationName())) {
            record.setLocation(rule.getLocationName());
        } else if (!Boolean.TRUE.equals(rule.getLocationRequired()) && StrUtil.isNotBlank(legacyLocation)) {
            record.setLocation(legacyLocation);
        }
        if (location == null) return;
        if (clockIn) {
            record.setClockInLongitude(location.longitude());
            record.setClockInLatitude(location.latitude());
            record.setClockInAccuracy(location.accuracy());
            record.setClockInDistance(location.distanceMeters());
            record.setClockInAddress(rule.getLocationAddress());
        } else {
            record.setClockOutLongitude(location.longitude());
            record.setClockOutLatitude(location.latitude());
            record.setClockOutAccuracy(location.accuracy());
            record.setClockOutDistance(location.distanceMeters());
            record.setClockOutAddress(rule.getLocationAddress());
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void supplementClockIn(HrmAttendanceExceptionDO exception) {
        // 仅处理补卡类型
        if (!HrmAttendanceExceptionTypeEnum.SUPPLEMENT.getType().equals(exception.getType())) {
            return;
        }
        LocalDate exceptionDate = exception.getExceptionDate();
        LocalDateTime exceptionTime = exception.getExceptionTime();
        Long userId = exception.getUserId();
        // 查询考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, exceptionDate);
        if (record == null) {
            // 如果没有记录,创建一条
            HrmEmployeeDO employee = employeeService.getEmployee(userId);
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee != null ? employee.getDeptId() : exception.getDeptId())
                    .date(exceptionDate)
                    .dataSource(HrmAttendanceDataSourceEnum.SUPPLEMENT.getType())
                    .build();
            attendanceRecordMapper.insert(record);
        }
        // 根据异常时间判断是上班还是下班补卡
        LocalTime time = exceptionTime.toLocalTime();
        if (time.isBefore(LocalTime.of(12, 0))) {
            // 上班补卡
            record.setClockInTime(exceptionTime);
            record.setClockInType(HrmAttendanceClockTypeEnum.NORMAL.getType());
        } else {
            // 下班补卡
            record.setClockOutTime(exceptionTime);
            record.setClockOutType(HrmAttendanceClockTypeEnum.NORMAL.getType());
        }
        // 更新数据来源为补卡审批
        record.setDataSource(HrmAttendanceDataSourceEnum.SUPPLEMENT.getType());
        // 计算工作时长
        if (record.getClockInTime() != null && record.getClockOutTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), record.getClockOutTime());
            BigDecimal workHours = BigDecimal.valueOf(duration.toMinutes())
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        attendanceRecordMapper.updateById(record);
    }
    // ==================== 我的考勤状态(App 打卡页) ====================
    @Override
    public HrmAttendanceMyStatusRespVO getMyStatus(Long loginUserId, LocalDate date) {
        LocalDate queryDate = date != null ? date : LocalDate.now();
        // 1. 登录用户 -> 员工档案
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
            throw new ServiceException(EMPLOYEE_NOT_EXISTS.getCode(),
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO();
@@ -106,8 +432,8 @@
            respVO.setRestDayReason(getRestDayReason(queryDate));
        }
        // 3. 当前生效规则(部门规则优先,其次全局规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId());
        // 3. 当前生效规则(部门规则优先,其次全局规则;按日期匹配生效区间)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), queryDate);
        if (rule != null) {
            HrmAttendanceRuleRespVO ruleRespVO = BeanUtils.toBean(rule, HrmAttendanceRuleRespVO.class);
            if (rule.getDeptId() != null) {
@@ -117,12 +443,21 @@
                }
            }
            respVO.setRule(ruleRespVO);
            // 兼容旧版客户端:保留平铺的定位字段
            respVO.setLocationRequired(rule.getLocationRequired());
            respVO.setLocationName(rule.getLocationName());
            respVO.setLocationAddress(rule.getLocationAddress());
            respVO.setLongitude(rule.getLongitude());
            respVO.setLatitude(rule.getLatitude());
            respVO.setRadiusMeters(rule.getRadiusMeters());
            respVO.setCoordinateSystem(rule.getCoordinateSystem());
        }
        // 4. 当日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(employee.getId(), queryDate);
        if (record != null) {
            respVO.setRecord(buildRecordRespVO(record, employee));
            respVO.setRecordId(record.getId());
        }
        // 5. 当前可打卡状态
@@ -151,7 +486,7 @@
    }
    /**
     * 构建打卡记录返回 VO,补齐打卡类型名称、员工与部门信息
     * 构建打卡记录返回 VO,补齐打卡类型名称与员工信息
     */
    private HrmAttendanceRecordRespVO buildRecordRespVO(HrmAttendanceRecordDO record, HrmEmployeeDO employee) {
        HrmAttendanceRecordRespVO recordRespVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
@@ -159,19 +494,12 @@
        recordRespVO.setClockOutTypeName(HrmAttendanceClockTypeEnum.getNameByType(record.getClockOutType()));
        if (employee != null) {
            recordRespVO.setUserName(employee.getName());
            recordRespVO.setPhone(employee.getPhone());
        }
        if (record.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(record.getDeptId());
            if (dept != null) {
                recordRespVO.setDeptName(dept.getName());
            }
        }
        return recordRespVO;
    }
    /**
     * 根据工作日、考勤规则与当日记录计算可打卡状态
     * 计算当前可打卡状态
     */
    private HrmAttendanceMyStatusRespVO.Clockable buildClockable(boolean workDay, HrmAttendanceRuleDO rule,
                                                                 HrmAttendanceRecordDO record, LocalDate date) {
@@ -232,248 +560,4 @@
        return clockable;
    }
    @Override
    public void createImportedRecord(HrmAttendanceRecordDO record) {
        // 按考勤规则重算打卡类型与时长,不信任 Excel 填写的值
        fillClockTypes(record);
        fillCalculatedHours(record);
        attendanceRecordMapper.insert(record);
    }
    @Override
    public void updateImportedRecord(HrmAttendanceRecordDO record) {
        fillClockTypes(record);
        fillCalculatedHours(record);
        attendanceRecordMapper.updateById(record);
    }
    /**
     * 按考勤规则重算上下班打卡类型(正常/迟到/早退),与打卡逻辑保持一致
     */
    private void fillClockTypes(HrmAttendanceRecordDO record) {
        if (record.getDeptId() == 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.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());
            }
        }
    }
    /**
     * 根据上下班打卡时间和考勤规则,计算工作时长与加班时长
     */
    private void fillCalculatedHours(HrmAttendanceRecordDO record) {
        // 工作时长 = 下班打卡时间 - 上班打卡时间(与打卡逻辑一致)
        if (record.getClockInTime() != null && record.getClockOutTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), record.getClockOutTime());
            BigDecimal workHours = BigDecimal.valueOf(duration.toMinutes())
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        // 加班时长:按考勤规则的标准下班时间计算超额部分(按下班打卡时间匹配班次)
        if (record.getClockOutTime() != null && record.getDeptId() != null) {
            HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(
                    record.getDeptId(), record.getClockOutTime());
            if (rule != null && rule.getWorkEndTime() != null) {
                LocalTime workEnd = rule.getWorkEndTime();
                LocalDate outDate = record.getClockOutTime().toLocalDate();
                LocalDateTime workEndDateTime = LocalDateTime.of(outDate, workEnd);
                if (record.getClockOutTime().isAfter(workEndDateTime)) {
                    Duration overtimeDuration = Duration.between(workEndDateTime, record.getClockOutTime());
                    BigDecimal overtimeThreshold = rule.getOvertimeThreshold() != null
                            ? rule.getOvertimeThreshold() : BigDecimal.ZERO;
                    BigDecimal overtimeHours = BigDecimal.valueOf(overtimeDuration.toMinutes())
                            .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
                    record.setOvertimeHours(overtimeHours.compareTo(overtimeThreshold) > 0 ? overtimeHours : BigDecimal.ZERO);
                } else {
                    record.setOvertimeHours(BigDecimal.ZERO);
                }
            } else {
                record.setOvertimeHours(BigDecimal.ZERO);
            }
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceRecordDO clockIn(Long userId, LocalDateTime clockTime, String location) {
        LocalDate clockDate = clockTime.toLocalDate();
        // 1. 校验是否工作日
        if (!attendanceHolidayService.isWorkDay(clockDate)) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY);
        }
        // 2. 获取员工信息和考勤规则
        HrmEmployeeDO employee = employeeService.getEmployee(userId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        // 按本次打卡时间匹配班次(同一个部门可能配置了多条规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockTime);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
        // 3. 查询当日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        if (record != null && record.getClockInTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        // 4. 判断打卡类型
        LocalTime workStartTime = rule.getWorkStartTime();
        int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
        LocalTime lateThreshold = workStartTime.plusMinutes(lateAllowMinutes);
        LocalTime clockTimeOnly = clockTime.toLocalTime();
        Integer clockInType = clockTimeOnly.isBefore(lateThreshold) || clockTimeOnly.equals(lateThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.LATE.getType();
        // 5. 创建或更新考勤记录
        if (record == null) {
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee.getDeptId())
                    .date(clockDate)
                    .clockInTime(clockTime)
                    .clockInType(clockInType)
                    .location(location)
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
            attendanceRecordMapper.insert(record);
        } else {
            record.setClockInTime(clockTime);
            record.setClockInType(clockInType);
            if (location != null) {
                record.setLocation(location);
            }
            attendanceRecordMapper.updateById(record);
        }
        return record;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceRecordDO clockOut(Long userId, LocalDateTime clockTime, String location) {
        LocalDate clockDate = clockTime.toLocalDate();
        // 1. 校验是否工作日
        if (!attendanceHolidayService.isWorkDay(clockDate)) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY);
        }
        // 2. 获取员工信息和考勤规则
        HrmEmployeeDO employee = employeeService.getEmployee(userId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS);
        }
        // 按本次打卡时间匹配班次(同一个部门可能配置了多条规则)
        HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId(), clockTime);
        if (rule == null) {
            throw exception(ATTENDANCE_CLOCK_FAIL_NO_RULE);
        }
        // 3. 查询当日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        if (record == null) {
            // 如果没有上班打卡记录,创建一条缺卡的记录
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee.getDeptId())
                    .date(clockDate)
                    .clockInType(HrmAttendanceClockTypeEnum.MISSING.getType())
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
            attendanceRecordMapper.insert(record);
        }
        if (record.getClockOutTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        // 4. 判断打卡类型
        LocalTime workEndTime = rule.getWorkEndTime();
        int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
        LocalTime earlyThreshold = workEndTime.minusMinutes(earlyAllowMinutes);
        LocalTime clockTimeOnly = clockTime.toLocalTime();
        Integer clockOutType = clockTimeOnly.isAfter(earlyThreshold) || clockTimeOnly.equals(earlyThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.EARLY.getType();
        // 5. 更新考勤记录
        record.setClockOutTime(clockTime);
        record.setClockOutType(clockOutType);
        if (location != null) {
            record.setLocation(location);
        }
        // 6. 计算工作时长
        if (record.getClockInTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), clockTime);
            BigDecimal workHours = BigDecimal.valueOf(duration.toMinutes())
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        attendanceRecordMapper.updateById(record);
        return record;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void supplementClockIn(HrmAttendanceExceptionDO exception) {
        // 仅处理补卡类型
        if (!HrmAttendanceExceptionTypeEnum.SUPPLEMENT.getType().equals(exception.getType())) {
            return;
        }
        LocalDate exceptionDate = exception.getExceptionDate();
        LocalDateTime exceptionTime = exception.getExceptionTime();
        Long userId = exception.getUserId();
        // 查询考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, exceptionDate);
        if (record == null) {
            // 如果没有记录,创建一条
            HrmEmployeeDO employee = employeeService.getEmployee(userId);
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee != null ? employee.getDeptId() : exception.getDeptId())
                    .date(exceptionDate)
                    .dataSource(HrmAttendanceDataSourceEnum.SUPPLEMENT.getType())
                    .build();
            attendanceRecordMapper.insert(record);
        }
        // 根据异常时间判断是上班还是下班补卡
        LocalTime time = exceptionTime.toLocalTime();
        if (time.isBefore(LocalTime.of(12, 0))) {
            // 上班补卡
            record.setClockInTime(exceptionTime);
            record.setClockInType(HrmAttendanceClockTypeEnum.NORMAL.getType());
        } else {
            // 下班补卡
            record.setClockOutTime(exceptionTime);
            record.setClockOutType(HrmAttendanceClockTypeEnum.NORMAL.getType());
        }
        // 更新数据来源为补卡审批
        record.setDataSource(HrmAttendanceDataSourceEnum.SUPPLEMENT.getType());
        // 计算工作时长
        if (record.getClockInTime() != null && record.getClockOutTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), record.getClockOutTime());
            BigDecimal workHours = BigDecimal.valueOf(duration.toMinutes())
                    .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
            record.setWorkHours(workHours);
        }
        attendanceRecordMapper.updateById(record);
    }
}
}