tuqin
2 天以前 ffec90c897e9bd78e88b9fa590a5dd75d26b82c8
fix(hrm): 修复员工导入失败(日期格式 + 部门/岗位/角色不存在)

问题:蓝羽纸业导入员工表格时整体失败,前端只看到 500「系统异常」,
48 条数据一条都进不去。

根因(三层叠加,前一层挡住后一层):
1. 入职日期格式不兼容(致命,导致整个接口 500)
客户表格填的是「2026.3.30」(点分隔文本),
FastExcel 内置 LocalDateStringConverter 只认 yyyy-MM-dd,
抛 ExcelDataConvertException: can not find date format for:2026.3.30,
ExcelUtils.read() 直接异常,业务逻辑根本没执行,且是整批失败。

2. 部门不存在
表格填「综合车间」,系统里只有造纸车间/制浆车间/加工车间/包装车间,
而 deptId 在 HrmEmployeeSaveReqVO 上是 @NotNull(部门不能为空)。

3. 岗位/角色不存在
表格填「车间员工」,系统里只有造纸操作工/包装工/劳务工等;
表格「用户角色」列填的是「综合管理部」(其实是部门名,误填到角色列)。

改动:
1. 新增 FlexibleLocalDateConverter(excel framework)
兼容 yyyy-M-d / yyyy.M.d / yyyy/M/d / yyyy年M月d日 / yyyyMMdd / 带时间,
数字单元格按 Excel 日期序列号(基准 1899-12-30)解析;
解析不了时置空而不是抛异常,交给业务侧按「必填」规则逐条提示,
避免一条脏数据拖垮整批导入。
HrmEmployeeImportExcelVO 的 hireDate / birthday 挂上该转换器。

2. DeptApi/PostApi 新增 getOrCreateDeptByName / getOrCreatePostByName
按名称找不到时自动补建(部门挂到根部门下,岗位 code 用时间戳生成),
保证客户表格里新出现的部门/岗位不会阻塞导入。

3. HrmEmployeeServiceImpl 的 parseDeptId / parsePostIds 改用 getOrCreate;
parseRoleIds 改为「角色不存在则跳过 + 打警告」,
因为角色对员工导入是可选项,不该因为一列误填就整条失败。

验证(蓝羽 mom-lyzy 实测):
- 原表格 48 条数据,成功导入 47 条,1 条因手机号重复被正常拦截
- 自动创建部门「综合车间」(id=136)、岗位「车间员工」(id=42)
- 入职日期 2026.3.30 → 2026-03-30
- 出生日期 31990(Excel 序列号)→ 1987-08-01
- 性别/员工状态通过字典正常映射(男=1、在职=1)
已添加1个文件
已修改6个文件
227 ■■■■■ 文件已修改
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/FlexibleLocalDateConverter.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeImportExcelVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/DeptApi.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/DeptApiImpl.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/PostApi.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/PostApiImpl.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/FlexibleLocalDateConverter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
package cn.iocoder.yudao.framework.excel.core.convert;
import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
/**
 * å®½æ¾çš„ LocalDate è½¬æ¢å™¨
 *
 * <p>FastExcel å†…置的 {@code LocalDateStringConverter} åªè®¤ {@code yyyy-MM-dd},
 * è€Œå®¢æˆ·å®žé™…维护的表格里很常见 {@code 2026.3.30}、{@code 2026/3/30}、{@code 2026å¹´3月30日} è¿™ç±»å†™æ³•,
 * ä¸€æ—¦è§£æžä¸äº†ä¼šæŠ› {@code ExcelDataConvertException},导致整个导入直接失败(500),
 * ä¸€æ¡æ•°æ®éƒ½è¿›ä¸åŽ»ã€‚è¿™é‡Œåšå¤šæ ¼å¼å…¼å®¹ï¼Œè§£æžä¸äº†æ—¶ç½®ç©ºï¼Œäº¤ç»™ä¸šåŠ¡ä¾§æŒ‰ã€Œå¿…å¡«ã€è§„åˆ™é€æ¡æç¤ºã€‚
 *
 * @author è¶…级管理员
 */
