liu
3 天以前 21898888e7781959f746b1fe6814f7353ab4810f
考勤记录下载模板 导入计算迟到加班正常打卡
已添加2个文件
已修改7个文件
285 ■■■■■ 文件已修改
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordImportExcelVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordImportReqVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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.HrmAttendanceRecordImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportReqVO;
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.dal.dataobject.attendance.HrmAttendanceRecordDO;
@@ -142,6 +144,7 @@
            HrmEmployeeDO employee = employeeMap.get(respVO.getUserId());
            if (employee != null) {
                respVO.setUserName(employee.getName());
                respVO.setPhone(employee.getPhone());
            }
            // éƒ¨é—¨åç§°
            DeptRespDTO dept = deptMap.get(respVO.getDeptId());
@@ -166,6 +169,7 @@
            HrmEmployeeDO employee = employeeService.getEmployee(attendanceRecord.getUserId());
            if (employee != null) {
                respVO.setUserName(employee.getName());
                respVO.setPhone(employee.getPhone());
            }
        }
        if (attendanceRecord.getDeptId() != null) {
@@ -177,6 +181,104 @@
        return success(respVO);
    }
    @GetMapping("/get-import-template")
    @Operation(summary = "下载考勤记录导入模板")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:import')")
    public void importTemplate(HttpServletResponse response) throws IOException {
        List<HrmAttendanceRecordImportExcelVO> list = Collections.singletonList(
                HrmAttendanceRecordImportExcelVO.builder()
                        .userName("张三")
                        .phone("13800138000")
                        .date(java.time.LocalDate.of(2026, 1, 15))
                        .clockInTime(java.time.LocalDateTime.of(2026, 1, 15, 8, 30))
                        .clockOutTime(java.time.LocalDateTime.of(2026, 1, 15, 17, 30))
                        .clockInType(1)
                        .clockOutType(1)
                        .location("办公区")
                        .remark("示例数据")
                        .build()
        );
        ExcelUtils.write(response, "考勤导入模板.xls", "考勤记录", HrmAttendanceRecordImportExcelVO.class, list);
    }
    @PostMapping("/import")
    @Operation(summary = "导入考勤记录")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:import')")
    public CommonResult<String> importExcel(@Valid HrmAttendanceRecordImportReqVO importReqVO) throws IOException {
        List<HrmAttendanceRecordImportExcelVO> list = ExcelUtils.read(importReqVO.getFile(), HrmAttendanceRecordImportExcelVO.class);
        if (CollUtil.isEmpty(list)) {
            return success("没有可导入的数据");
        }
        int successCount = 0;
        Set<String> missingNames = new LinkedHashSet<>();
        Set<String> duplicateNames = new LinkedHashSet<>();
        Set<String> invalidRows = new LinkedHashSet<>();
        for (HrmAttendanceRecordImportExcelVO item : list) {
            if (item.getDate() == null) {
                invalidRows.add((item.getUserName() == null || item.getUserName().isBlank()) ? "未填姓名的行" : "考勤日期为空(" + item.getUserName().trim() + ")");
                continue;
            }
            if (item.getUserName() == null || item.getUserName().isBlank()) {
                invalidRows.add("考勤日期为 " + item.getDate() + " ä½†æœªå¡«å‘˜å·¥å§“名");
                continue;
            }
            List<HrmEmployeeDO> employees = employeeService.getEmployeeListByName(item.getUserName().trim());
            if (employees.isEmpty()) {
                missingNames.add(item.getUserName().trim());
                continue;
            }
            HrmEmployeeDO employee = employees.get(0);
            // å­˜åœ¨å¤šä¸ªåŒåå‘˜å·¥æ—¶ï¼Œç”¨æ‰‹æœºå·åŒºåˆ†
            if (employees.size() > 1) {
                String phone = item.getPhone() != null ? item.getPhone().trim() : null;
                List<HrmEmployeeDO> matchedByPhone = new ArrayList<>();
                if (phone != null && !phone.isEmpty()) {
                    for (HrmEmployeeDO emp : employees) {
                        if (phone.equals(emp.getPhone())) {
                            matchedByPhone.add(emp);
                        }
                    }
                }
                if (matchedByPhone.size() == 1) {
                    employee = matchedByPhone.get(0);
                } else {
                    duplicateNames.add(item.getUserName().trim());
                    continue;
                }
            }
            HrmAttendanceRecordDO record = attendanceRecordService.getAttendanceRecordByUserIdAndDate(employee.getId(), item.getDate());
            if (record == null) {
                record = HrmAttendanceRecordDO.builder()
                        .userId(employee.getId()).deptId(employee.getDeptId()).date(item.getDate())
                        .clockInTime(item.getClockInTime()).clockOutTime(item.getClockOutTime())
                        .clockInType(item.getClockInType()).clockOutType(item.getClockOutType())
                        .location(item.getLocation()).remark(item.getRemark())
                        .dataSource(2).build();
                attendanceRecordService.createImportedRecord(record);
            } else if (Boolean.TRUE.equals(importReqVO.getUpdateSupport())) {
                record.setClockInTime(item.getClockInTime());
                record.setClockOutTime(item.getClockOutTime());
                record.setClockInType(item.getClockInType());
                record.setClockOutType(item.getClockOutType());
                record.setLocation(item.getLocation());
                record.setRemark(item.getRemark());
                attendanceRecordService.updateImportedRecord(record);
            }
            successCount++;
        }
        StringBuilder msg = new StringBuilder("成功导入 " + successCount + " æ¡");
        if (!missingNames.isEmpty()) {
            msg.append(";以下员工不存在,未能导入:").append(String.join("、", missingNames));
        }
        if (!duplicateNames.isEmpty()) {
            msg.append(";以下员工存在多名同名员工,需补充手机号区分,未能导入:").append(String.join("、", duplicateNames));
        }
        if (!invalidRows.isEmpty()) {
            msg.append(";以下记录数据不完整,未能导入:").append(String.join("、", invalidRows));
        }
        return success(msg.toString());
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出考勤记录 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:export')")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordImportExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import cn.idev.excel.annotation.ExcelProperty;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * è€ƒå‹¤è®°å½• Excel å¯¼å…¥æ¨¡æ¿ VO
 */
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class HrmAttendanceRecordImportExcelVO {
    @ExcelProperty("员工姓名")
    private String userName;
    @ExcelProperty("手机号")
    private String phone;
    @ExcelProperty("考勤日期")
    private LocalDate date;
    @ExcelProperty("上班打卡时间")
    private LocalDateTime clockInTime;
    @ExcelProperty("下班打卡时间")
    private LocalDateTime clockOutTime;
    @ExcelProperty(value = "上班打卡类型", converter = DictConvert.class)
    @DictFormat("hrm_clock_type")
    private Integer clockInType;
    @ExcelProperty(value = "下班打卡类型", converter = DictConvert.class)
    @DictFormat("hrm_clock_type")
    private Integer clockOutType;
    @ExcelProperty("打卡地点")
    private String location;
    @ExcelProperty("备注")
    private String remark;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordImportReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
@Schema(description = "管理后台 - è€ƒå‹¤è®°å½•导入 Request VO")
@Data
public class HrmAttendanceRecordImportReqVO {
    @Schema(description = "Excel æ–‡ä»¶", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "Excel æ–‡ä»¶ä¸èƒ½ä¸ºç©º")
    private MultipartFile file;
    @Schema(description = "是否覆盖已存在的数据")
    private Boolean updateSupport = false;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java
@@ -34,6 +34,10 @@
    @ExcelProperty("部门名称")
    private String deptName;
    @Schema(description = "手机号")
    @ExcelProperty("手机号")
    private String phone;
    @Schema(description = "考勤日期")
    @ExcelProperty("考勤日期")
    private LocalDate date;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java
@@ -21,6 +21,14 @@
        return selectOne(HrmEmployeeDO::getPhone, phone);
    }
    default HrmEmployeeDO selectByName(String name) {
        return selectOne(HrmEmployeeDO::getName, name);
    }
    default List<HrmEmployeeDO> selectListByName(String name) {
        return selectList(HrmEmployeeDO::getName, name);
    }
    default HrmEmployeeDO selectByEmployeeNo(String employeeNo) {
        return selectOne(HrmEmployeeDO::getEmployeeNo, employeeNo);
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java
@@ -37,6 +37,12 @@
     */
    HrmAttendanceRecordDO getAttendanceRecordByUserIdAndDate(Long userId, LocalDate date);
    /** åˆ›å»ºå¯¼å…¥çš„考勤记录 */
    void createImportedRecord(HrmAttendanceRecordDO record);
    /** æ›´æ–°å¯¼å…¥çš„考勤记录 */
    void updateImportedRecord(HrmAttendanceRecordDO record);
    /**
     * æ‰“卡(上班打卡)
     *
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -67,6 +67,86 @@
    }
    @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 HrmAttendanceRecordDO clockIn(Long userId, LocalDateTime clockTime, String location) {
        LocalDate clockDate = clockTime.toLocalDate();
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java
@@ -99,6 +99,12 @@
     */
    HrmEmployeeDO getEmployeeByUserId(Long userId);
    /** æ ¹æ®å‘˜å·¥å§“名获取员工 */
    HrmEmployeeDO getEmployeeByName(String name);
    /** æ ¹æ®å‘˜å·¥å§“名获取所有同名员工 */
    List<HrmEmployeeDO> getEmployeeListByName(String name);
    /**
     * å¯¼å…¥å‘˜å·¥åˆ—表
     *
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -335,6 +335,16 @@
    }
    @Override
    public HrmEmployeeDO getEmployeeByName(String name) {
        return employeeMapper.selectByName(name);
    }
    @Override
    public List<HrmEmployeeDO> getEmployeeListByName(String name) {
        return employeeMapper.selectListByName(name);
    }
    @Override
    public HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> importEmployees,
                                                      HrmEmployeeImportReqVO importReqVO) {
        // 1. æ ¡éªŒéžç©º