diff --git a/.DS_Store b/.DS_Store index b6a1e2df..9c73533e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java index d1df8570..6c9a45f7 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java @@ -2,19 +2,36 @@ package com.ruoyi.info.collection.utils; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.write.builder.ExcelWriterBuilder; import com.alibaba.excel.write.metadata.WriteSheet; import com.alibaba.excel.write.handler.WriteHandler; import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; +import com.ruoyi.common.annotation.DictDropdown; +import com.ruoyi.common.exception.ServiceException; import com.ruoyi.info.collection.handler.DictDropdownWriteHandler; import com.ruoyi.info.collection.handler.RequiredFieldWriteHandler; import com.ruoyi.info.collection.handler.TextFormatWriteHandler; import jakarta.servlet.http.HttpServletResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataValidation; +import org.apache.poi.ss.usermodel.DataValidationConstraint; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.CellRangeAddress; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; /** @@ -77,8 +94,10 @@ public class EasyExcelUtil { * @return 数据列表 */ public static List importExcel(String fileName, Class clazz) { - try { - return EasyExcel.read(fileName).head(clazz).sheet().doReadSync(); + try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) { + return importExcel(inputStream, clazz); + } catch (ServiceException e) { + throw e; } catch (Exception e) { throw new RuntimeException("导入Excel失败", e); } @@ -94,7 +113,11 @@ public class EasyExcelUtil { */ public static List importExcel(java.io.InputStream inputStream, Class clazz) { try { - return EasyExcel.read(inputStream).head(clazz).sheet().doReadSync(); + byte[] bytes = inputStream.readAllBytes(); + validateDictDropdownTemplate(bytes, clazz, null); + return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync(); + } catch (ServiceException e) { + throw e; } catch (Exception e) { throw new RuntimeException("导入Excel失败", e); } @@ -111,7 +134,11 @@ public class EasyExcelUtil { */ public static List importExcel(java.io.InputStream inputStream, Class clazz, String sheetName) { try { - return EasyExcel.read(inputStream).head(clazz).sheet(sheetName).doReadSync(); + byte[] bytes = inputStream.readAllBytes(); + validateDictDropdownTemplate(bytes, clazz, sheetName); + return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync(); + } catch (ServiceException e) { + throw e; } catch (Exception e) { throw new RuntimeException("导入Excel失败", e); } @@ -128,7 +155,7 @@ public class EasyExcelUtil { public static void importTemplateExcel(HttpServletResponse response, Class clazz, String sheetName) { try { setResponseHeader(response, sheetName + "模板"); - EasyExcel.write(response.getOutputStream(), clazz) + templateWriter(response, clazz) .sheet(sheetName) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .doWrite(List.of()); @@ -151,7 +178,7 @@ public class EasyExcelUtil { WriteHandler... handlers) { try { setResponseHeader(response, sheetName + "模板"); - var writerBuilder = EasyExcel.write(response.getOutputStream(), clazz) + var writerBuilder = templateWriter(response, clazz) .sheet(sheetName) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()); // 注册所有自定义处理器 @@ -190,7 +217,7 @@ public class EasyExcelUtil { public static void importTemplateWithDictDropdown(HttpServletResponse response, Class clazz, String sheetName) { try { setResponseHeader(response, sheetName + "模板"); - EasyExcel.write(response.getOutputStream(), clazz) + templateWriter(response, clazz) .sheet(sheetName) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .registerWriteHandler(new DictDropdownWriteHandler(clazz)) @@ -217,7 +244,7 @@ public class EasyExcelUtil { String sheetName, String fileName) { try { setResponseHeader(response, fileName); - EasyExcel.write(response.getOutputStream(), clazz) + templateWriter(response, clazz) .sheet(sheetName) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .registerWriteHandler(new DictDropdownWriteHandler(clazz)) @@ -250,7 +277,7 @@ public class EasyExcelUtil { String fileName ) { setResponseHeader(response, fileName); - try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) { + try (ExcelWriter writer = templateWriter(response).build()) { writer.write(List.of(), buildTemplateSheet(0, firstClazz, firstSheetName)); writer.write(List.of(), buildTemplateSheet(1, secondClazz, secondSheetName)); } catch (IOException e) { @@ -322,4 +349,137 @@ public class EasyExcelUtil { throw new RuntimeException("导出带字典下拉框的Excel失败", e); } } + + private static void validateDictDropdownTemplate(byte[] bytes, Class clazz, String sheetName) { + List dropdownColumns = resolveDropdownColumns(clazz); + if (dropdownColumns.isEmpty()) { + return; + } + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) { + Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName); + if (sheet == null) { + return; + } + + int lastDataRowIndex = findLastDataRowIndex(sheet); + if (lastDataRowIndex < 1) { + return; + } + + List missingColumnTitles = new ArrayList<>(); + for (DropdownColumn column : dropdownColumns) { + if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) { + missingColumnTitles.add(column.title()); + } + } + if (!missingColumnTitles.isEmpty()) { + throw new ServiceException(sheet.getSheetName() + " Sheet 的 " + + String.join("、", missingColumnTitles) + + " 列缺少下拉框,请下载最新导入模板填写后重新导入"); + } + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("导入Excel失败", e); + } + } + + private static List resolveDropdownColumns(Class clazz) { + List columns = new ArrayList<>(); + Class current = clazz; + while (current != null && current != Object.class) { + for (Field field : current.getDeclaredFields()) { + if (field.getAnnotation(DictDropdown.class) == null) { + continue; + } + + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty == null || excelProperty.index() < 0) { + continue; + } + + columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty))); + } + current = current.getSuperclass(); + } + columns.sort(Comparator.comparingInt(DropdownColumn::index)); + return columns; + } + + private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) { + if (excelProperty.value().length > 0 && excelProperty.value()[0] != null + && !excelProperty.value()[0].isBlank()) { + return excelProperty.value()[0].replace("*", ""); + } + return field.getName(); + } + + private static int findLastDataRowIndex(Sheet sheet) { + int lastDataRowIndex = -1; + for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (hasData(row)) { + lastDataRowIndex = rowIndex; + } + } + return lastDataRowIndex; + } + + private static boolean hasData(Row row) { + if (row == null || row.getLastCellNum() < 0) { + return false; + } + for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) { + if (cellIndex < 0) { + continue; + } + Cell cell = row.getCell(cellIndex); + if (cell != null && cell.toString() != null && !cell.toString().isBlank()) { + return true; + } + } + return false; + } + + private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) { + boolean[] coveredRows = new boolean[lastDataRowIndex + 1]; + for (DataValidation validation : sheet.getDataValidations()) { + DataValidationConstraint constraint = validation.getValidationConstraint(); + if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) { + continue; + } + + for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) { + if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) { + continue; + } + + int firstRow = Math.max(1, address.getFirstRow()); + int lastRow = Math.min(lastDataRowIndex, address.getLastRow()); + for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) { + coveredRows[rowIndex] = true; + } + } + } + + for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) { + if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) { + return false; + } + } + return true; + } + + private record DropdownColumn(int index, String title) {} + + private static ExcelWriterBuilder templateWriter(HttpServletResponse response, Class clazz) + throws IOException { + // 模板为空且体量小,使用内存工作簿避免 SXSSF 在无字体环境初始化 Fontconfig。 + return EasyExcel.write(response.getOutputStream(), clazz).inMemory(Boolean.TRUE); + } + + private static ExcelWriterBuilder templateWriter(HttpServletResponse response) throws IOException { + return EasyExcel.write(response.getOutputStream()).inMemory(Boolean.TRUE); + } } diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml index ebd31dc2..c57d5395 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml @@ -42,7 +42,7 @@ AND e.status = #{query.status} - ORDER BY e.create_time DESC + ORDER BY e.create_time DESC, e.staff_id DESC diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java index d1443071..67b9d2e5 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java @@ -21,4 +21,15 @@ class CcdiBaseStaffMapperTest { assertTrue(xml.contains("#{item.partyMember}"), xml); } } + + @Test + void mapperXml_shouldUseStableOrderForBaseStaffPagination() throws Exception { + try (InputStream inputStream = getClass().getClassLoader() + .getResourceAsStream("mapper/info/collection/CcdiBaseStaffMapper.xml")) { + String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8) + .replaceAll("\\s+", " "); + + assertTrue(xml.contains("ORDER BY e.create_time DESC, e.staff_id DESC"), xml); + } + } } diff --git a/docs/reports/implementation/2026-04-30-base-staff-import-browser-use-test.md b/docs/reports/implementation/2026-04-30-base-staff-import-browser-use-test.md new file mode 100644 index 00000000..099067e3 --- /dev/null +++ b/docs/reports/implementation/2026-04-30-base-staff-import-browser-use-test.md @@ -0,0 +1,89 @@ +# 员工信息维护导入功能真实页面测试记录 + +## 测试范围 + +- 页面:`http://localhost:8080/maintain/baseStaff` +- 后端:`http://localhost:62318` +- 模块:员工信息维护导入 +- 测试方式:`browser-use` 打开真实业务页面,确认导入入口、模板下载和页面列表/详情展示;使用真实模板生成测试工作簿后调用同一导入接口上传,轮询导入任务状态并查询失败记录。 + +## 测试文件 + +- 真实模板:`output/spreadsheet/base_staff_import_template_20260430.xlsx` +- 空模板:`output/spreadsheet/base_staff_import_empty_20260430.xlsx` +- 员工成功样本:`output/spreadsheet/base_staff_import_staff_success_20260430.xlsx` +- 员工混合失败样本:`output/spreadsheet/base_staff_import_staff_mixed_20260430.xlsx` +- 资产成功样本:`output/spreadsheet/base_staff_import_asset_success_20260430.xlsx` +- 资产混合失败样本:`output/spreadsheet/base_staff_import_asset_mixed_20260430.xlsx` +- 双 Sheet 成功样本:`output/spreadsheet/base_staff_import_dual_success_20260430.xlsx` + +## 页面验证 + +1. 打开真实员工信息维护页面,确认页面标题、查询区、列表、单一“导入”按钮加载正常。 +2. 打开“员工信息维护导入”弹窗,确认存在“下载模板”入口。 +3. 点击“下载模板”,页面触发真实模板下载。 +4. 弹窗提示确认: + - 模板包含“员工信息”和“员工资产信息”两个 Sheet。 + - 两个 Sheet 可单独填写,也可同时填写。 + - 员工信息命中现有员工直接报错。 + - 资产信息仅支持员工本人资产。 +5. 页面查询员工 `9843001`,确认成功导入员工 `Codex导入员工A` 可在真实列表展示。 +6. 打开员工 `9843001` 详情,确认资产 `Codex资产住宅A` 展示在资产信息区。 +7. 页面查询员工 `9843004`,确认双 Sheet 成功样本中的员工 `Codex导入员工D` 可在真实列表展示。 + +## 接口与异步任务验证 + +| 场景 | 文件 | 结果 | +| --- | --- | --- | +| 空模板 | `base_staff_import_empty_20260430.xlsx` | 返回 `code=500`,提示“至少需要一条数据” | +| 员工 Sheet 成功 | `base_staff_import_staff_success_20260430.xlsx` | 员工任务 `8afb5d0d-009d-4460-b9a5-e66b35716506`,`SUCCESS`,总数 1,成功 1,失败 0 | +| 员工 Sheet 混合失败 | `base_staff_import_staff_mixed_20260430.xlsx` | 员工任务 `c57fc240-c50f-42a7-8d77-1614c46789ec`,`PARTIAL_SUCCESS`,总数 7,成功 1,失败 6 | +| 资产 Sheet 成功 | `base_staff_import_asset_success_20260430.xlsx` | 资产任务 `8f266611-10ef-471f-8cc7-92b5b65cac45`,`SUCCESS`,总数 1,成功 1,失败 0 | +| 资产 Sheet 混合失败 | `base_staff_import_asset_mixed_20260430.xlsx` | 资产任务 `314e1ec1-cf3c-4145-a60f-96f2e8311949`,`PARTIAL_SUCCESS`,总数 5,成功 1,失败 4 | +| 双 Sheet 同时成功 | `base_staff_import_dual_success_20260430.xlsx` | 员工任务 `cacb797b-f308-4766-8470-d32061f0a964` 成功 1;资产任务 `08d02f9d-266e-49a1-a52a-fff29426e033` 成功 1 | + +## 失败记录核对 + +员工混合失败记录包含 `sheetName=员工信息`、准确 `rowNum` 和失败原因: + +- 第 2 行:员工 ID 已存在。 +- 第 3 行:姓名不能为空。 +- 第 4 行:所属部门 ID 不存在或已停用/删除。 +- 第 5 行:身份证号长度必须为 18 位。 +- 第 6 行:年收入不能为负数。 +- 第 8 行:员工 ID 在导入文件中重复。 + +资产混合失败记录包含 `sheetName=员工资产信息`、准确 `rowNum` 和失败原因: + +- 第 2 行:资产记录已存在。 +- 第 3 行:员工资产导入仅支持员工本人证件号。 +- 第 4 行:资产名称不能为空。 +- 第 6 行:资产记录在导入文件中重复。 + +## 数据回查 + +- 员工 `9843001`:列表接口返回 1 条,姓名 `Codex导入员工A`,部门 `研发部门`,状态 `在职`。 +- 员工 `9843003`:列表接口返回 1 条,姓名 `Codex导入员工C`,验证混合失败文件中的成功行已入库。 +- 员工 `9843004`:列表接口返回 1 条,姓名 `Codex导入员工D`,验证双 Sheet 员工任务成功入库。 +- 员工 `9843001` 详情接口返回 3 条本轮导入资产: + - `Codex资产住宅A` + - `Codex资产车辆重复` + - `Codex资产商铺D` + +## 发现的问题 + +- 员工 `9843001` 列表中 `deptName=研发部门`,但详情接口返回 `deptName=null`,页面详情“所属部门”显示为 `-`。该问题不阻断导入成功,但属于员工详情展示链路的现存缺陷。 + +## 清理范围 + +本轮成功写入的测试员工: + +- `9843001` +- `9843003` +- `9843004` + +删除员工时,当前后端会同步删除员工身份证号对应的本人资产数据。 + +## 结论 + +员工信息维护导入主链路通过:双 Sheet 单入口、空数据拦截、员工新增、员工重复/必填/部门/证件/金额校验、资产新增、资产重复/归属/必填校验、失败记录 Sheet 与行号定位、页面列表与详情资产展示均已验证。需要单独处理员工详情部门名称缺失问题。 diff --git a/docs/reports/implementation/2026-04-30-base-staff-pagination-stable-order-implementation.md b/docs/reports/implementation/2026-04-30-base-staff-pagination-stable-order-implementation.md new file mode 100644 index 00000000..f02ee997 --- /dev/null +++ b/docs/reports/implementation/2026-04-30-base-staff-pagination-stable-order-implementation.md @@ -0,0 +1,39 @@ +# 员工信息维护分页重复修复实施记录 + +## 问题 + +- 页面:`http://localhost:1025/maintain/baseStaff` +- 接口:`GET /ccdi/baseStaff/list` +- 现象:第一页和第二页存在相同员工。 +- 复现结果:接口第一页和第二页出现 `9020004`、`9020005`、`9020009`、`9020003`、`9020008` 等重复员工。 + +## 原因 + +员工列表 SQL 仅按 `create_time DESC` 排序。批量导入的员工会在同一批次写入相同 `create_time`,数据库在相同排序值之间没有稳定顺序,分页时可能导致同一员工跨页重复出现。 + +## 修改内容 + +- `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml` + - 将员工分页列表排序从 `ORDER BY e.create_time DESC` 调整为 `ORDER BY e.create_time DESC, e.staff_id DESC`。 + - 使用唯一的 `staff_id` 作为二级排序字段,保证同一创建时间内分页顺序稳定。 +- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java` + - 增加 Mapper XML 断言,防止后续误删稳定分页排序。 + +## 影响范围 + +- 仅影响员工信息维护列表分页顺序。 +- 不改变筛选条件、列表字段、总数统计、导入、新增、编辑、删除逻辑。 + +## 验证 + +- 修改前已通过后端接口复现第一页和第二页员工重复。 +- `node -e` 轻量 XML 断言通过,确认 Mapper 中包含 `ORDER BY e.create_time DESC, e.staff_id DESC`。 +- `mvn -pl ruoyi-admin -am clean package -DskipTests` 构建通过。 +- `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffMapperTest -Dsurefire.failIfNoSpecifiedTests=false test` 通过,`CcdiBaseStaffMapperTest` 共 2 个用例全部通过。 +- 后端接口复验: + - 第 1 页:`9020018` 至 `9020009`。 + - 第 2 页:`9020008` 至 `9020001`,并继续显示后续员工。 + - 第 1 页与第 2 页 `staffId` 交集为 `none`。 +- browser-use 打开真实页面 `http://localhost:1025/maintain/baseStaff` 验证: + - 第 1 页首行显示 `许平 / 9020018`。 + - 点击分页第 2 页后,页面首行显示 `郑丽 / 9020008`,未再显示第一页首行员工。 diff --git a/docs/reports/implementation/2026-04-30-easyexcel-import-template-fontconfig-fix.md b/docs/reports/implementation/2026-04-30-easyexcel-import-template-fontconfig-fix.md new file mode 100644 index 00000000..7c0a6cfd --- /dev/null +++ b/docs/reports/implementation/2026-04-30-easyexcel-import-template-fontconfig-fix.md @@ -0,0 +1,35 @@ +# EasyExcel 导入模板 Fontconfig 异常修复实施记录 + +## 保存路径确认 + +- 文档类型:实施记录 +- 保存路径:`docs/reports/implementation/` + +## 问题说明 + +- 请求地址:`/ccdi/baseStaff/importTemplate` +- 异常信息:`java.lang.RuntimeException: Fontconfig head is null, check your fonts or fonts configuration` +- 触发链路:员工信息维护导入模板下载使用 `EasyExcelUtil.importTemplateWithDictDropdown` 生成双 Sheet 模板,EasyExcel 默认创建 `SXSSFWorkbook`,在无可用字体配置的服务器环境中创建 Sheet 时会触发 POI/AWT 字体配置初始化异常。 + +## 修改内容 + +- 修改 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java` +- 将导入模板下载相关写出入口统一改为 `inMemory(Boolean.TRUE)`: + - 单 Sheet 普通模板 + - 单 Sheet 带字典下拉模板 + - 指定文件名的带字典下拉模板 + - 双 Sheet 带字典下拉模板 +- 普通数据导出入口保持流式写出,不改变大数据导出的内存特性。 + +## 影响范围 + +- 修复员工信息维护 `/ccdi/baseStaff/importTemplate` 模板下载。 +- 同步覆盖复用同一工具方法的其他导入模板下载接口。 +- 不改变导入解析逻辑、模板表头、字典下拉、必填标记和文本格式处理逻辑。 + +## 验证记录 + +- 已执行:`mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:失败,失败原因是本机 JDK 21 下 Mockito inline 自附加失败,错误为 `Could not self-attach to current VM`,未进入模板业务断言。 +- 已执行:`mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest -Dsurefire.failIfNoSpecifiedTests=false -DargLine=-javaagent:/Users/wkc/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar test` + - 结果:通过,`Tests run: 5, Failures: 0, Errors: 0, Skipped: 0`。 diff --git a/docs/reports/implementation/2026-05-06-import-dropdown-validation-implementation.md b/docs/reports/implementation/2026-05-06-import-dropdown-validation-implementation.md new file mode 100644 index 00000000..10a05a70 --- /dev/null +++ b/docs/reports/implementation/2026-05-06-import-dropdown-validation-implementation.md @@ -0,0 +1,42 @@ +# 导入模板下拉框结构校验实施记录 + +## 修改背景 + +员工信息维护导入文件中存在字典列下拉框被删除的情况,例如“状态”列不是模板下拉框。此类文件不应进入业务导入流程,需要在导入解析阶段直接报错,且所有使用 `@DictDropdown` 的导入类都应统一校验。 + +## 修改内容 + +- 在 `EasyExcelUtil.importExcel(...)` 入口增加导入模板结构校验。 +- 校验范围为导入实体类中带 `@DictDropdown` 且配置了 `@ExcelProperty(index = ...)` 的列。 +- 对有实际数据的行逐行检查对应列是否被 `LIST` 类型数据验证覆盖。 +- 同一个 Sheet 内存在多个缺失下拉框列时,合并列名一次性提示,例如: + - `员工信息 Sheet 的 是否党员、状态 列缺少下拉框,请下载最新导入模板填写后重新导入` +- 无 `@DictDropdown` 的普通导入类不执行该结构校验。 + +## 影响范围 + +- 统一影响 `EasyExcelUtil.importExcel(InputStream, Class)`、`EasyExcelUtil.importExcel(InputStream, Class, String)` 和文件路径导入入口。 +- 员工信息维护、员工资产、亲属关系、招聘信息、实体库等复用该工具类且存在 `@DictDropdown` 字段的导入功能都会具备下拉框结构校验。 +- 不改变模板下载逻辑,不改变业务字段枚举值校验和异步导入逻辑。 + +## 验证结果 + +- 已执行 Maven 回归: + - `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest,EasyExcelUtilTemplateTest,CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest -Dsurefire.failIfNoSpecifiedTests=false -DargLine="-javaagent:/Users/wkc/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar" test` + - 结果:32 个测试全部通过。 +- 已检查用户提供的文件: + - `/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx` + - `员工信息` Sheet 数据验证数量为 0。 + - `员工资产信息` Sheet 数据验证数量为 0。 +- 已重启后端并调用真实导入接口上传该文件: + - `POST http://localhost:62318/ccdi/baseStaff/importData` + - 返回:`{"msg":"员工信息 Sheet 的 是否党员、状态 列缺少下拉框,请下载最新导入模板填写后重新导入","code":500}` +- 已使用 browser-use 打开真实页面: + - `http://localhost:1025/maintain/baseStaff` + - 已进入“员工信息维护导入”弹窗,确认页面入口和真实业务页可访问。 + - 当前 browser-use 可用 API 不支持直接给隐藏文件选择框设置本地文件,上传动作改用真实后端接口验证。 + +## 清理说明 + +- 本轮异常导入在模板结构校验阶段被拦截,未提交异步导入任务,未写入员工或资产业务数据。 +- 测试过程中未生成需要提交到 Git 的测试文件。