@Slf4j
public class FlexibleLocalDateConverter implements Converter<LocalDate> {
    /**
     * Excel æ—¥æœŸåºåˆ—号的基准日(1899-12-30,兼容 Excel çš„ 1900 é—°å¹´ bug)
     */
    private static final LocalDate EXCEL_EPOCH = LocalDate.of(1899, 12, 30);
    /**
     * çº¯æ—¥æœŸæ ¼å¼ï¼ŒæŒ‰é¡ºåºå°è¯•
     */
    private static final List<DateTimeFormatter> DATE_FORMATTERS = Arrays.asList(
            DateTimeFormatter.ofPattern("yyyy-M-d"),
            DateTimeFormatter.ofPattern("yyyy.M.d"),
            DateTimeFormatter.ofPattern("yyyy/M/d"),
            DateTimeFormatter.ofPattern("yyyyå¹´M月d日"),
            DateTimeFormatter.ofPattern("yyyyMMdd")
    );
    /**
     * å¸¦æ—¶é—´çš„æ ¼å¼ï¼ŒæŒ‰é¡ºåºå°è¯•后取日期部分
     */
    private static final List<DateTimeFormatter> DATETIME_FORMATTERS = Arrays.asList(
            DateTimeFormatter.ofPattern("yyyy-M-d H:m:s"),
            DateTimeFormatter.ofPattern("yyyy-M-d H:m"),
            DateTimeFormatter.ofPattern("yyyy.M.d H:m:s"),
            DateTimeFormatter.ofPattern("yyyy/M/d H:m:s"),
            DateTimeFormatter.ofPattern("yyyy/M/d H:m")
    );
    @Override
    public Class<LocalDate> supportJavaTypeKey() {
        return LocalDate.class;
    }
    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        // ä¸é™å®šå•元格类型:文本和数字都要支持
        return null;
    }
    @Override
    public LocalDate convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty,
                                       GlobalConfiguration globalConfiguration) {
        if (cellData == null) {
            return null;
        }
        // 1. æ•°å­—:按 Excel æ—¥æœŸåºåˆ—号解析
        if (CellDataTypeEnum.NUMBER.equals(cellData.getType())) {
            BigDecimal numberValue = cellData.getNumberValue();
            return numberValue == null ? null : EXCEL_EPOCH.plusDays(numberValue.longValue());
        }
        // 2. æ–‡æœ¬ï¼šå¤šæ ¼å¼ä¾æ¬¡å°è¯•
        String text = cellData.getStringValue();
        if (text == null || text.trim().isEmpty()) {
            return null;
        }
        text = text.trim();
        for (DateTimeFormatter formatter : DATE_FORMATTERS) {
            try {
                return LocalDate.parse(text, formatter);
            } catch (Exception ignored) {
                // ç»§ç»­å°è¯•下一个格式
            }
        }
        for (DateTimeFormatter formatter : DATETIME_FORMATTERS) {
            try {
                return LocalDateTime.parse(text, formatter).toLocalDate();
            } catch (Exception ignored) {
                // ç»§ç»­å°è¯•下一个格式
            }
        }
        log.warn("[convertToJavaData][日期({}) æ— æ³•识别,已置空]", text);
        return null;
    }
    @Override
    public WriteCellData<?> convertToExcelData(LocalDate value, ExcelContentProperty contentProperty,
                                               GlobalConfiguration globalConfiguration) {
        if (value == null) {
            return new WriteCellData<>("");
        }
        return new WriteCellData<>(value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeImportExcelVO.java
@@ -3,6 +3,7 @@
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 cn.iocoder.yudao.framework.excel.core.convert.FlexibleLocalDateConverter;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -38,7 +39,7 @@
    @ExcelProperty("用户角色")
    private String roleNames;
    @ExcelProperty("入职日期")
    @ExcelProperty(value = "入职日期", converter = FlexibleLocalDateConverter.class)
    private LocalDate hireDate;
    @ExcelProperty(value = "员工状态", converter = DictConvert.class)
@@ -48,7 +49,7 @@
    @ExcelProperty("身份证号")
    private String idCard;
    @ExcelProperty("出生日期")
    @ExcelProperty(value = "出生日期", converter = FlexibleLocalDateConverter.class)
    private LocalDate birthday;
    @ExcelProperty("民族")
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -3,6 +3,7 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -62,6 +63,7 @@
 */
@Service
@Validated
@Slf4j
public class HrmEmployeeServiceImpl implements HrmEmployeeService {
    @Resource
@@ -470,11 +472,18 @@
        if (StrUtil.isBlank(deptName)) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "部门名称");
        }
        DeptRespDTO dept = deptMap.get(deptName.trim());
        if (dept == null) {
            throw exception(EMPLOYEE_DEPT_NAME_NOT_EXISTS, deptName.trim());
        String trimmed = deptName.trim();
        DeptRespDTO dept = deptMap.get(trimmed);
        if (dept != null) {
            return dept.getId();
        }
        return dept.getId();
        // å®¢æˆ·è¡¨æ ¼é‡Œçš„部门名可能尚未在系统中建立(如「综合车间」):
        // éƒ¨é—¨æ˜¯å‘˜å·¥çš„必填项,直接报错会让整批导入全部失败,这里自动补建到根部门下
        Long deptId = deptApi.getOrCreateDeptByName(trimmed);
        if (deptId == null) {
            throw exception(EMPLOYEE_DEPT_NAME_NOT_EXISTS, trimmed);
        }
        return deptId;
    }
    private Set<Long> parsePostIds(String postNames, Map<String, PostRespDTO> postMap) {
@@ -488,10 +497,15 @@
                continue;
            }
            PostRespDTO post = postMap.get(trimmed);
            if (post == null) {
                throw exception(EMPLOYEE_POST_NAME_NOT_EXISTS, trimmed);
            if (post != null) {
                postIds.add(post.getId());
                continue;
            }
            postIds.add(post.getId());
            // å²—位未建立时自动补建,避免整批导入失败
            Long postId = postApi.getOrCreatePostByName(trimmed);
            if (postId != null) {
                postIds.add(postId);
            }
        }
        if (CollUtil.isEmpty(postIds)) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "岗位名称");
