liu
16 小时以前 997d864a76bc718d49aee6cb41d9412ac2a88d73
feat(hrm): 支持定位范围打卡
已修改11个文件
已添加5个文件
589 ■■■■■ 文件已修改
sql/mysql/hrm_attendance_location_20261024.sql 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockReqVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockRespVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceMyStatusRespVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRecordDO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockResult.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockLocationTest.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_attendance_location_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
-- è€ƒå‹¤å®šä½å­—段迁移。旧规则保持 location_required=0,先配置地点再启用范围限制。
DELIMITER $$
DROP PROCEDURE IF EXISTS xgdl_hrm_attendance_add_column_20261024$$
CREATE PROCEDURE xgdl_hrm_attendance_add_column_20261024(
    IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_ddl_sql VARCHAR(1000))
BEGIN
    IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
                   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table_name AND COLUMN_NAME = p_column_name) THEN
        SET @hrm_location_ddl = p_ddl_sql;
        PREPARE hrm_location_stmt FROM @hrm_location_ddl;
        EXECUTE hrm_location_stmt;
        DEALLOCATE PREPARE hrm_location_stmt;
    END IF;
END$$
DELIMITER ;
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','location_name',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN location_name varchar(100) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','location_address',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN location_address varchar(255) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','longitude',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN longitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','latitude',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN latitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','radius_meters',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN radius_meters int DEFAULT 10');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','location_required',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN location_required bit(1) NOT NULL DEFAULT b''0''');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','coordinate_system',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN coordinate_system varchar(20) DEFAULT ''WGS84''');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_longitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_longitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_latitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_latitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_accuracy',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_accuracy decimal(8,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_distance',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_distance decimal(10,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_address',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_address varchar(255) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_longitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_longitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_latitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_latitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_accuracy',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_accuracy decimal(8,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_distance',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_distance decimal(10,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_address',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_address varchar(255) NULL');
DROP PROCEDURE xgdl_hrm_attendance_add_column_20261024;
-- å¯¹é½è€ƒå‹¤è§„则与打卡按钮权限码,确保 Web å’Œ APP æ™®é€šè§’色可以授权使用。
UPDATE `system_menu` SET `permission` = 'hrm:attendance-rule:query' WHERE `id` = 60131;
UPDATE `system_menu` SET `permission` = 'hrm:attendance-rule:create' WHERE `id` = 60132;
UPDATE `system_menu` SET `permission` = 'hrm:attendance-rule:update' WHERE `id` = 60133;
UPDATE `system_menu` SET `permission` = 'hrm:attendance-rule:delete' WHERE `id` = 60134;
UPDATE `system_menu` SET `permission` = 'hrm:attendance-rule:export' WHERE `id` = 60135;
INSERT INTO `system_menu`
(`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`,
 `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`,
 `updater`, `update_time`, `deleted`)
VALUES
(60114, '定位打卡', 'hrm:attendance-record:clock', 3, 4, 6011, '', '', '', '',
 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0')
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `permission` = VALUES(`permission`), `status` = 0, `deleted` = b'0';
-- å·²æ‹¥æœ‰â€œè€ƒå‹¤è®°å½•”菜单的角色自动获得定位打卡按钮权限。
INSERT INTO `system_role_menu`
(`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`)
SELECT DISTINCT parent_role.`role_id`, 60114, '1', NOW(), '1', NOW(), b'0', parent_role.`tenant_id`
FROM `system_role_menu` parent_role
WHERE parent_role.`menu_id` = 6011 AND parent_role.`deleted` = b'0'
  AND NOT EXISTS (
      SELECT 1 FROM `system_role_menu` current_grant
      WHERE current_grant.`role_id` = parent_role.`role_id`
        AND current_grant.`menu_id` = 60114
        AND current_grant.`tenant_id` = parent_role.`tenant_id`
        AND current_grant.`deleted` = b'0'
  );
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -94,6 +94,12 @@
    ErrorCode ATTENDANCE_EXCEPTION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_004_015, "考勤异常审批流程不存在");
    ErrorCode ATTENDANCE_CLOCK_ALREADY_CLOCKED = new ErrorCode(1_030_004_016, "已打过卡,请勿重复打卡");
    ErrorCode ATTENDANCE_HOLIDAY_DATE_RANGE_INVALID = new ErrorCode(1_030_004_017, "结束日期【{}】不能早于开始日期【{}】,无法保存。请重新选择正确的日期区间");
    ErrorCode ATTENDANCE_LOCATION_RULE_INVALID = new ErrorCode(1_030_004_050, "考勤规则地点配置不完整,请联系管理员");
    ErrorCode ATTENDANCE_LOCATION_REQUIRED = new ErrorCode(1_030_004_051, "请开启定位并提供经纬度、精度和坐标系后打卡");
    ErrorCode ATTENDANCE_LOCATION_COORDINATE_INVALID = new ErrorCode(1_030_004_052, "打卡经纬度超出有效范围");
    ErrorCode ATTENDANCE_LOCATION_ACCURACY_INVALID = new ErrorCode(1_030_004_053, "定位精度须大于 0 ä¸”不超过 100 ç±³ï¼Œè¯·åˆ°å¼€é˜”位置重试");
    ErrorCode ATTENDANCE_LOCATION_SYSTEM_INVALID = new ErrorCode(1_030_004_054, "打卡坐标系必须为 WGS84");
    ErrorCode ATTENDANCE_LOCATION_OUT_OF_RANGE = new ErrorCode(1_030_004_055, "当前位置距“{}”{}米,超过允许范围{}ç±³");
    // ========== è–ªé…¬ç®¡ç† 1-030-005-000 ==========
    ErrorCode SALARY_STRUCTURE_NOT_EXISTS = new ErrorCode(1_030_005_000, "薪酬结构不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java
@@ -7,6 +7,8 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportReqVO;
@@ -14,6 +16,7 @@
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordRespVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.service.attendance.HrmAttendanceClockResult;
import cn.iocoder.yudao.module.hrm.service.attendance.HrmAttendanceRecordService;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
@@ -70,34 +73,38 @@
    @PostMapping("/clock-in")
    @Operation(summary = "上班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceRecordRespVO> clockIn(@RequestParam("location") String location) {
    @ApiAccessLog(requestEnable = false)
    public CommonResult<HrmAttendanceClockRespVO> clockIn(
            @Valid @RequestBody(required = false) HrmAttendanceClockReqVO reqVO,
            @RequestParam(value = "location", required = false) String legacyLocation) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS, "当前用户未关联员工信息,无法打卡");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockIn(employee.getId(), LocalDateTime.now(), location);
        HrmAttendanceRecordRespVO respVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
        respVO.setUserName(employee.getName());
        if (record.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(record.getDeptId());
            if (dept != null) {
                respVO.setDeptName(dept.getName());
            }
        }
        return success(respVO);
        HrmAttendanceClockResult result = attendanceRecordService.clockIn(
                employee.getId(), LocalDateTime.now(), reqVO, legacyLocation);
        return success(buildClockResultVO(result, employee));
    }
    @PostMapping("/clock-out")
    @Operation(summary = "下班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceRecordRespVO> clockOut(@RequestParam("location") String location) {
    @ApiAccessLog(requestEnable = false)
    public CommonResult<HrmAttendanceClockRespVO> clockOut(
            @Valid @RequestBody(required = false) HrmAttendanceClockReqVO reqVO,
            @RequestParam(value = "location", required = false) String legacyLocation) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS, "当前用户未关联员工信息,无法打卡");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockOut(employee.getId(), LocalDateTime.now(), location);
        HrmAttendanceClockResult result = attendanceRecordService.clockOut(
                employee.getId(), LocalDateTime.now(), reqVO, legacyLocation);
        return success(buildClockResultVO(result, employee));
    }
    private HrmAttendanceRecordRespVO buildClockRespVO(HrmAttendanceRecordDO record, HrmEmployeeDO employee) {
        HrmAttendanceRecordRespVO respVO = BeanUtils.toBean(record, HrmAttendanceRecordRespVO.class);
        respVO.setUserName(employee.getName());
        if (record.getDeptId() != null) {
@@ -106,7 +113,20 @@
                respVO.setDeptName(dept.getName());
            }
        }
        return success(respVO);
        return respVO;
    }
    private HrmAttendanceClockRespVO buildClockResultVO(HrmAttendanceClockResult result,
                                                         HrmEmployeeDO employee) {
        HrmAttendanceClockRespVO respVO = new HrmAttendanceClockRespVO();
        respVO.setRecord(buildClockRespVO(result.record(), employee));
        respVO.setLocationName(result.rule().getLocationName());
        if (Boolean.TRUE.equals(result.rule().getLocationRequired())) {
            respVO.setDistanceMeters(result.distanceMeters());
            respVO.setRadiusMeters(result.rule().getRadiusMeters());
            respVO.setWithinRange(true);
        }
        return respVO;
    }
    private Long getLoginUserId() {
@@ -310,4 +330,4 @@
        ExcelUtils.write(response, "考勤记录.xls", "数据", HrmAttendanceRecordRespVO.class, list);
    }
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/** App/Web æ‰“卡定位原始数据;是否必填由当前生效规则决定。 */
@Data
public class HrmAttendanceClockReqVO {
    @Schema(description = "设备经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "设备纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "定位精度,米")
    private BigDecimal accuracy;
    @Schema(description = "坐标系,仅支持 WGS84")
    private String coordinateSystem;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class HrmAttendanceClockRespVO {
    @Schema(description = "考勤记录")
    private HrmAttendanceRecordRespVO record;
    @Schema(description = "规则地点名称")
    private String locationName;
    @Schema(description = "距规则地点距离,米;未启用位置限制时为空")
    private BigDecimal distanceMeters;
    @Schema(description = "允许半径,米;未启用位置限制时为空")
    private Integer radiusMeters;
    @Schema(description = "是否在允许范围;未启用位置限制时为空")
    private Boolean withinRange;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceMyStatusRespVO.java
@@ -39,6 +39,27 @@
    @Schema(description = "下班时间")
    private LocalTime workEndTime;
    @Schema(description = "是否启用定位范围校验")
    private Boolean locationRequired;
    @Schema(description = "打卡地点名称")
    private String locationName;
    @Schema(description = "打卡地点地址")
    private String locationAddress;
    @Schema(description = "打卡中心经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "打卡中心纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "允许打卡半径,单位米")
    private Integer radiusMeters;
    @Schema(description = "坐标系")
    private String coordinateSystem;
    @Schema(description = "今日记录ID,未打卡时为空")
    private Long recordId;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java
@@ -72,6 +72,27 @@
    @ExcelProperty("打卡地点")
    private String location;
    @Schema(description = "上班打卡经度(WGS84)")
    private BigDecimal clockInLongitude;
    @Schema(description = "上班打卡纬度(WGS84)")
    private BigDecimal clockInLatitude;
    @Schema(description = "上班定位精度,米")
    private BigDecimal clockInAccuracy;
    @Schema(description = "上班距规则地点距离,米")
    private BigDecimal clockInDistance;
    @Schema(description = "上班规则地点地址快照")
    private String clockInAddress;
    @Schema(description = "下班打卡经度(WGS84)")
    private BigDecimal clockOutLongitude;
    @Schema(description = "下班打卡纬度(WGS84)")
    private BigDecimal clockOutLatitude;
    @Schema(description = "下班定位精度,米")
    private BigDecimal clockOutAccuracy;
    @Schema(description = "下班距规则地点距离,米")
    private BigDecimal clockOutDistance;
    @Schema(description = "下班规则地点地址快照")
    private String clockOutAddress;
    @Schema(description = "备注")
    @ExcelProperty("备注")
    private String remark;
@@ -85,4 +106,4 @@
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java
@@ -51,6 +51,21 @@
    @ExcelProperty("加班起算时长")
    private BigDecimal overtimeThreshold;
    @Schema(description = "打卡地点名称")
    private String locationName;
    @Schema(description = "打卡地点地址")
    private String locationAddress;
    @Schema(description = "地点经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "地点纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "允许打卡半径,单位米")
    private Integer radiusMeters;
    @Schema(description = "是否启用位置限制")
    private Boolean locationRequired;
    @Schema(description = "坐标系,仅支持 WGS84")
    private String coordinateSystem;
    @Schema(description = "状态:0-启用,1-禁用")
    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat("hrm_rule_status")
@@ -64,4 +79,4 @@
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java
@@ -5,6 +5,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.AssertTrue;
import java.math.BigDecimal;
import java.time.LocalTime;
@@ -39,10 +40,36 @@
    @Schema(description = "加班起算时长(小时)", example = "1")
    private BigDecimal overtimeThreshold;
    @Schema(description = "打卡地点名称")
    private String locationName;
    @Schema(description = "打卡地点地址")
    private String locationAddress;
    @Schema(description = "地点经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "地点纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "允许打卡半径,单位米")
    private Integer radiusMeters;
    @Schema(description = "是否启用位置限制")
    private Boolean locationRequired;
    @Schema(description = "坐标系,仅支持 WGS84")
    private String coordinateSystem;
    @AssertTrue(message = "启用定位打卡时,地点名称、经纬度和10~5000米打卡半径必须完整配置")
    public boolean isLocationConfigValid() {
        if (!Boolean.TRUE.equals(locationRequired)) {
            return true;
        }
        return locationName != null && !locationName.isBlank()
                && longitude != null && latitude != null && radiusMeters != null
                && radiusMeters >= 10 && radiusMeters <= 5000
                && "WGS84".equalsIgnoreCase(coordinateSystem);
    }
    @Schema(description = "状态:0-启用,1-禁用", example = "0")
    private Integer status;
    @Schema(description = "备注", example = "适用于研发部门")
    private String remark;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRecordDO.java
@@ -83,6 +83,17 @@
     */
    private String location;
    private BigDecimal clockInLongitude;
    private BigDecimal clockInLatitude;
    private BigDecimal clockInAccuracy;
    private BigDecimal clockInDistance;
    private String clockInAddress;
    private BigDecimal clockOutLongitude;
    private BigDecimal clockOutLatitude;
    private BigDecimal clockOutAccuracy;
    private BigDecimal clockOutDistance;
    private String clockOutAddress;
    /**
     * å¤‡æ³¨
     */
@@ -93,4 +104,4 @@
     */
    private Integer dataSource;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java
@@ -67,6 +67,15 @@
     */
    private BigDecimal overtimeThreshold;
    /** è€ƒå‹¤åœ°ç‚¹ä¸Žå¯æ‰“卡范围;旧规则默认不启用位置限制。 */
    private String locationName;
    private String locationAddress;
    private BigDecimal longitude;
    private BigDecimal latitude;
    private Integer radiusMeters;
    private Boolean locationRequired;
    private String coordinateSystem;
    /**
     * çŠ¶æ€ï¼š0-启用,1-禁用
     */
@@ -77,4 +86,4 @@
     */
    private String remark;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import java.math.BigDecimal;
/** æ‰“卡保存结果和本次校验使用的规则快照。 */
public record HrmAttendanceClockResult(HrmAttendanceRecordDO record, HrmAttendanceRuleDO rule,
                                       BigDecimal distanceMeters) {
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
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;
@@ -49,20 +50,24 @@
     *
     * @param userId å‘˜å·¥ID
     * @param clockTime æ‰“卡时间
     * @param location æ‰“卡地点
     * @return è€ƒå‹¤è®°å½•
     * @param reqVO åŽŸå§‹å®šä½ä¿¡æ¯ï¼ˆWGS84)
     * @param legacyLocation æ—§ç‰ˆåœ°ç‚¹æ–‡æœ¬ï¼Œä»…在未启用位置限制时兼容使用
     * @return æ‰“卡记录、规则和距离
     */
    HrmAttendanceRecordDO clockIn(Long userId, LocalDateTime clockTime, String location);
    HrmAttendanceClockResult clockIn(Long userId, LocalDateTime clockTime,
                                     HrmAttendanceClockReqVO reqVO, String legacyLocation);
    /**
     * æ‰“卡(下班打卡)
     *
     * @param userId å‘˜å·¥ID
     * @param clockTime æ‰“卡时间
     * @param location æ‰“卡地点
     * @return è€ƒå‹¤è®°å½•
     * @param reqVO åŽŸå§‹å®šä½ä¿¡æ¯ï¼ˆWGS84)
     * @param legacyLocation æ—§ç‰ˆåœ°ç‚¹æ–‡æœ¬ï¼Œä»…在未启用位置限制时兼容使用
     * @return æ‰“卡记录、规则和距离
     */
    HrmAttendanceRecordDO clockOut(Long userId, LocalDateTime clockTime, String location);
    HrmAttendanceClockResult clockOut(Long userId, LocalDateTime clockTime,
                                      HrmAttendanceClockReqVO reqVO, String legacyLocation);
    /**
     * èŽ·å–å‘˜å·¥ä»Šæ—¥æ‰“å¡çŠ¶æ€
@@ -79,4 +84,4 @@
     */
    void supplementClockIn(HrmAttendanceExceptionDO exception);
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -1,7 +1,9 @@
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;
@@ -149,7 +151,8 @@
    @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)) {
@@ -169,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;
@@ -185,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)) {
@@ -219,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())
@@ -228,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();
@@ -244,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);
@@ -254,8 +256,92 @@
                    .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
