package cn.iocoder.yudao.module.hrm.service.attendance; import cn.hutool.core.util.StrUtil; 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.HrmAttendanceClockReqVO; import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO; import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO; 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; import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO; import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRecordMapper; 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.service.employee.HrmEmployeeService; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; 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 HrmAttendanceRecordServiceImpl implements HrmAttendanceRecordService { @Resource private HrmAttendanceRecordMapper attendanceRecordMapper; @Resource private HrmEmployeeService employeeService; @Resource private HrmAttendanceRuleService attendanceRuleService; @Resource private HrmAttendanceHolidayService attendanceHolidayService; @Override public PageResult getAttendanceRecordPage(HrmAttendanceRecordPageReqVO pageReqVO) { return attendanceRecordMapper.selectPage(pageReqVO); } @Override public HrmAttendanceRecordDO getAttendanceRecord(Long id) { return attendanceRecordMapper.selectById(id); } @Override public List getAttendanceRecordListByUserIdAndDateBetween(Long userId, String startDate, String endDate) { return attendanceRecordMapper.selectListByUserIdAndDateBetween(userId, LocalDate.parse(startDate), LocalDate.parse(endDate)); } @Override public HrmAttendanceRecordDO getAttendanceRecordByUserIdAndDate(Long userId, LocalDate date) { return attendanceRecordMapper.selectByUserIdAndDate(userId, date); } @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()); 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()); 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()); 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()); 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 public HrmAttendanceMyStatusRespVO getMyStatus(Long userId) { // 1. 员工不存在时返回 null,由前端提示未关联员工,避免打卡页加载直接报错 HrmEmployeeDO employee = employeeService.getEmployee(userId); if (employee == null) { return null; } LocalDate today = LocalDate.now(); // 2. 查询今日考勤记录与考勤规则 HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, today); HrmAttendanceRuleDO rule = attendanceRuleService.getAttendanceRuleByDeptId(employee.getDeptId()); boolean workDay = attendanceHolidayService.isWorkDay(today); boolean clockInDone = record != null && record.getClockInTime() != null; boolean clockOutDone = record != null && record.getClockOutTime() != null; // 3. 组装今日打卡状态 HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO(); respVO.setDate(today); respVO.setWorkDay(workDay); respVO.setEmployeeId(employee.getId()); respVO.setUserName(employee.getName()); respVO.setDeptId(employee.getDeptId()); if (rule != null) { respVO.setRuleName(rule.getName()); respVO.setWorkStartTime(rule.getWorkStartTime()); respVO.setWorkEndTime(rule.getWorkEndTime()); 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()); } if (record != null) { respVO.setRecordId(record.getId()); respVO.setClockInTime(record.getClockInTime()); respVO.setClockOutTime(record.getClockOutTime()); respVO.setClockInType(record.getClockInType()); respVO.setClockOutType(record.getClockOutType()); respVO.setWorkHours(record.getWorkHours()); respVO.setOvertimeHours(record.getOvertimeHours()); respVO.setLocation(record.getLocation()); respVO.setDataSource(record.getDataSource()); } respVO.setClockInDone(clockInDone); respVO.setClockOutDone(clockOutDone); // 非工作日不可打卡,与打卡接口的校验保持一致 respVO.setCanClockIn(workDay && !clockInDone); respVO.setCanClockOut(workDay && !clockOutDone); return respVO; } @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); } }