From 997d864a76bc718d49aee6cb41d9412ac2a88d73 Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期一, 26 十月 2026 14:44:55 +0800
Subject: [PATCH] feat(hrm): 支持定位范围打卡

---
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java |  258 +++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 239 insertions(+), 19 deletions(-)

diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
index 6eab672..a2d41f8 100644
--- a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
+++ b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -1,7 +1,10 @@
 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;
@@ -31,7 +34,7 @@
 /**
  * 鑰冨嫟璁板綍 Service 瀹炵幇绫�
  *
- * @author 鑺嬮亾婧愮爜
+ * @author 瓒呯骇绠$悊鍛�
  */
 @Service
 @Validated
@@ -67,8 +70,89 @@
     }
 
     @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);
+    }
+
+    /**
+     * 鎸夎�冨嫟瑙勫垯閲嶇畻涓婁笅鐝墦鍗$被鍨嬶紙姝e父/杩熷埌/鏃╅��锛夛紝涓庢墦鍗¢�昏緫淇濇寔涓�鑷�
+     */
+    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 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)) {
@@ -88,6 +172,8 @@
         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;
@@ -104,24 +190,24 @@
                     .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)) {
@@ -138,8 +224,11 @@
         }
         // 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())
@@ -147,10 +236,6 @@
                     .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();
@@ -163,9 +248,7 @@
         // 5. 鏇存柊鑰冨嫟璁板綍
         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);
@@ -173,8 +256,145 @@
                     .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
             record.setWorkHours(workHours);
         }
-        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());
+        }
+    }
+
+    @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
@@ -223,4 +443,4 @@
         attendanceRecordMapper.updateById(record);
     }
 
-}
\ No newline at end of file
+}

--
Gitblit v1.9.3