| | |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO; |
| | | 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.controller.admin.attendance.vo.HrmAttendanceClockReqVO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceHolidayDO; |
| | | 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.system.api.dept.DeptApi; |
| | | import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import org.springframework.validation.annotation.Validated; |
| | |
| | | import java.time.LocalDateTime; |
| | | import java.time.LocalTime; |
| | | import java.util.List; |
| | | import cn.hutool.core.util.StrUtil; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | | import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*; |
| | |
| | | */ |
| | | @Service |
| | | @Validated |
| | | @Slf4j |
| | | public class HrmAttendanceRecordServiceImpl implements HrmAttendanceRecordService { |
| | | |
| | | @Resource |
| | |
| | | @Override |
| | | public HrmAttendanceMyStatusRespVO getMyStatus(Long loginUserId, LocalDate date) { |
| | | LocalDate queryDate = date != null ? date : LocalDate.now(); |
| | | HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO(); |
| | | respVO.setUserId(loginUserId); |
| | | respVO.setDate(queryDate); |
| | | respVO.setServerTime(LocalDateTime.now()); |
| | | // 1. 登录用户 -> 员工档案 |
| | | HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId); |
| | | if (employee == null) { |
| | | throw exception(EMPLOYEE_NOT_EXISTS, |
| | | "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。"); |
| | | HrmAttendanceMyStatusRespVO.Clockable clockable = new HrmAttendanceMyStatusRespVO.Clockable(); |
| | | clockable.setClockIn(false); |
| | | clockable.setClockOut(false); |
| | | clockable.setNextAction("none"); |
| | | clockable.setMessage("当前账号未关联员工档案,请联系管理员在员工管理中完成关联"); |
| | | respVO.setClockable(clockable); |
| | | return respVO; |
| | | } |
| | | HrmAttendanceMyStatusRespVO respVO = new HrmAttendanceMyStatusRespVO(); |
| | | respVO.setUserId(loginUserId); |
| | | respVO.setEmployeeId(employee.getId()); |
| | | respVO.setEmployeeName(employee.getName()); |
| | | respVO.setDeptId(employee.getDeptId()); |
| | |
| | | respVO.setDeptName(dept.getName()); |
| | | } |
| | | } |
| | | respVO.setDate(queryDate); |
| | | respVO.setServerTime(LocalDateTime.now()); |
| | | |
| | | // 2. 是否工作日 + 休息原因 |
| | | boolean workDay = attendanceHolidayService.isWorkDay(queryDate); |
| | |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public HrmAttendanceRecordDO clockIn(Long userId, LocalDateTime clockTime, String location) { |
| | | public HrmAttendanceClockResult clockIn(Long userId, LocalDateTime clockTime, |
| | | HrmAttendanceClockReqVO reqVO, String legacyLocation) { |
| | | LocalDate clockDate = clockTime.toLocalDate(); |
| | | // 1. 校验是否工作日 |
| | | if (!attendanceHolidayService.isWorkDay(clockDate)) { |
| | |
| | | if (record != null && record.getClockInTime() != null) { |
| | | throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED); |
| | | } |
| | | // 4. 判断打卡类型 |
| | | // 4. 在任何写库操作前校验位置;不能用旧版 location 字符串绕过范围限制。 |
| | | ClockLocation clockLocation = validateClockLocation(rule, reqVO); |
| | | // 5. 判断打卡类型 |
| | | LocalTime workStartTime = rule.getWorkStartTime(); |
| | | int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0; |
| | | LocalTime lateThreshold = workStartTime.plusMinutes(lateAllowMinutes); |
| | |
| | | Integer clockInType = clockTimeOnly.isBefore(lateThreshold) || clockTimeOnly.equals(lateThreshold) |
| | | ? HrmAttendanceClockTypeEnum.NORMAL.getType() |
| | | : HrmAttendanceClockTypeEnum.LATE.getType(); |
| | | // 5. 创建或更新考勤记录 |
| | | // 6. 创建或更新考勤记录 |
| | | if (record == null) { |
| | | record = HrmAttendanceRecordDO.builder() |
| | | .userId(userId) |
| | |
| | | .date(clockDate) |
| | | .clockInTime(clockTime) |
| | | .clockInType(clockInType) |
| | | .location(location) |
| | | .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType()) |
| | | .build(); |
| | | applyClockLocation(record, rule, clockLocation, legacyLocation, true); |
| | | attendanceRecordMapper.insert(record); |
| | | } else { |
| | | record.setClockInTime(clockTime); |
| | | record.setClockInType(clockInType); |
| | | if (location != null) { |
| | | record.setLocation(location); |
| | | } |
| | | applyClockLocation(record, rule, clockLocation, legacyLocation, true); |
| | | attendanceRecordMapper.updateById(record); |
| | | } |
| | | return record; |
| | | return new HrmAttendanceClockResult(record, rule, |
| | | clockLocation != null ? clockLocation.distanceMeters() : null); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public HrmAttendanceRecordDO clockOut(Long userId, LocalDateTime clockTime, String location) { |
| | | public HrmAttendanceClockResult clockOut(Long userId, LocalDateTime clockTime, |
| | | HrmAttendanceClockReqVO reqVO, String legacyLocation) { |
| | | LocalDate clockDate = clockTime.toLocalDate(); |
| | | // 1. 校验是否工作日 |
| | | if (!attendanceHolidayService.isWorkDay(clockDate)) { |
| | |
| | | } |
| | | // 3. 查询当日考勤记录 |
| | | HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate); |
| | | if (record != null && record.getClockOutTime() != null) { |
| | | throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED); |
| | | } |
| | | ClockLocation clockLocation = validateClockLocation(rule, reqVO); |
| | | // 4. 无上班卡时也先完成位置校验,再创建缺卡记录。 |
| | | if (record == null) { |
| | | // 如果没有上班打卡记录,创建一条缺卡的记录 |
| | | record = HrmAttendanceRecordDO.builder() |
| | | .userId(userId) |
| | | .deptId(employee.getDeptId()) |
| | |
| | | .clockInType(HrmAttendanceClockTypeEnum.MISSING.getType()) |
| | | .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType()) |
| | | .build(); |
| | | attendanceRecordMapper.insert(record); |
| | | } |
| | | if (record.getClockOutTime() != null) { |
| | | throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED); |
| | | } |
| | | // 4. 判断打卡类型 |
| | | // 5. 判断打卡类型 |
| | | LocalTime workEndTime = rule.getWorkEndTime(); |
| | | int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0; |
| | | LocalTime earlyThreshold = workEndTime.minusMinutes(earlyAllowMinutes); |
| | |
| | | Integer clockOutType = clockTimeOnly.isAfter(earlyThreshold) || clockTimeOnly.equals(earlyThreshold) |
| | | ? HrmAttendanceClockTypeEnum.NORMAL.getType() |
| | | : HrmAttendanceClockTypeEnum.EARLY.getType(); |
| | | // 5. 更新考勤记录 |
| | | // 6. 更新考勤记录 |
| | | record.setClockOutTime(clockTime); |
| | | record.setClockOutType(clockOutType); |
| | | if (location != null) { |
| | | record.setLocation(location); |
| | | } |
| | | applyClockLocation(record, rule, clockLocation, legacyLocation, false); |
| | | // 6. 计算工作时长 |
| | | if (record.getClockInTime() != null) { |
| | | Duration duration = Duration.between(record.getClockInTime(), clockTime); |
| | |
| | | } |
| | | // 7. 计算加班时长:超过规则下班时间的部分 |
| | | record.setOvertimeHours(computeOvertimeHours(rule.getWorkEndTime(), clockTimeOnly)); |
| | | attendanceRecordMapper.updateById(record); |
| | | return record; |
| | | 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()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | LocalDate exceptionDate = exception.getExceptionDate(); |
| | | LocalDateTime exceptionTime = exception.getExceptionTime(); |
| | | // 补卡时间缺失时无法回填考勤记录,记录日志并跳过,避免空指针 |
| | | if (exceptionTime == null) { |
| | | log.warn("[supplementClockIn] 补卡申请({}) 补卡时间为空,跳过考勤记录回填", exception.getId()); |
| | | return; |
| | | } |
| | | Long userId = exception.getUserId(); |
| | | HrmEmployeeDO employee = employeeService.getEmployee(userId); |
| | | HrmAttendanceRuleDO rule = employee == null ? null |
| | |
| | | attendanceRecordMapper.updateById(record); |
| | | } |
| | | |
| | | } |
| | | } |