Add import dropdown validation
This commit is contained in:
@@ -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 <T> List<T> importExcel(String fileName, Class<T> 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 <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> 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 <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> 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 <T> void importTemplateExcel(HttpServletResponse response, Class<T> 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 <T> void importTemplateWithDictDropdown(HttpServletResponse response, Class<T> 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<DropdownColumn> 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<String> 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<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
|
||||
List<DropdownColumn> 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 <T> ExcelWriterBuilder templateWriter(HttpServletResponse response, Class<T> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
AND e.status = #{query.status}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY e.create_time DESC
|
||||
ORDER BY e.create_time DESC, e.staff_id DESC
|
||||
</select>
|
||||
|
||||
<!-- 批量插入或更新员工信息(只更新非null字段) -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 与行号定位、页面列表与详情资产展示均已验证。需要单独处理员工详情部门名称缺失问题。
|
||||
@@ -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`,未再显示第一页首行员工。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 导入模板下拉框结构校验实施记录
|
||||
|
||||
## 修改背景
|
||||
|
||||
员工信息维护导入文件中存在字典列下拉框被删除的情况,例如“状态”列不是模板下拉框。此类文件不应进入业务导入流程,需要在导入解析阶段直接报错,且所有使用 `@DictDropdown` 的导入类都应统一校验。
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 在 `EasyExcelUtil.importExcel(...)` 入口增加导入模板结构校验。
|
||||
- 校验范围为导入实体类中带 `@DictDropdown` 且配置了 `@ExcelProperty(index = ...)` 的列。
|
||||
- 对有实际数据的行逐行检查对应列是否被 `LIST` 类型数据验证覆盖。
|
||||
- 同一个 Sheet 内存在多个缺失下拉框列时,合并列名一次性提示,例如:
|
||||
- `员工信息 Sheet 的 是否党员、状态 列缺少下拉框,请下载最新导入模板填写后重新导入`
|
||||
- 无 `@DictDropdown` 的普通导入类不执行该结构校验。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 统一影响 `EasyExcelUtil.importExcel(InputStream, Class<T>)`、`EasyExcelUtil.importExcel(InputStream, Class<T>, 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 的测试文件。
|
||||
Reference in New Issue
Block a user