@@ -511,7 +525,9 @@
            }
            RoleRespDTO role = roleMap.get(trimmed);
            if (role == null) {
                throw exception(EMPLOYEE_ROLE_NAME_NOT_EXISTS, trimmed);
                // è§’色对员工导入是可选项,表格里填了系统没有的角色名(常见是把部门名误填到角色列)时跳过,不阻塞导入
                log.warn("[parseRoleIds][角色({}) ä¸å­˜åœ¨ï¼Œå·²è·³è¿‡]", trimmed);
                continue;
            }
            roleIds.add(role.getId());
        }
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/DeptApi.java
@@ -65,4 +65,16 @@
     */
    List<DeptRespDTO> getChildDeptList(Long id);
    /**
     * æŒ‰åç§°èŽ·å¾—éƒ¨é—¨ç¼–å·ï¼Œä¸å­˜åœ¨æ—¶è‡ªåŠ¨åˆ›å»ºï¼ˆæŒ‚åœ¨æ ¹éƒ¨é—¨ä¸‹ï¼‰
     *
     * <p>用于 Excel å¯¼å…¥ç­‰ã€Œåªæœ‰åç§°æ²¡æœ‰ç¼–号」的场景:员工导入时部门是必填项,
     * è‹¥å®¢æˆ·è¡¨æ ¼é‡Œçš„部门名在系统中尚未建立,直接报错会导致整批导入失败,
     * è¿™é‡Œæ”¹ä¸ºè‡ªåŠ¨è¡¥å»ºï¼Œä¿è¯å¯¼å…¥å¯ç”¨ã€‚
     *
     * @param name éƒ¨é—¨åç§°
     * @return éƒ¨é—¨ç¼–号;名称为空时返回 {@code null}
     */
    Long getOrCreateDeptByName(String name);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/DeptApiImpl.java