@@ -284,6 +370,13 @@
            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());
@@ -350,4 +443,4 @@
        attendanceRecordMapper.updateById(record);
    }
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java
@@ -1,5 +1,6 @@
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.HrmAttendanceRulePageReqVO;
@@ -30,6 +31,7 @@
    @Override
    public Long createAttendanceRule(HrmAttendanceRuleSaveReqVO createReqVO) {
        validateLocation(createReqVO);
        // æ ¡éªŒè§„则名称是否重复
        validateAttendanceRuleNameUnique(null, createReqVO.getName());
        // æ ¡éªŒéƒ¨é—¨æ˜¯å¦å·²é…ç½®è§„则(如果指定了部门)
@@ -56,7 +58,21 @@
    @Override
    public void updateAttendanceRule(HrmAttendanceRuleSaveReqVO updateReqVO) {
        validateAttendanceRuleExists(updateReqVO.getId());
        HrmAttendanceRuleDO currentRule = attendanceRuleMapper.selectById(updateReqVO.getId());
        if (currentRule == null) {
            throw exception(ATTENDANCE_RULE_NOT_EXISTS);
        }
        // å…¼å®¹æ—§ç®¡ç†ç«¯ï¼šæ²¡æœ‰æäº¤åœ°ç‚¹å­—段时,不清空已配置的位置限制。
        if (updateReqVO.getLocationRequired() == null) {
            updateReqVO.setLocationRequired(currentRule.getLocationRequired());
            if (updateReqVO.getLocationName() == null) updateReqVO.setLocationName(currentRule.getLocationName());
            if (updateReqVO.getLocationAddress() == null) updateReqVO.setLocationAddress(currentRule.getLocationAddress());
            if (updateReqVO.getLongitude() == null) updateReqVO.setLongitude(currentRule.getLongitude());
            if (updateReqVO.getLatitude() == null) updateReqVO.setLatitude(currentRule.getLatitude());
            if (updateReqVO.getRadiusMeters() == null) updateReqVO.setRadiusMeters(currentRule.getRadiusMeters());
            if (updateReqVO.getCoordinateSystem() == null) updateReqVO.setCoordinateSystem(currentRule.getCoordinateSystem());
        }
        validateLocation(updateReqVO);
        // æ ¡éªŒè§„则名称是否重复
        validateAttendanceRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
        // æ ¡éªŒéƒ¨é—¨æ˜¯å¦å·²é…ç½®è§„则(如果指定了部门)
@@ -76,6 +92,39 @@
    private void validateAttendanceRuleExists(Long id) {
        if (attendanceRuleMapper.selectById(id) == null) {
            throw exception(ATTENDANCE_RULE_NOT_EXISTS);
        }
    }
    private void validateLocation(HrmAttendanceRuleSaveReqVO reqVO) {
        if (reqVO.getLocationRequired() == null) {
            reqVO.setLocationRequired(false);
        }
        if (reqVO.getRadiusMeters() == null) {
            reqVO.setRadiusMeters(10);
        }
        if (StrUtil.isBlank(reqVO.getCoordinateSystem())) {
            reqVO.setCoordinateSystem("WGS84");
        } else {
            reqVO.setCoordinateSystem(reqVO.getCoordinateSystem().trim().toUpperCase(java.util.Locale.ROOT));
        }
        if (!"WGS84".equals(reqVO.getCoordinateSystem())) {
            throw exception(ATTENDANCE_LOCATION_SYSTEM_INVALID);
        }
        if (reqVO.getLongitude() != null && (reqVO.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
                || reqVO.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0)) {
            throw exception(ATTENDANCE_LOCATION_COORDINATE_INVALID);
        }
        if (reqVO.getLatitude() != null && (reqVO.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
                || reqVO.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0)) {
            throw exception(ATTENDANCE_LOCATION_COORDINATE_INVALID);
        }
        if (reqVO.getRadiusMeters() < 10 || reqVO.getRadiusMeters() > 5000) {
            throw exception(ATTENDANCE_LOCATION_RULE_INVALID);
        }
        if (Boolean.TRUE.equals(reqVO.getLocationRequired())
                && (StrUtil.isBlank(reqVO.getLocationName()) || reqVO.getLongitude() == null
                || reqVO.getLatitude() == null)) {
            throw exception(ATTENDANCE_LOCATION_RULE_INVALID);
        }
    }
@@ -138,4 +187,4 @@
        return null;
    }
}
}
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockLocationTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
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.service.employee.HrmEmployeeService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class HrmAttendanceClockLocationTest {
    private final HrmAttendanceRecordServiceImpl service = new HrmAttendanceRecordServiceImpl();
    private HrmAttendanceRuleDO requiredRule() {
        return HrmAttendanceRuleDO.builder().locationRequired(true).locationName("公司前台")
                .longitude(BigDecimal.ZERO).latitude(BigDecimal.ZERO)
                .radiusMeters(100).coordinateSystem("WGS84").build();
    }
    private HrmAttendanceClockReqVO position(String latitude, String accuracy) {
        HrmAttendanceClockReqVO reqVO = new HrmAttendanceClockReqVO();
        reqVO.setLongitude(BigDecimal.ZERO);
        reqVO.setLatitude(new BigDecimal(latitude));
        reqVO.setAccuracy(new BigDecimal(accuracy));
        reqVO.setCoordinateSystem("WGS84");
        return reqVO;
    }
    @Test
    void oldRuleWithoutLocationStillAcceptsClock() {
        HrmAttendanceRuleDO rule = HrmAttendanceRuleDO.builder().locationRequired(false).build();
        assertNull(service.validateClockLocation(rule, null));
        assertNull(service.validateClockLocation(rule, position("0.0001", "20")).distanceMeters());
    }
    @Test
    void requiredLocationValidatesCompletenessCoordinatesAndAccuracy() {
        HrmAttendanceRuleDO rule = requiredRule();
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, null));
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, position("91", "20")));
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, position("0", "100.01")));
        assertNotNull(service.validateClockLocation(rule, position("0", "100")));
    }
    @Test
    void comparesUnroundedDistanceWithRadius() {
        HrmAttendanceRuleDO rule = requiredRule();
        assertTrue(HrmAttendanceRecordServiceImpl.haversineMeters(0, 0, 0, 0.00089) < 100);
        assertNotNull(service.validateClockLocation(rule, position("0.00089", "20")));
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, position("0.00091", "20")));
    }
    @Test
    void invalidClockOutLocationDoesNotCreateMissingClockInRecord() {
        HrmAttendanceHolidayService holidayService = mock(HrmAttendanceHolidayService.class);
        HrmEmployeeService employeeService = mock(HrmEmployeeService.class);
        HrmAttendanceRuleService ruleService = mock(HrmAttendanceRuleService.class);
        HrmAttendanceRecordMapper mapper = mock(HrmAttendanceRecordMapper.class);
        ReflectionTestUtils.setField(service, "attendanceHolidayService", holidayService);
        ReflectionTestUtils.setField(service, "employeeService", employeeService);
        ReflectionTestUtils.setField(service, "attendanceRuleService", ruleService);
        ReflectionTestUtils.setField(service, "attendanceRecordMapper", mapper);
        LocalDateTime clockTime = LocalDateTime.of(2026, 10, 22, 18, 56);
        when(holidayService.isWorkDay(any(LocalDate.class))).thenReturn(true);
        HrmEmployeeDO employee = HrmEmployeeDO.builder().id(1L).deptId(2L).build();
        when(employeeService.getEmployee(1L)).thenReturn(employee);
        when(ruleService.getAttendanceRuleByDeptId(2L)).thenReturn(requiredRule());
        assertThrows(ServiceException.class,
                () -> service.clockOut(1L, clockTime, position("0.00091", "20"), null));
        verify(mapper, never()).insert(any(HrmAttendanceRecordDO.class));
        verify(mapper, never()).updateById(any(HrmAttendanceRecordDO.class));
    }
}