@@ -1,8 +1,11 @@
package cn.iocoder.yudao.module.system.api.dept;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
import cn.iocoder.yudao.module.system.service.dept.DeptService;
import org.springframework.stereotype.Service;
@@ -51,4 +54,31 @@
        return BeanUtils.toBean(childDeptList, DeptRespDTO.class);
    }
    @Override
    public Long getOrCreateDeptByName(String name) {
        String deptName = StrUtil.trim(name);
        if (StrUtil.isEmpty(deptName)) {
            return null;
        }
        List<DeptRespDTO> depts = getDeptList();
        // 1. å·²å­˜åœ¨ï¼šç›´æŽ¥å¤ç”¨
        for (DeptRespDTO dept : depts) {
            if (deptName.equals(StrUtil.trim(dept.getName()))) {
                return dept.getId();
            }
        }
        // 2. ä¸å­˜åœ¨ï¼šè¡¥å»ºåˆ°æ ¹éƒ¨é—¨ä¸‹
        Long parentId = depts.stream()
                .filter(dept -> dept.getParentId() == null || dept.getParentId() == 0L)
                .map(DeptRespDTO::getId)
                .findFirst()
                .orElse(0L);
        DeptSaveReqVO createReqVO = new DeptSaveReqVO();
        createReqVO.setName(deptName);
        createReqVO.setParentId(parentId);
        createReqVO.setSort(0);
        createReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
        return deptService.createDept(createReqVO);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/PostApi.java
@@ -34,6 +34,16 @@
     */
    List<PostRespDTO> getPostList();
    /**
     * æŒ‰åç§°èŽ·å¾—å²—ä½ç¼–å·ï¼Œä¸å­˜åœ¨æ—¶è‡ªåŠ¨åˆ›å»º
     *
     * <p>用于 Excel å¯¼å…¥ç­‰ã€Œåªæœ‰åç§°æ²¡æœ‰ç¼–号」的场景,避免因岗位尚未建立导致整批导入失败。
     *
     * @param name å²—位名称
     * @return å²—位编号;名称为空时返回 {@code null}
     */
    Long getOrCreatePostByName(String name);
    default Map<Long, PostRespDTO> getPostMap(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return MapUtil.empty();
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/dept/PostApiImpl.java
@@ -1,7 +1,10 @@
package cn.iocoder.yudao.module.system.api.dept;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO;
import cn.iocoder.yudao.module.system.controller.admin.dept.vo.post.PostSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.PostDO;
import cn.iocoder.yudao.module.system.service.dept.PostService;
import org.springframework.stereotype.Service;
@@ -38,4 +41,26 @@
        return BeanUtils.toBean(list, PostRespDTO.class);
    }
    @Override
    public Long getOrCreatePostByName(String name) {
        String postName = StrUtil.trim(name);
        if (StrUtil.isEmpty(postName)) {
            return null;
        }
        List<PostRespDTO> posts = getPostList();
        // 1. å·²å­˜åœ¨ï¼šç›´æŽ¥å¤ç”¨
        for (PostRespDTO post : posts) {
            if (postName.equals(StrUtil.trim(post.getName()))) {
                return post.getId();
            }
        }
        // 2. ä¸å­˜åœ¨ï¼šè¡¥å»ºã€‚code å¿…填且需唯一,用时间戳生成
        PostSaveReqVO createReqVO = new PostSaveReqVO();
        createReqVO.setName(postName);
        createReqVO.setCode("POST_" + System.currentTimeMillis());
        createReqVO.setSort(0);
        createReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
        return postService.createPost(createReqVO);
    }
}